Compare commits

..

108 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
Matt
ef1bf24388 Fix evaluation criteria, jury preferences, assignment config, and dashboard stats
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s
- Fix criteria not showing for jurors: fetch active form independently via
  getStageForm query instead of relying on existing evaluation record
- Fix scoringMode default from 'global' to 'criteria' (matching schema)
- Parse scale string format ("1-10") into minScore/maxScore for criteria display
- Fix COI dialog dismissal: prevent outside click on evaluate page Dialog
- Fix requiredReviews hardcoded to 3: read from round configJson in 4 locations
- Add jury preferences banner for unconfirmed caps on jury dashboard
- Add updateJuryPreferences tRPC procedure for self-service cap/ratio
- Simplify onboarding: always show jury step, allow cap up to 50
- Add role/ratio/availability fields to jury member invite dialog
- Simplify jury group settings (keep only defaultMaxAssignments)
- Enforce deliberation showCollectiveRankings flag for non-admin users
- Redesign dashboard stat cards: editorial data strip on mobile,
  clean grid layout on desktop (no more generic card pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:33:20 +01:00
Matt
f9016168e7 UI fixes: onboarding scroll, expertise tags, jury assignments view
- Fix onboarding card overflow (overflow-hidden → overflow-x-hidden) so
  expertise step can scroll to submit button
- Reduce expertise category list height (max-h-64 → max-h-48)
- Add color dots to expertise tag options matching admin display
- Single-column layout for expertise tags (no truncation)
- Ocean background on onboarding (matches email template)
- Rewrite jury competitions page as assignment-centric grouped by round
- Conditionally show Awards nav item only when juror has award assignments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:48:14 +01:00
Matt
a006c6505c Onboarding: use ocean background image, show full expertise tag names
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m32s
- Replace blue gradient with ocean.png background (matches email templates)
- Display expertise tags one per line with full names (no truncation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:38:26 +01:00
Matt
d80043c4aa Strip null bytes from extracted text to fix PostgreSQL UTF-8 errors
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some PDFs contain \x00 null bytes in their text which PostgreSQL rejects
with "invalid byte sequence for encoding UTF8: 0x00". Sanitize extracted
text in both document-analyzer and file-content-extractor services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:34:05 +01:00
Matt
1a0525c108 Redesign admin dashboard: pipeline view, round-specific stats, smart actions
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
Complete rewrite of the admin dashboard replacing the 1056-line monolith with
modular components. Key improvements:

- Competition pipeline: horizontal visualization of all rounds in pipeline order
  (by sortOrder, not creation date) with type-specific icons and metrics
- Round-specific stats: stat cards dynamically change based on active round type
  (INTAKE shows submissions/docs, FILTERING shows pass/fail/flagged, EVALUATION
  shows assignments/completion, fallback shows generic project/jury stats)
- Smart actions: context-aware "Action Required" panel that only flags the next
  draft round (not all), only flags unassigned projects for EVALUATION rounds,
  includes deadline warnings with severity levels (critical/warning/info)
- Active round panel: detailed view with project state bar, type-specific content,
  and deadline countdown
- Extracted components: project list, activity feed, category breakdown, skeleton

Backend enriched with 17 parallel queries building rich PipelineRound data
including per-round project states, eval stats, filtering stats, live session
status, and deliberation counts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:12:28 +01:00
Matt
842e79e319 Simplify doc-analysis upload hooks (always fire-and-forget)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:02:05 +01:00
Matt
ed5e782f61 Fix document analysis: switch to unpdf + mammoth for PDF/Word parsing
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m26s
pdf-parse v2 requires DOMMatrix (browser API) which fails in Node.js.
Replaced with unpdf (serverless PDF.js build) for PDFs and mammoth for
Word .docx files. Also fixed the same broken pdf-parse usage in
file-content-extractor.ts used by AI filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:27:36 +01:00
Matt
c9640c6086 Add document analysis: page count, text extraction & language detection
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m7s
Introduces a document analyzer service that extracts page count (via pdf-parse),
text preview, and detected language (via franc) from uploaded files. Analysis runs
automatically on upload (configurable via SystemSettings) and can be triggered
retroactively for existing files. Results are displayed as badges in the FileViewer
and fed to AI screening for language-based filtering criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:09:04 +01:00
Matt
771f35c695 Retroactive auto-PASS for projects with complete documents
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
Wire batchCheckRequirementsAndTransition into round activation and reopen
so pre-existing projects that already have all required docs get auto-
passed. Also adds checkDocumentCompletion endpoint for manual sweeps on
already-active rounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:29:57 +01:00
Matt
fbeec846a3 Pass tag confidence scores to AI assignment for weighted matching
The AI assignment path was receiving project tags as flat strings, losing
the confidence scores from AI tagging. Now both the GPT path and the
fallback algorithm weight tag matches by confidence — a 0.9 tag matters
more than a 0.5 one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:29:46 +01:00
Matt
cfeef9a601 Add auto-pass & advance for intake rounds (no manual marking needed)
For INTAKE, SUBMISSION, and MENTORING rounds, the Advance Projects dialog
now shows a simplified "Advance All" flow that auto-passes all pending
projects and advances them in one click. Backend accepts autoPassPending
flag to bulk-set PENDING→PASSED before advancing. Jury/evaluation rounds
keep the existing per-project selection workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:17:16 +01:00
Matt
fcee8761b9 Hide jury stat card in header for non-jury rounds (INTAKE, FILTERING, etc.)
The jury selector card in the stats bar was still visible on round types
where juries don't apply. Now conditionally rendered based on hasJury,
with the grid adjusting from 4 to 3 columns accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:17:15 +01:00
7b98b64c1c Auto-transition projects to PASSED when all required documents uploaded
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m55s
Add checkRequirementsAndTransition() to round-engine that checks if all
required FileRequirements for a round are satisfied by uploaded files.
When all are met and the project is PENDING/IN_PROGRESS, it auto-
transitions to PASSED. Also adds batchCheckRequirementsAndTransition()
for bulk operations.

Wired into:
- file.adminUploadForRoundRequirement (admin bulk upload)
- applicant.saveFileMetadata (applicant self-upload)

Non-fatal: failures in the check never break the upload itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 01:43:28 +01:00
Matt
09049d2911 Round management: tab cleanup, date pickers, advancement workflow
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m15s
- Remove Document Windows tab (round dates + file requirements in
  Config are sufficient, separate SubmissionWindow was redundant)
- Restrict Jury and Awards tabs to round types that use them
  (EVALUATION, LIVE_FINAL, DELIBERATION only)
- Add Round Dates card in Config tab with DateTimePicker for
  start/end dates (supports past and future dates)
- Make Advance Projects button always visible when projects exist
  (dimmed with guidance when no projects are PASSED yet)
- Add Close & Advance combined quick action to streamline round
  progression workflow
- Add target round selector to Advance Projects dialog so admin
  can pick which round to advance projects into

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:43:23 +01:00
Matt
3fb0d128a1 Fix missing query invalidations across member management
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m19s
Add utils.user.list.invalidate() after mutations that change user
status to ensure member lists refresh without manual page reload:
- Member detail page: after update and send invitation
- User mobile actions: after send invitation
- Add member dialog: after send invitation in jury group flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:16:23 +01:00
Matt
5965f7889d Platform-wide UX fixes: assignment dialog, invalidation, settings, dashboard
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m4s
1. Assignment dialog overhaul: replace raw UUID inputs with searchable
   juror Combobox (shows name, email, capacity) and multi-select project
   checklist with bulk assignment support

2. Query invalidation sweep: fix missing invalidations in
   assignment-preview-sheet (roundAssignment.execute) and
   filtering-dashboard (filtering.finalizeResults) so data refreshes
   without page reload

3. Rename Submissions tab to Document Windows with descriptive
   header explaining upload window configuration

4. Connect 6 disconnected settings: storage_provider, local_storage_path,
   avatar_max_size_mb, allowed_image_types, whatsapp_enabled,
   whatsapp_provider - all now accessible in Settings UI

5. Admin dashboard redesign: branded Editorial Command Center with
   Dark Blue gradient header, colored border-l-4 stat cards, staggered
   animations, 2-column layout, action-required panel, activity timeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:05:25 +01:00
Matt
b2279067e2 Add LiteLLM proxy support for ChatGPT subscription AI access
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s
- Add ai_provider setting: 'openai' (API key) or 'litellm' (ChatGPT subscription proxy)
- Auto-strip max_tokens/max_completion_tokens for chatgpt/ prefix models
  (ChatGPT subscription backend rejects token limit fields)
- LiteLLM mode: dummy API key when none configured, base URL required
- isOpenAIConfigured() checks base URL instead of API key for LiteLLM
- listAvailableModels() returns manualEntry flag for LiteLLM (no models.list)
- Settings UI: conditional fields, info banner, manual model input with
  chatgpt/ prefix examples when LiteLLM selected
- All 7 AI services work transparently via buildCompletionParams()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:48:34 +01:00
Matt
014bb15890 Reduce AI costs: switch tagging to gpt-4o-mini, add custom base URL support
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Change AI tagging to use AI_MODELS.QUICK (gpt-4o-mini) instead of gpt-4o for
  10-15x cost reduction on classification tasks
- Add openai_base_url system setting for OpenAI-compatible providers
  (OpenRouter, Groq, Together AI, local models)
- Reset OpenAI client singleton when API key, base URL, or model changes
- Add base URL field to AI settings form with provider examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:34:59 +01:00
Matt
f12c29103c Fix project detail crash: replace dynamic hooks with single query
The project detail page called useQuery inside .map() to fetch file
requirements per round, violating React's rules of hooks. When
competitionRounds changed from [] to [round1, round2], the hook count
changed, causing React to crash with "Cannot read properties of
undefined (reading 'length')".

Fix: Add listRequirementsByRounds endpoint that accepts multiple
roundIds in one query, replacing the dynamic hook pattern with a
single stable useQuery call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:30:44 +01:00
Matt
65a22e6f19 Optimize all AI functions for efficiency and speed
- AI Tagging: batch 10 projects per API call with 3 concurrent batches (~10x faster)
  - New `tagProjectsBatch()` with `getAISuggestionsBatch()` for multi-project prompts
  - Single DB query for all projects, single anonymization pass
  - Compact JSON in prompts (no pretty-print) saves tokens
- AI Shortlist: run STARTUP and BUSINESS_CONCEPT categories in parallel (2x faster)
- AI Filtering: increase default parallel batches from 1 to 3 (3x faster)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 14:02:38 +01:00
Matt
989db4dc14 Allow AI tagging dialog to close during processing, show background progress
- Remove blocking guard on dialog close when tagging is in progress
- Change Cancel button to "Run in Background" during processing
- Add amber border + spinner + progress % on AI Tags button when job runs in background
- Job already runs server-side and sends in-app notification on completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:58:03 +01:00
200 changed files with 25434 additions and 7733 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

873
package-lock.json generated

File diff suppressed because it is too large Load Diff

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",
@@ -62,11 +65,13 @@
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"franc": "^6.2.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^4.1.0", "jspdf": "^4.1.0",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"mammoth": "^1.11.0",
"minio": "^8.0.2", "minio": "^8.0.2",
"motion": "^11.15.0", "motion": "^11.15.0",
"next": "^15.1.0", "next": "^15.1.0",
@@ -83,10 +88,10 @@
"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",
"unpdf": "^1.4.0",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "ProjectFile" ADD COLUMN "textPreview" TEXT;
ALTER TABLE "ProjectFile" ADD COLUMN "detectedLang" TEXT;
ALTER TABLE "ProjectFile" ADD COLUMN "langConfidence" DOUBLE PRECISION;
ALTER TABLE "ProjectFile" ADD COLUMN "analyzedAt" TIMESTAMP(3);

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?
@@ -378,8 +380,9 @@ model User {
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy") filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
// Award overrides // Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
// In-app notifications // In-app notifications
notifications InAppNotification[] @relation("UserNotifications") notifications InAppNotification[] @relation("UserNotifications")
@@ -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
@@ -689,6 +698,12 @@ model ProjectFile {
size Int // bytes size Int // bytes
pageCount Int? // Number of pages (PDFs, presentations, etc.) pageCount Int? // Number of pages (PDFs, presentations, etc.)
// Document analysis (optional, populated by document-analyzer service)
textPreview String? @db.Text // First ~2000 chars of extracted text
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
langConfidence Float? // 0.01.0 confidence
analyzedAt DateTime? // When analysis last ran
// MinIO location // MinIO location
bucket String bucket String
objectKey String objectKey String
@@ -900,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
@@ -1501,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
@@ -1524,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])
@@ -1539,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
@@ -1551,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])
@@ -2074,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
@@ -2088,6 +2116,7 @@ model Competition {
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@@index([isTest])
} }
model Round { model Round {
@@ -2112,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[]
@@ -2151,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>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto"> {log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
{JSON.stringify(log.detailsJson, null, 2)} <ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
</pre> ) : log.action === 'COI_REASSIGNMENT' ? (
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
) : (
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</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>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto"> {log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
{JSON.stringify(log.detailsJson, null, 2)} <ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
</pre> ) : log.action === 'COI_REASSIGNMENT' ? (
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
) : (
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</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,13 +262,11 @@ 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) <SelectItem key={round.id} value={round.id}>
.map((round) => ( {round.name} ({round.roundType})
<SelectItem key={round.id} value={round.id}> </SelectItem>
{round.name} ({round.roundType}) ))}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -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,13 +27,30 @@ 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,
}) })
const { data: selectedRound } = trpc.round.getById.useQuery(
{ id: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const requiredReviews = (selectedRound?.configJson as Record<string, unknown>)?.requiredReviewsPerProject as number || 3
const { data: unassignedQueue, isLoading: isLoadingQueue } = const { data: unassignedQueue, isLoading: isLoadingQueue } =
trpc.roundAssignment.unassignedQueue.useQuery( trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId: selectedRoundId, requiredReviews: 3 }, { roundId: selectedRoundId, requiredReviews },
{ enabled: !!selectedRoundId } { enabled: !!selectedRoundId }
) )
@@ -50,8 +68,19 @@ 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>
) )
} }
@@ -97,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">
@@ -111,7 +153,7 @@ export default function AssignmentsDashboardPage() {
</TabsList> </TabsList>
<TabsContent value="coverage" className="mt-6"> <TabsContent value="coverage" className="mt-6">
<CoverageReport roundId={selectedRoundId} /> <CoverageReport roundId={selectedRoundId} requiredReviews={requiredReviews} />
</TabsContent> </TabsContent>
<TabsContent value="unassigned" className="mt-6"> <TabsContent value="unassigned" className="mt-6">
@@ -119,7 +161,7 @@ export default function AssignmentsDashboardPage() {
<CardHeader> <CardHeader>
<CardTitle>Unassigned Projects</CardTitle> <CardTitle>Unassigned Projects</CardTitle>
<CardDescription> <CardDescription>
Projects with fewer than 3 assignments Projects with fewer than {requiredReviews} assignments
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -143,7 +185,7 @@ export default function AssignmentsDashboardPage() {
</p> </p>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{project.assignmentCount || 0} / 3 assignments {project.assignmentCount || 0} / {requiredReviews} assignments
</div> </div>
</div> </div>
))} ))}
@@ -162,6 +204,11 @@ export default function AssignmentsDashboardPage() {
roundId={selectedRoundId} roundId={selectedRoundId}
open={previewSheetOpen} open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen} onOpenChange={setPreviewSheetOpen}
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,13 +106,39 @@ 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 (
<div className="space-y-6 p-4 sm:p-6"> <div className="space-y-6 p-4 sm:p-6">
@@ -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) {

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,6 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
Table, Table,
@@ -523,11 +522,6 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
name: group.name, name: group.name,
description: group.description || '', description: group.description || '',
defaultMaxAssignments: group.defaultMaxAssignments, defaultMaxAssignments: group.defaultMaxAssignments,
defaultCapMode: group.defaultCapMode,
softCapBuffer: group.softCapBuffer,
categoryQuotasEnabled: group.categoryQuotasEnabled,
allowJurorCapAdjustment: group.allowJurorCapAdjustment,
allowJurorRatioAdjustment: group.allowJurorRatioAdjustment,
}) })
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -562,99 +556,20 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
/> />
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-2">
<div className="space-y-2"> <Label>Default Max Assignments</Label>
<Label>Default Max Assignments</Label> <Input
<Input type="number"
type="number" min="1"
min="1" max="50"
value={formData.defaultMaxAssignments} value={formData.defaultMaxAssignments}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) }) setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) || 15 })
} }
/> />
</div> <p className="text-xs text-muted-foreground">
Suggested cap for new members. Per-member overrides and juror self-service preferences take priority.
<div className="space-y-2"> </p>
<Label>Cap Mode</Label>
<Select
value={formData.defaultCapMode}
onValueChange={(v) => setFormData({ ...formData, defaultCapMode: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{formData.defaultCapMode === 'SOFT' && (
<div className="space-y-2">
<Label>Soft Cap Buffer</Label>
<Input
type="number"
min="0"
value={formData.softCapBuffer}
onChange={(e) =>
setFormData({ ...formData, softCapBuffer: parseInt(e.target.value, 10) })
}
/>
<p className="text-xs text-muted-foreground">
Number of assignments allowed above the cap when in soft mode
</p>
</div>
)}
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Category Quotas Enabled</Label>
<p className="text-xs text-muted-foreground">
Enable category-based assignment quotas
</p>
</div>
<Switch
checked={formData.categoryQuotasEnabled}
onCheckedChange={(checked) =>
setFormData({ ...formData, categoryQuotasEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Allow Juror Cap Adjustment</Label>
<p className="text-xs text-muted-foreground">
Allow jurors to set their own assignment cap during onboarding
</p>
</div>
<Switch
checked={formData.allowJurorCapAdjustment}
onCheckedChange={(checked) =>
setFormData({ ...formData, allowJurorCapAdjustment: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Allow Juror Ratio Adjustment</Label>
<p className="text-xs text-muted-foreground">
Allow jurors to set their own startup/concept ratio during onboarding
</p>
</div>
<Switch
checked={formData.allowJurorRatioAdjustment}
onCheckedChange={(checked) =>
setFormData({ ...formData, allowJurorRatioAdjustment: checked })
}
/>
</div>
</div> </div>
<Button type="submit" disabled={isPending} className="w-full sm:w-auto"> <Button type="submit" disabled={isPending} className="w-full sm:w-auto">

View File

@@ -58,6 +58,7 @@ import {
export default function MemberDetailPage() { export default function MemberDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
const utils = trpc.useUtils()
const userId = params.id as string const userId = params.id as string
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId }) const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
@@ -103,6 +104,8 @@ export default function MemberDetailPage() {
expertiseTags, expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null, maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
}) })
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success('Member updated successfully') toast.success('Member updated successfully')
router.push('/admin/members') router.push('/admin/members')
} catch (error) { } catch (error) {
@@ -115,6 +118,7 @@ export default function MemberDetailPage() {
await sendInvitation.mutateAsync({ userId }) await sendInvitation.mutateAsync({ userId })
toast.success('Invitation email sent successfully') toast.success('Invitation email sent successfully')
refetch() refetch()
utils.user.list.invalidate()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation') toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} }

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,57 +565,74 @@ 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>
)}
<span className={isRead ? 'text-muted-foreground' : 'font-medium'}>
{String(msg?.subject || 'No subject')}
</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">
<Badge variant="outline" className="text-xs"> <div className="flex gap-1">
{channel === 'EMAIL' ? ( {channels.includes('EMAIL') && (
<><Mail className="mr-1 h-3 w-3" />Email</> <Badge variant="outline" className="text-xs">
) : ( <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,12 +65,20 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</p> </p>
</div> </div>
</div> </div>
<Button variant="outline" asChild> <div className="flex items-center gap-2">
<Link href={`/admin/programs/${id}/edit`}> <Button variant="outline" asChild>
<Pencil className="mr-2 h-4 w-4" /> <Link href={`/admin/programs/${id}/mentorship` as Route}>
Edit <GraduationCap className="mr-2 h-4 w-4" />
</Link> Mentorship
</Button> </Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
</div> </div>
{program.description && ( {program.description && (
@@ -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,
@@ -49,7 +53,12 @@ import {
Heart, Heart,
Crown, Crown,
UserPlus, UserPlus,
Loader2,
ScanSearch,
Eye,
MessageSquare,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils' import { formatDate, formatDateOnly } from '@/lib/utils'
interface PageProps { interface PageProps {
@@ -76,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
@@ -105,16 +115,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// Extract all rounds from the competition // Extract all rounds from the competition
const competitionRounds = competition?.rounds || [] const competitionRounds = competition?.rounds || []
// Fetch requirements for each round // Fetch requirements for all rounds in a single query (avoids dynamic hook violation)
const requirementQueries = competitionRounds.map((round: { id: string; name: string }) => const roundIds = competitionRounds.map((r: { id: string }) => r.id)
trpc.file.listRequirements.useQuery({ roundId: round.id }) const { data: allRequirements = [] } = trpc.file.listRequirementsByRounds.useQuery(
{ roundIds },
{ enabled: roundIds.length > 0 }
) )
// Combine requirements from all rounds
const allRequirements = requirementQueries.flatMap((q: { data?: unknown[] }) => q.data || [])
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 />
} }
@@ -530,116 +543,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<AnimatedCard index={4}> <AnimatedCard index={4}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg"> <div className="flex items-center justify-between">
<div className="rounded-lg bg-rose-500/10 p-1.5"> <div>
<FileText className="h-4 w-4 text-rose-500" /> <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>
Files
</CardTitle>
<CardDescription>
Project documents and materials organized by competition round
</CardDescription>
</div> </div>
Files <AnalyzeDocumentsButton projectId={projectId} onComplete={() => utils.file.listByProject.invalidate({ projectId })} />
</CardTitle> </div>
<CardDescription>
Project documents and materials organized by competition round
</CardDescription>
</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 }))}
@@ -653,21 +575,30 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{files && files.length > 0 && ( {files && files.length > 0 && (
<> <>
<Separator /> <Separator />
<div> <FileViewer
<p className="text-sm font-semibold mb-3">All Uploaded Files</p> projectId={projectId}
<FileViewer files={files.map((f) => ({
projectId={projectId} id: f.id,
files={files.map((f) => ({ fileName: f.fileName,
id: f.id, fileType: f.fileType,
fileName: f.fileName, mimeType: f.mimeType,
fileType: f.fileType, size: f.size,
mimeType: f.mimeType, bucket: f.bucket,
size: f.size, objectKey: f.objectKey,
bucket: f.bucket, pageCount: f.pageCount,
objectKey: f.objectKey, textPreview: f.textPreview,
}))} detectedLang: f.detectedLang,
/> langConfidence: f.langConfidence,
</div> 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,
}))}
/>
</> </>
)} )}
</CardContent> </CardContent>
@@ -709,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
@@ -787,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>
@@ -796,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
@@ -848,6 +800,203 @@ function ProjectDetailSkeleton() {
) )
} }
function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string; onComplete: () => void }) {
const analyzeMutation = trpc.file.analyzeProjectFiles.useMutation({
onSuccess: (result) => {
toast.success(
`Analyzed ${result.analyzed} file${result.analyzed !== 1 ? 's' : ''}${result.failed > 0 ? ` (${result.failed} failed)` : ''}`
)
onComplete()
},
onError: (error) => {
toast.error(error.message || 'Analysis failed')
},
})
return (
<Button
variant="outline"
size="sm"
onClick={() => analyzeMutation.mutate({ projectId })}
disabled={analyzeMutation.isPending}
>
{analyzeMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ScanSearch className="mr-2 h-4 w-4" />
)}
{analyzeMutation.isPending ? 'Analyzing...' : 'Analyze Documents'}
</Button>
)
}
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

@@ -366,8 +366,9 @@ export default function ProjectsPage() {
} }
const handleCloseTaggingDialog = () => { const handleCloseTaggingDialog = () => {
setAiTagDialogOpen(false)
// Only reset job state if not in progress (preserve polling for background jobs)
if (!taggingInProgress) { if (!taggingInProgress) {
setAiTagDialogOpen(false)
setActiveTaggingJobId(null) setActiveTaggingJobId(null)
setSelectedRoundForTagging('') setSelectedRoundForTagging('')
setSelectedProgramForTagging('') setSelectedProgramForTagging('')
@@ -618,9 +619,22 @@ export default function ProjectsPage() {
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}> <Button
<Bot className="mr-2 h-4 w-4" /> variant="outline"
onClick={() => setAiTagDialogOpen(true)}
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''}
>
{taggingInProgress ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
) : (
<Bot className="mr-2 h-4 w-4" />
)}
AI Tags AI Tags
{taggingInProgress && (
<span className="ml-1.5 text-[10px] text-amber-600 font-medium">
{taggingProgressPercent}%
</span>
)}
</Button> </Button>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href="/admin/projects/pool"> <Link href="/admin/projects/pool">
@@ -697,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)
@@ -856,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>
@@ -899,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>
@@ -1057,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 && (
@@ -1168,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>
@@ -1833,9 +1823,8 @@ export default function ProjectsPage() {
<Button <Button
variant="outline" variant="outline"
onClick={handleCloseTaggingDialog} onClick={handleCloseTaggingDialog}
disabled={taggingInProgress}
> >
Cancel {taggingInProgress ? 'Run in Background' : 'Cancel'}
</Button> </Button>
<Button <Button
onClick={handleStartTagging} onClick={handleStartTagging}

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)
if (programs?.length && !selectedValue) { useEffect(() => {
setSelectedValue(`all:${programs[0].id}`) if (programs?.length && !selectedValue) {
} 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
if (rounds.length && !selectedValue) { useEffect(() => {
setSelectedValue(rounds[0].id) if (rounds.length && !selectedValue) {
} 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` }))
) || [] ) || []
if (stages.length && !selectedValue) { useEffect(() => {
setSelectedValue(stages[0].id) if (stages.length && !selectedValue) {
} 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` }))
) || [] ) || []
if (stages.length && !selectedValue) { useEffect(() => {
setSelectedValue(stages[0].id) if (stages.length && !selectedValue) {
} 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` }))
) || [] ) || []
if (pdfStages.length && !pdfStageId) { useEffect(() => {
setPdfStageId(pdfStages[0].id) if (pdfStages.length && !pdfStageId) {
} 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

@@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content' import { SettingsContent } from '@/components/settings/settings-content'
// Categories that only super admins can access // Categories that only super admins can access
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY']) const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP'])
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) { async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const settings = await prisma.systemSettings.findMany({ const settings = await prisma.systemSettings.findMany({

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,16 +19,25 @@ 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) {
const role = session.user.role // Verify user still exists in DB (handles deleted accounts with stale sessions)
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') { const dbUser = await prisma.user.findUnique({
redirect('/admin') where: { id: session.user.id },
} else if (role === 'JURY_MEMBER') { select: { id: true },
redirect('/jury') })
} else if (role === 'OBSERVER') {
redirect('/observer') if (dbUser) {
} else if (role === 'MENTOR') { const role = session.user.role
redirect('/mentor') if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
redirect('/admin')
} else if (role === 'JURY_MEMBER') {
redirect('/jury')
} else if (role === 'OBSERVER') {
redirect('/observer')
} else if (role === 'MENTOR') {
redirect('/mentor')
}
} }
// If user doesn't exist in DB, fall through and show auth page
} }
return ( return (

View File

@@ -51,10 +51,9 @@ type JuryPref = {
juryGroupMemberId: string juryGroupMemberId: string
juryGroupName: string juryGroupName: string
currentCap: number currentCap: number
allowCapAdjustment: boolean
allowRatioAdjustment: boolean
selfServiceCap: number | null selfServiceCap: number | null
selfServiceRatio: number | null selfServiceRatio: number | null
preferredStartupRatio: number | null
} }
export default function OnboardingPage() { export default function OnboardingPage() {
@@ -221,7 +220,7 @@ export default function OnboardingPage() {
// Show loading while session hydrates or fetching user data // Show loading while session hydrates or fetching user data
if (sessionStatus === 'loading' || userLoading || !initialized) { if (sessionStatus === 'loading' || userLoading || !initialized) {
return ( return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]"> <div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
<AnimatedCard> <AnimatedCard>
<Card className="w-full max-w-lg shadow-2xl overflow-hidden"> <Card className="w-full max-w-lg shadow-2xl overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" /> <div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
@@ -236,9 +235,9 @@ export default function OnboardingPage() {
} }
return ( return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]"> <div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
<AnimatedCard> <AnimatedCard>
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-hidden shadow-2xl"> <Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-x-hidden shadow-2xl">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" /> <div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
{/* Progress indicator */} {/* Progress indicator */}
<div className="px-6 pt-6"> <div className="px-6 pt-6">
@@ -530,60 +529,59 @@ export default function OnboardingPage() {
{juryMemberships.map((m) => { {juryMemberships.map((m) => {
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {} const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? 0.5 const ratioValue = pref.ratio ?? m.selfServiceRatio ?? m.preferredStartupRatio ?? 0.5
return ( return (
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4"> <div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
<h4 className="font-medium text-sm">{m.juryGroupName}</h4> <h4 className="font-medium text-sm">{m.juryGroupName}</h4>
{m.allowCapAdjustment && ( <div className="space-y-2">
<div className="space-y-2"> <Label className="text-xs text-muted-foreground">
<Label className="text-xs text-muted-foreground"> Maximum assignments: {capValue}
Maximum assignments: {capValue} </Label>
</Label> <Slider
<Slider value={[capValue]}
value={[capValue]} onValueChange={([v]) =>
onValueChange={([v]) => setJuryPrefs((prev) => {
setJuryPrefs((prev) => { const next = new Map(prev)
const next = new Map(prev) next.set(m.juryGroupMemberId, { ...pref, cap: v })
next.set(m.juryGroupMemberId, { ...pref, cap: v }) return next
return next })
}) }
} min={1}
min={1} max={50}
max={m.currentCap} step={1}
step={1} />
/> <p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground"> Admin suggestion: {m.currentCap}. Adjust to match your availability.
Admin default: {m.currentCap}. You may reduce this to match your availability. </p>
</p> </div>
</div>
)}
{m.allowRatioAdjustment && ( <div className="space-y-2">
<div className="space-y-2"> <Label className="text-xs text-muted-foreground">
<Label className="text-xs text-muted-foreground"> Category preference: {Math.round(ratioValue * 100)}% Startups / {Math.round((1 - ratioValue) * 100)}% Business Concepts
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}% </Label>
</Label> <Slider
<Slider value={[ratioValue * 100]}
value={[ratioValue * 100]} onValueChange={([v]) =>
onValueChange={([v]) => setJuryPrefs((prev) => {
setJuryPrefs((prev) => { const next = new Map(prev)
const next = new Map(prev) next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 })
next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 }) return next
return next })
}) }
} min={0}
min={0} max={100}
max={100} step={10}
step={5} />
/> <div className="flex justify-between text-xs text-muted-foreground">
<div className="flex justify-between text-xs text-muted-foreground"> <span>More Business Concepts</span>
<span>More Business Concepts</span> <span>More Startups</span>
<span>More Startups</span>
</div>
</div> </div>
)} <p className="text-xs text-muted-foreground/70 italic">
This is a preference, not a guarantee. Due to the number of projects, the system will try to match your preference but exact ratios cannot be ensured.
</p>
</div>
</div> </div>
) )
})} })}

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,20 +69,36 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{ enabled: !!myAssignment?.id } { enabled: !!myAssignment?.id }
) )
// 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(
{ roundId },
{ enabled: !!roundId }
)
// 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')
@@ -92,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())
@@ -111,24 +135,139 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (existingEvaluation.feedbackText) { if (existingEvaluation.feedbackText) {
setFeedbackText(existingEvaluation.feedbackText) setFeedbackText(existingEvaluation.feedbackText)
} }
isDirtyRef.current = false
} }
}, [existingEvaluation]) }, [existingEvaluation])
// Parse evaluation config from round // Parse evaluation config from round
const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null
const scoringMode = evalConfig?.scoringMode ?? 'global' const scoringMode = evalConfig?.scoringMode ?? 'criteria'
const requireFeedback = evalConfig?.requireFeedback ?? true const requireFeedback = evalConfig?.requireFeedback ?? true
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10 const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
// Get criteria from evaluation form // Parse criteria from the active form
const criteria = existingEvaluation?.form?.criteriaJson as Array<{ const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
id: string const type = (c as any).type || 'numeric'
label: string let minScore = 1
description?: string let maxScore = 10
weight?: number if (type === 'numeric' && c.scale) {
minScore?: number const parts = c.scale.split('-').map(Number)
maxScore?: number if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
}> | undefined minScore = parts[0]
maxScore = parts[1]
}
}
return {
id: c.id,
label: c.label,
description: c.description,
type: type as 'numeric' | 'text' | 'boolean' | 'section_header',
weight: c.weight,
minScore,
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) {
@@ -136,21 +275,21 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
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 () => {
@@ -159,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
return )
} for (const c of requiredCriteria) {
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false const val = criteriaValues[c.id]
if (requiredCriteria) { if (c.type === 'numeric' && (val === undefined || val === null)) {
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined) toast.error(`Please score "${c.label}"`)
if (!allScored) { return
toast.error('Please score all criteria') }
if (c.type === 'boolean' && val === undefined) {
toast.error(`Please answer "${c.label}"`)
return
}
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
toast.error(`Please fill in "${c.label}"`)
return return
} }
} }
@@ -197,72 +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={setShowCOIDialog}>
<DialogContent>
<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">
@@ -278,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">
@@ -291,10 +524,27 @@ 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">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" /> <AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
@@ -302,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>
@@ -310,64 +560,218 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Evaluation Form</CardTitle> <div className="flex items-start justify-between">
<CardDescription> <div>
Provide your assessment using the {scoringMode} scoring method <CardTitle>Evaluation Form</CardTitle>
</CardDescription> <CardDescription>
{scoringMode === 'criteria'
? 'Complete all required fields below'
: `Provide your assessment using the ${scoringMode} scoring method`}
</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>
)} )}
</Label> </div>
{criterion.description && ( )
<p className="text-xs text-muted-foreground">{criterion.description}</p> }
)}
<Input if (criterion.type === 'boolean') {
id={criterion.id} const currentValue = criteriaValues[criterion.id]
type="number" return (
min={criterion.minScore ?? 0} <div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
max={criterion.maxScore ?? 10} <div className="space-y-1">
value={criteriaScores[criterion.id] ?? ''} <Label className="text-base font-medium">
onChange={(e) => {criterion.label}
setCriteriaScores({ {criterion.required && <span className="text-destructive ml-1">*</span>}
...criteriaScores, </Label>
[criterion.id]: parseInt(e.target.value, 10) || 0, {criterion.description && (
}) <p className="text-sm text-muted-foreground">{criterion.description}</p>
} )}
placeholder={`Score (${criterion.minScore ?? 0}-${criterion.maxScore ?? 10})`} </div>
/> <div className="flex gap-3">
</div> <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">
Overall Score <span className="text-destructive">*</span> <Label>
</Label> Overall Score <span className="text-destructive">*</span>
<Input </Label>
id="globalScore" <span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
type="number" {globalScore || '\u2014'}/10
min="1" </span>
max="10" </div>
value={globalScore} <div className="flex items-center gap-2">
onChange={(e) => setGlobalScore(e.target.value)} <span className="text-xs text-muted-foreground">1</span>
placeholder="Enter score (1-10)" <Slider
/> min={1}
<p className="text-xs text-muted-foreground"> max={10}
Provide a score from 1 to 10 based on your overall assessment step={1}
</p> value={[globalScore ? parseInt(globalScore, 10) : 5]}
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
className="flex-1"
/>
<span className="text-xs text-muted-foreground">10</span>
</div>
<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>
)} )}
@@ -377,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">
@@ -399,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,35 +78,76 @@ 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>
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light"> {isVotingOpen ? (
<Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}> <Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Target className="mr-2 h-4 w-4" /> <Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}>
Evaluate Project <Target className="mr-2 h-4 w-4" />
</Link> Evaluate Project
</Button> </Link>
</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 && (
<Badge variant="outline">{project.competitionCategory}</Badge>
)}
{project.tags && project.tags.length > 0 && (
project.tags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))
)}
</div> </div>
{/* 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>
)}
</Badge>
))
: project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))
}
</div>
</div>
)}
{/* Description */} {/* Description */}
{project.description && ( {project.description && (
<div> <div>

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

@@ -3,41 +3,62 @@
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, CardHeader, CardTitle } from '@/components/ui/card'
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 { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ArrowLeft, ArrowRight, ClipboardList, Target } from 'lucide-react' import { StatusBadge } from '@/components/shared/status-badge'
import { toast } from 'sonner' import { ProjectLogo } from '@/components/shared/project-logo'
import {
ArrowLeft,
ArrowRight,
ClipboardList,
CheckCircle2,
Clock,
FileEdit,
} from 'lucide-react'
import { formatDateOnly, formatEnumLabel } from '@/lib/utils'
export default function JuryCompetitionsPage() { export default function JuryAssignmentsPage() {
const { data: competitions, isLoading } = trpc.competition.getMyCompetitions.useQuery() const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({})
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Skeleton className="h-8 w-64" /> <Skeleton className="h-8 w-64" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="space-y-3">
{[1, 2, 3].map((i) => ( {[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-40" /> <Skeleton key={i} className="h-24 w-full rounded-xl" />
))} ))}
</div> </div>
</div> </div>
) )
} }
// Group assignments by round
const byRound = new Map<string, { round: { id: string; name: string; roundType: string; status: string; windowCloseAt: Date | null }; items: typeof assignments }>()
for (const a of assignments ?? []) {
if (!a.round) continue
if (!byRound.has(a.round.id)) {
byRound.set(a.round.id, { round: a.round, items: [] })
}
byRound.get(a.round.id)!.items!.push(a)
}
const roundGroups = Array.from(byRound.values())
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<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">
My Competitions My Assignments
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
View competitions and rounds you&apos;re assigned to 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
@@ -45,65 +66,101 @@ export default function JuryCompetitionsPage() {
</Button> </Button>
</div> </div>
{!competitions || competitions.length === 0 ? ( {roundGroups.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-4"> <div className="rounded-2xl bg-brand-teal/10 p-4 mb-4">
<ClipboardList className="h-8 w-8 text-brand-teal/60" /> <ClipboardList className="h-8 w-8 text-brand-teal/60" />
</div> </div>
<h2 className="text-xl font-semibold mb-2">No Competitions</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 competition 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>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="space-y-6">
{competitions.map((competition) => { {roundGroups.map(({ round, items }) => {
const activeRounds = competition.rounds?.filter(r => r.status !== 'ROUND_ARCHIVED') || [] const completed = (items ?? []).filter(
const totalRounds = competition.rounds?.length || 0 (a) => a.evaluation?.status === 'SUBMITTED'
).length
const total = items?.length ?? 0
return ( return (
<Card key={competition.id} className="flex flex-col transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"> <Card key={round.id}>
<CardHeader> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex flex-wrap items-center gap-2">
<div> <CardTitle className="text-base">{round.name}</CardTitle>
<CardTitle className="text-lg">{competition.name}</CardTitle> <Badge variant="secondary" className="text-xs shrink-0">
</div> {formatEnumLabel(round.roundType)}
<Badge variant="secondary">
{totalRounds} round{totalRounds !== 1 ? 's' : ''}
</Badge> </Badge>
{round.status !== 'ROUND_ACTIVE' && (
<Badge variant="outline" className="text-xs text-muted-foreground shrink-0">
{formatEnumLabel(round.status)}
</Badge>
)}
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{completed}/{total} completed
</span>
{round.windowCloseAt && (
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Clock className="h-3 w-3" />
Due {formatDateOnly(round.windowCloseAt)}
</Badge>
)}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col space-y-4"> <CardContent>
<div className="divide-y">
{(items ?? []).map((assignment) => {
const project = assignment.project
const evalStatus = assignment.evaluation?.status
const isSubmitted = evalStatus === 'SUBMITTED'
const isDraft = evalStatus === 'DRAFT'
<div className="flex-1" /> return (
<div className="space-y-2">
{activeRounds.length > 0 ? (
activeRounds.slice(0, 2).map((round) => (
<Link <Link
key={round.id} key={assignment.id}
href={`/jury/competitions/${round.id}` as Route} href={`/jury/competitions/${round.id}/projects/${project.id}` as Route}
className="flex items-center justify-between p-3 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all group" className="block"
> >
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
<Target className="h-4 w-4 text-brand-teal shrink-0" /> <ProjectLogo
<span className="text-sm font-medium truncate">{round.name}</span> project={project}
size="sm"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
{project.title}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{[project.teamName, project.country].filter(Boolean).join(' \u00b7 ')}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{isSubmitted ? (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
Submitted
</Badge>
) : isDraft ? (
<Badge variant="warning" className="gap-1">
<FileEdit className="h-3 w-3" />
Draft
</Badge>
) : (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
Pending
</Badge>
)}
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors" />
</div>
</div> </div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors shrink-0" />
</Link> </Link>
)) )
) : ( })}
<p className="text-sm text-muted-foreground text-center py-2">
No active rounds
</p>
)}
{activeRounds.length > 2 && (
<p className="text-xs text-muted-foreground text-center">
+{activeRounds.length - 2} more round{activeRounds.length - 2 !== 1 ? 's' : ''}
</p>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

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,12 +25,12 @@ 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'
import { CountdownTimer } from '@/components/shared/countdown-timer' import { CountdownTimer } from '@/components/shared/countdown-timer'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { JuryPreferencesBanner } from '@/components/jury/preferences-banner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
function getGreeting(): string { function getGreeting(): string {
@@ -47,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: {
@@ -106,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(
@@ -186,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',
}, },
] ]
@@ -235,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"
@@ -248,18 +244,20 @@ 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>
<Link {juryCompareEnabled && (
href="/jury/competitions" <Link
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" 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"
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40"> >
<GitCompare className="h-4 w-4 text-brand-teal" /> <div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
</div> <GitCompare className="h-4 w-4 text-brand-teal" />
<div className="text-left"> </div>
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p> <div className="text-left">
<p className="text-xs text-muted-foreground">Side-by-side view</p> <p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
</div> <p className="text-xs text-muted-foreground">Side-by-side view</p>
</Link> </div>
</Link>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -300,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>
<div className={cn('rounded-xl p-3', stat.iconBg)}> ))}
<stat.icon className={cn('h-5 w-5', stat.iconColor)} /> </div>
</div>
<div> {/* Desktop: editorial stat row */}
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p> <div className="hidden md:block">
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p> <div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
</div> {stats.map((s, i) => (
</CardContent> <div
</Card> key={i}
</AnimatedCard> className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
))} >
{/* Overall completion as 5th stat card */} <span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
<AnimatedCard index={5}> <p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"> <p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
<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"> </div>
{completionRate.toFixed(0)}% </div>
</p> </AnimatedCard>
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/60 mt-1">
<div
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
style={{ width: `${completionRate}%` }}
/>
</div>
</div>
</CardContent>
</Card>
</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">
@@ -373,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
@@ -472,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"
@@ -485,18 +464,20 @@ 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>
<Link {juryCompareEnabled && (
href="/jury/competitions" <Link
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" 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"
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60"> >
<GitCompare className="h-5 w-5 text-brand-teal" /> <div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
</div> <GitCompare className="h-5 w-5 text-brand-teal" />
<div className="text-left"> </div>
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p> <div className="text-left">
<p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p> <p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
</div> <p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p>
</Link> </div>
</Link>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -669,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>
</CardContent>
</Card>
))} ))}
</div> </div>
{/* Progress bar skeleton */} <div className="hidden md:block">
<Card className="overflow-hidden"> <div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
<div className="h-1 w-full bg-muted" /> {[...Array(4)].map((_, i) => (
<CardContent className="py-5 px-6"> <div key={i} className="bg-background px-5 py-4">
<div className="flex items-center justify-between mb-3"> <Skeleton className="h-9 w-12" />
<Skeleton className="h-4 w-36" /> <Skeleton className="h-4 w-20 mt-1" />
<Skeleton className="h-7 w-16" /> <Skeleton className="h-3 w-16 mt-1" />
</div> </div>
<Skeleton className="h-3 w-full rounded-full" /> ))}
</CardContent> </div>
</Card> </div>
{/* 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">
@@ -757,10 +733,16 @@ export default async function JuryDashboardPage() {
</p> </p>
</div> </div>
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
<JuryPreferencesBanner />
{/* Content */} {/* Content */}
<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,13 +11,15 @@ export default async function ObserverLayout({
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<ObserverNav <EditionProvider>
user={{ <ObserverNav
name: session.user.name, user={{
email: session.user.email, name: session.user.name,
}} email: session.user.email,
/> }}
<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
status: string
roundType: string
windowCloseAt: Date | null
_count: { projects: number; assignments: number; evaluations: number }
programId: string
programName: string
}
const stages = programs?.flatMap(p => type TabDef = { value: string; label: string; icon: LucideIcon }
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
...s,
programName: `${p.year} Edition`,
}))
) || []
const queryInput = parseSelection(selectedValue) function getRoundTabs(roundType: string): TabDef[] {
const hasSelection = !!queryInput.roundId || !!queryInput.programId switch (roundType) {
case 'INTAKE':
const { data: overviewStats, isLoading: statsLoading } = return [{ value: 'overview', label: 'Overview', icon: LayoutDashboard }]
trpc.analytics.getOverviewStats.useQuery( case 'FILTERING':
queryInput, return [
{ enabled: hasSelection } { value: 'screening', label: 'Screening', icon: Filter },
) ]
case 'EVALUATION':
if (isLoading) { return [
return ( { value: 'evaluation', label: 'Evaluation', icon: TrendingUp },
<div className="space-y-6"> ]
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> case 'SUBMISSION':
{[...Array(4)].map((_, i) => ( return [{ value: 'overview', label: 'Overview', icon: Upload }]
<Card key={i}> case 'MENTORING':
<CardHeader className="space-y-0 pb-2"> return [{ value: 'overview', label: 'Overview', icon: Users }]
<Skeleton className="h-4 w-20" /> case 'LIVE_FINAL':
</CardHeader> return [{ value: 'session', label: 'Session', icon: Presentation }]
<CardContent> case 'DELIBERATION':
<Skeleton className="h-8 w-16" /> return [
<Skeleton className="mt-2 h-3 w-24" /> { value: 'deliberation', label: 'Deliberation', icon: Vote },
</CardContent> ]
</Card> default:
))} return []
</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 }) { function RoundTypeContent({
const queryInput = parseSelection(selectedValue) roundType,
const hasSelection = !!queryInput.roundId || !!queryInput.programId roundId,
programId,
const { data: scoreDistribution, isLoading: scoreLoading } = stages,
trpc.analytics.getScoreDistribution.useQuery( selectedValue,
queryInput, }: {
{ enabled: hasSelection } roundType: string
) roundId: string
programId: string
const { data: timeline, isLoading: timelineLoading } = stages: Stage[]
trpc.analytics.getEvaluationTimeline.useQuery( selectedValue: string | null
queryInput, }) {
{ enabled: hasSelection } switch (roundType) {
) case 'INTAKE':
return <IntakeReportTabs roundId={roundId} programId={programId} />
const { data: statusBreakdown, isLoading: statusLoading } = case 'FILTERING':
trpc.analytics.getStatusBreakdown.useQuery( return <FilteringReportTabs roundId={roundId} programId={programId} />
queryInput, case 'EVALUATION':
{ enabled: hasSelection } return (
) <EvaluationReportTabs
roundId={roundId}
const { data: jurorWorkload, isLoading: workloadLoading } = programId={programId}
trpc.analytics.getJurorWorkload.useQuery( stages={stages}
queryInput, selectedValue={selectedValue}
{ enabled: hasSelection } />
) )
case 'SUBMISSION':
const { data: projectRankings, isLoading: rankingsLoading } = return <SubmissionReportTabs roundId={roundId} programId={programId} />
trpc.analytics.getProjectRankings.useQuery( case 'MENTORING':
{ ...queryInput, limit: 15 }, return <MentoringReportTabs roundId={roundId} programId={programId} />
{ enabled: hasSelection } case 'LIVE_FINAL':
) return <LiveFinalReportTabs roundId={roundId} programId={programId} />
case 'DELIBERATION':
const { data: criteriaScores, isLoading: criteriaLoading } = return <DeliberationReportTabs roundId={roundId} programId={programId} />
trpc.analytics.getCriteriaScores.useQuery( default:
queryInput, return null
{ 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 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: consistency, isLoading } = const [activeTab, setActiveTab] = useState<string | null>(null)
trpc.analytics.getJurorConsistency.useQuery(
queryInput,
{ enabled: hasSelection }
)
if (isLoading) return <Skeleton className="h-[400px]" />
if (!consistency) return null
return (
<JurorConsistencyChart
data={consistency as {
overallAverage: number
jurors: Array<{
userId: string; name: string; email: string
evaluationCount: number; averageScore: number
stddev: number; deviationFromOverall: number; isOutlier: boolean
}>
}}
/>
)
}
function DiversityTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: diversity, isLoading } =
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)
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
}
const hasSelection = !!selectedValue useEffect(() => {
if (stages.length && !selectedValue) {
const active = stages.find((s) => s.status === 'ROUND_ACTIVE')
setSelectedValue(active ? active.id : stages[0].id)
}
}, [stages.length, 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}> </TabsTrigger>
<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>
</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}
/> />
)} </TabsContent>
</div>
<TabsContent value="overview"> {/* Round-type-specific or "All Rounds" progress tab */}
<OverviewTab selectedValue={selectedValue} /> {roundSpecificTabs.map((tab) => (
</TabsContent> <TabsContent key={tab.value} value={tab.value}>
{isAllRounds ? (
<TabsContent value="analytics"> <EvaluationReportTabs
{hasSelection ? ( roundId=""
<AnalyticsTab selectedValue={selectedValue!} /> programId={programId}
) : ( stages={stages}
<Card> selectedValue={selectedValue}
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> />
<BarChart3 className="h-12 w-12 text-muted-foreground/50" /> ) : selectedRound ? (
<p className="mt-2 font-medium">Select a round</p> <RoundTypeContent
<p className="text-sm text-muted-foreground"> roundType={roundType}
Choose a round or edition from the dropdown above to view analytics roundId={selectedRound.id}
</p> programId={programId}
</CardContent> stages={stages}
</Card> selectedValue={selectedValue}
)} />
</TabsContent> ) : null}
</TabsContent>
<TabsContent value="cross-stage"> ))}
<CrossStageTab /> </Tabs>
</TabsContent> )}
<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>
</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

@@ -7,11 +7,12 @@ import { AlertCircle, CheckCircle2, Users } from 'lucide-react'
interface CoverageReportProps { interface CoverageReportProps {
roundId: string roundId: string
requiredReviews?: number
} }
export function CoverageReport({ roundId }: CoverageReportProps) { export function CoverageReport({ roundId, requiredReviews = 3 }: CoverageReportProps) {
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery( const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery(
{ roundId, requiredReviews: 3 }, { roundId, requiredReviews },
{ refetchInterval: 15_000 }, { refetchInterval: 15_000 },
) )
@@ -71,7 +72,7 @@ export function CoverageReport({ roundId }: CoverageReportProps) {
<CardContent> <CardContent>
<div className="text-2xl font-bold">{unassignedCount}</div> <div className="text-2xl font-bold">{unassignedCount}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Projects below 3 reviews Projects below {requiredReviews} reviews
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

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

@@ -15,6 +15,8 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -38,12 +40,18 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
const [selectedUserId, setSelectedUserId] = useState<string>('') const [selectedUserId, setSelectedUserId] = useState<string>('')
const [maxAssignments, setMaxAssignments] = useState<string>('') const [maxAssignments, setMaxAssignments] = useState<string>('')
const [capMode, setCapMode] = useState<string>('') const [capMode, setCapMode] = useState<string>('')
const [role, setRole] = useState<string>('MEMBER')
const [startupRatio, setStartupRatio] = useState<number | null>(null)
const [availabilityNotes, setAvailabilityNotes] = useState('')
// Invite new user state // Invite new user state
const [inviteName, setInviteName] = useState('') const [inviteName, setInviteName] = useState('')
const [inviteEmail, setInviteEmail] = useState('') const [inviteEmail, setInviteEmail] = useState('')
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('') const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
const [inviteCapMode, setInviteCapMode] = useState<string>('') const [inviteCapMode, setInviteCapMode] = useState<string>('')
const [inviteRole, setInviteRole] = useState<string>('MEMBER')
const [inviteStartupRatio, setInviteStartupRatio] = useState<number | null>(null)
const [inviteAvailabilityNotes, setInviteAvailabilityNotes] = useState('')
const [inviteExpertise, setInviteExpertise] = useState('') const [inviteExpertise, setInviteExpertise] = useState('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -73,9 +81,11 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
addMember({ addMember({
juryGroupId, juryGroupId,
userId: newUser.id, userId: newUser.id,
role: 'MEMBER', role: inviteRole as 'CHAIR' | 'MEMBER' | 'OBSERVER',
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null, maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null, capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
preferredStartupRatio: inviteStartupRatio,
availabilityNotes: inviteAvailabilityNotes.trim() || null,
}) })
// Send invitation email // Send invitation email
sendInvitation({ userId: newUser.id, juryGroupId }) sendInvitation({ userId: newUser.id, juryGroupId })
@@ -88,6 +98,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({ const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
toast.success(`Invitation sent to ${result.email}`) toast.success(`Invitation sent to ${result.email}`)
utils.user.list.invalidate()
}, },
onError: (err) => { onError: (err) => {
// Don't block — user was created and added, just invitation failed // Don't block — user was created and added, just invitation failed
@@ -100,10 +111,16 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
setSelectedUserId('') setSelectedUserId('')
setMaxAssignments('') setMaxAssignments('')
setCapMode('') setCapMode('')
setRole('MEMBER')
setStartupRatio(null)
setAvailabilityNotes('')
setInviteName('') setInviteName('')
setInviteEmail('') setInviteEmail('')
setInviteMaxAssignments('') setInviteMaxAssignments('')
setInviteCapMode('') setInviteCapMode('')
setInviteRole('MEMBER')
setInviteStartupRatio(null)
setInviteAvailabilityNotes('')
setInviteExpertise('') setInviteExpertise('')
} }
@@ -118,9 +135,11 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
addMember({ addMember({
juryGroupId, juryGroupId,
userId: selectedUserId, userId: selectedUserId,
role: 'MEMBER', role: role as 'CHAIR' | 'MEMBER' | 'OBSERVER',
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null, maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null, capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
preferredStartupRatio: startupRatio,
availabilityNotes: availabilityNotes.trim() || null,
}) })
} }
@@ -211,6 +230,19 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="capMode">Cap Mode</Label> <Label htmlFor="capMode">Cap Mode</Label>
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}> <Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
@@ -239,6 +271,38 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
/> />
</div> </div>
<div className="space-y-2">
<Label>Category Preference</Label>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
<Slider
value={[startupRatio !== null ? startupRatio * 100 : 50]}
onValueChange={([v]) => setStartupRatio(v / 100)}
min={0}
max={100}
step={10}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
</div>
<p className="text-xs text-muted-foreground">
{startupRatio !== null
? `~${Math.round(startupRatio * 100)}% startups / ~${Math.round((1 - startupRatio) * 100)}% concepts`
: 'No preference set (balanced distribution)'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="availabilityNotes">Availability Notes (optional)</Label>
<Textarea
id="availabilityNotes"
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
rows={2}
value={availabilityNotes}
onChange={(e) => setAvailabilityNotes(e.target.value)}
/>
</div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
@@ -280,6 +344,19 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="inviteRole">Role</Label>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger id="inviteRole">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="inviteCapMode">Cap Mode</Label> <Label htmlFor="inviteCapMode">Cap Mode</Label>
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}> <Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
@@ -308,6 +385,38 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
/> />
</div> </div>
<div className="space-y-2">
<Label>Category Preference</Label>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
<Slider
value={[inviteStartupRatio !== null ? inviteStartupRatio * 100 : 50]}
onValueChange={([v]) => setInviteStartupRatio(v / 100)}
min={0}
max={100}
step={10}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
</div>
<p className="text-xs text-muted-foreground">
{inviteStartupRatio !== null
? `~${Math.round(inviteStartupRatio * 100)}% startups / ~${Math.round((1 - inviteStartupRatio) * 100)}% concepts`
: 'No preference set (balanced distribution)'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="inviteAvailabilityNotes">Availability Notes (optional)</Label>
<Textarea
id="inviteAvailabilityNotes"
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
rows={2}
value={inviteAvailabilityNotes}
onChange={(e) => setInviteAvailabilityNotes(e.target.value)}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label> <Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
<Input <Input

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">
</div> {(cursor.activeProject.tags as string[]).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</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> <Play className="mr-2 h-4 w-4" />
</CardContent> {resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
</Card> </Button>
) : (
{/* Scores Display */} <Button
<Card> className="w-full"
<CardHeader> variant="outline"
<CardTitle>Live Scores</CardTitle> onClick={() => pauseMutation.mutate({ roundId })}
</CardHeader> disabled={pauseMutation.isPending || !cursor}
<CardContent> >
{scores && scores.length > 0 ? ( <Pause className="mr-2 h-4 w-4" />
<div className="space-y-2"> {pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
{scores.map((score: any, index: number) => ( </Button>
<div )}
key={score.projectId} {cursor?.isPaused && (
className="flex items-center justify-between rounded-lg border p-3" <Badge variant="destructive" className="w-full justify-center py-1">
> Session Paused
<div> </Badge>
<p className="font-medium"> )}
#{index + 1} {score.projectTitle} {cursor?.openCohorts && cursor.openCohorts.length > 0 && (
</p> <div className="rounded-lg border p-3">
<p className="text-sm text-muted-foreground">{score.votes} votes</p> <p className="text-sm font-medium mb-2">Open Voting Windows</p>
</div> {cursor.openCohorts.map((cohort: any) => (
<Badge variant="outline">{score.totalScore.toFixed(1)}</Badge> <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'

File diff suppressed because it is too large Load Diff

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

@@ -250,6 +250,7 @@ export function UserMobileActions({
try { try {
await sendInvitation.mutateAsync({ userId }) await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`) toast.success(`Invitation sent to ${userEmail}`)
utils.user.list.invalidate()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation') toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally { } finally {

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]"> <BarChart
<ResponsiveContainer width="100%" height="100%"> data={chartData}
<BarChart index="criterion"
data={formattedData} categories={['Avg Score']}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }} colors={['indigo']}
> maxValue={10}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> layout="vertical"
<XAxis yAxisWidth={160}
dataKey="displayName" showLegend={false}
tick={{ fontSize: 11 }} className="h-[300px]"
angle={-45} />
textAnchor="end"
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"> <Card>
{/* Metrics Comparison */} <CardHeader>
<Card> <CardTitle>Round Metrics Comparison</CardTitle>
<CardHeader> </CardHeader>
<CardTitle>Stage Metrics Comparison</CardTitle> <CardContent>
</CardHeader> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CardContent> <Card>
<div className="h-[350px]"> <CardHeader className="pb-2">
<ResponsiveContainer width="100%" height="100%"> <CardTitle className="text-sm font-medium">Projects</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart <BarChart
data={comparisonData} data={baseData}
margin={{ top: 20, right: 30, 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} </CardContent>
tick={{ fontSize: 12 }} </Card>
/>
<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 */} <Card>
<div className="grid gap-6 lg:grid-cols-2"> <CardHeader className="pb-2">
<Card> <CardTitle className="text-sm font-medium">
<CardHeader> Evaluations
<CardTitle>Completion Rate by Stage</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="pt-0">
<div className="h-[300px]"> <BarChart
<ResponsiveContainer width="100%" height="100%"> data={baseData}
<BarChart index="name"
data={comparisonData} categories={['Evaluations']}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }} colors={['violet']}
> showLegend={false}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> yAxisWidth={40}
<XAxis className="h-[200px]"
dataKey="name" />
angle={-25} </CardContent>
textAnchor="end" </Card>
height={60}
tick={{ fontSize: 12 }}
/>
<YAxis domain={[0, 100]} unit="%" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Bar dataKey="completionRate" name="Completion %" fill="#6ad82f" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Average Score by Stage</CardTitle> <CardTitle className="text-sm font-medium">
</CardHeader> Completion Rate
<CardContent> </CardTitle>
<div className="h-[300px]"> </CardHeader>
<ResponsiveContainer width="100%" height="100%"> <CardContent className="pt-0">
<BarChart <BarChart
data={comparisonData} data={baseData}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }} index="name"
> categories={['Completion Rate']}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={['emerald']}
<XAxis showLegend={false}
dataKey="name" maxValue={100}
angle={-25} yAxisWidth={40}
textAnchor="end" valueFormatter={(v) => `${v}%`}
height={60} className="h-[200px]"
tick={{ fontSize: 12 }} />
/> </CardContent>
<YAxis domain={[0, 10]} /> </Card>
<Tooltip
contentStyle={{ <Card>
backgroundColor: 'hsl(var(--card))', <CardHeader className="pb-2">
border: '1px solid hsl(var(--border))', <CardTitle className="text-sm font-medium">
borderRadius: '6px', Average Score
}} </CardTitle>
/> </CardHeader>
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} /> <CardContent className="pt-0">
</BarChart> <BarChart
</ResponsiveContainer> data={baseData}
</div> index="name"
</CardContent> categories={['Avg Score']}
</Card> colors={['amber']}
</div> showLegend={false}
</div> maxValue={10}
yAxisWidth={40}
className="h-[200px]"
/>
</CardContent>
</Card>
</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) => { <p className="text-muted-foreground text-center py-8">No geographic data</p>
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>
</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" </div>
width={110} <div className="h-3 w-full rounded-full bg-muted overflow-hidden">
tick={{ fontSize: 13 }} <div
/> className="h-full rounded-full bg-[#053d57] transition-all duration-500"
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} /> style={{ width: `${pct}%` }}
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} /> />
</BarChart> </div>
</ResponsiveContainer> </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]"> <BarChart
<ResponsiveContainer width="100%" height="100%"> data={oceanIssueData}
<BarChart index="issue"
data={formattedOceanIssues} categories={['Projects']}
margin={{ top: 20, right: 30, bottom: 80, left: 20 }} colors={['blue']}
> showLegend={false}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> layout="horizontal"
<XAxis yAxisWidth={200}
dataKey="issue" className="h-[400px]"
angle={-35} />
textAnchor="end"
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]} </div>
label={{ value: 'Average Score', position: 'insideBottom', offset: -10 }} <div className="flex-1">
/> <ScoreDot score={juror.averageScore} />
<YAxis </div>
type="number" <div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
dataKey="stddev" <span className="tabular-nums">&sigma; {juror.stddev.toFixed(1)}</span>
name="Std Deviation" <span className="tabular-nums">{juror.evaluationCount} eval{juror.evaluationCount !== 1 ? 's' : ''}</span>
label={{ value: 'Std Deviation', angle: -90, position: 'insideLeft' }} </div>
/> {juror.isOutlier && (
<Tooltip <AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
contentStyle={{ )}
backgroundColor: 'hsl(var(--card))', </div>
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"> {/* Overall average line */}
Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean). <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,49 +112,92 @@ 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>
<Table> {/* Desktop table */}
<TableHeader> <div className="hidden md:block">
<TableRow> <Table>
<TableHead>Juror</TableHead> <TableHeader>
<TableHead className="text-right">Evaluations</TableHead> <TableRow>
<TableHead className="text-right">Avg Score</TableHead> <TableHead>Juror</TableHead>
<TableHead className="text-right">Std Dev</TableHead> <TableHead className="text-right">Evaluations</TableHead>
<TableHead className="text-right">Deviation from Mean</TableHead> <TableHead className="text-right">Avg Score</TableHead>
<TableHead className="text-center">Status</TableHead> <TableHead className="text-right">Std Dev</TableHead>
</TableRow> <TableHead className="text-right">Deviation</TableHead>
</TableHeader> <TableHead className="text-center">Status</TableHead>
<TableBody>
{data.jurors.map((juror) => (
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}>
<TableCell>
<div>
<p className="font-medium">{juror.name}</p>
<p className="text-xs text-muted-foreground">{juror.email}</p>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{juror.evaluationCount}</TableCell>
<TableCell className="text-right tabular-nums">{juror.averageScore.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">{juror.stddev.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">
{juror.deviationFromOverall.toFixed(2)}
</TableCell>
<TableCell className="text-center">
{juror.isOutlier ? (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
Outlier
</Badge>
) : (
<Badge variant="secondary">Normal</Badge>
)}
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {sorted.map((juror) => (
<TableRow
key={juror.userId}
className={juror.isOutlier ? 'bg-destructive/5' : ''}
>
<TableCell className="font-medium">{juror.name}</TableCell>
<TableCell className="text-right tabular-nums">
{juror.evaluationCount}
</TableCell>
<TableCell className="text-right tabular-nums">
{juror.averageScore.toFixed(2)}
</TableCell>
<TableCell className="text-right tabular-nums">
{juror.stddev.toFixed(2)}
</TableCell>
<TableCell className="text-right tabular-nums">
{juror.deviationFromOverall >= 0 ? '+' : ''}{juror.deviationFromOverall.toFixed(2)}
</TableCell>
<TableCell className="text-center">
{juror.isOutlier ? (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
Outlier
</Badge>
) : (
<Badge variant="secondary">Normal</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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]"> <BarChart
<ResponsiveContainer width="100%" height="100%"> data={chartData}
<BarChart index="juror"
data={formattedData} categories={['Completed', 'Remaining']}
layout="vertical" colors={['blue', 'slate']}
margin={{ top: 20, right: 30, bottom: 20, left: 100 }} layout="horizontal"
> stack={true}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> yAxisWidth={160}
<XAxis type="number" /> className={`h-[${Math.max(300, data.length * 35)}px]`}
<YAxis style={{ height: `${Math.max(300, data.length * 35)}px` }}
dataKey="displayName" />
type="category"
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]"> <BarChart
<ResponsiveContainer width="100%" height="100%"> data={chartData}
<BarChart index="project"
data={displayData} categories={['Score']}
layout="vertical" colors={['blue']}
margin={{ top: 20, right: 30, bottom: 20, left: 150 }} layout="horizontal"
> yAxisWidth={200}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> maxValue={10}
<XAxis type="number" domain={[0, 10]} /> showLegend={false}
<YAxis className={`h-[${Math.max(400, displayData.length * 30)}px]`}
dataKey="displayTitle" style={{ height: `${Math.max(400, displayData.length * 30)}px` }}
type="category" />
width={140}
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]"> <BarChart
<ResponsiveContainer width="100%" height="100%"> data={chartData}
<BarChart index="score"
data={data} categories={['Count']}
margin={{ top: 20, right: 20, bottom: 20, left: 20 }} colors={['blue']}
> yAxisWidth={40}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> showLegend={false}
<XAxis className="h-[300px]"
dataKey="score" />
label={{
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

@@ -0,0 +1,302 @@
'use client'
import Link from 'next/link'
import { motion } from 'motion/react'
import {
Inbox,
Filter,
ClipboardCheck,
Upload,
Users,
Radio,
Scale,
Clock,
ArrowRight,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { StatusBadge } from '@/components/shared/status-badge'
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
export type PipelineRound = {
id: string
name: string
roundType: string
status: string
projectStates: {
PENDING: number
IN_PROGRESS: number
PASSED: number
REJECTED: number
COMPLETED: number
WITHDRAWN: number
total: number
}
assignmentCount: number
evalSubmitted: number
evalDraft: number
evalTotal: number
filteringPassed: number
filteringRejected: number
filteringFlagged: number
filteringTotal: number
liveSessionStatus: string | null
deliberationCount: number
windowOpenAt: Date | null
windowCloseAt: Date | null
sortOrder: number
slug: string
}
type ActiveRoundPanelProps = {
round: PipelineRound
}
const roundTypeIcons: Record<string, React.ElementType> = {
INTAKE: Inbox,
FILTERING: Filter,
EVALUATION: ClipboardCheck,
SUBMISSION: Upload,
MENTORING: Users,
LIVE_FINAL: Radio,
DELIBERATION: Scale,
}
const stateColors: Record<string, { bg: string; label: string }> = {
PENDING: { bg: 'bg-slate-300', label: 'Pending' },
IN_PROGRESS: { bg: 'bg-blue-400', label: 'In Progress' },
PASSED: { bg: 'bg-emerald-500', label: 'Passed' },
REJECTED: { bg: 'bg-red-400', label: 'Rejected' },
COMPLETED: { bg: 'bg-[#557f8c]', label: 'Completed' },
WITHDRAWN: { bg: 'bg-slate-400', label: 'Withdrawn' },
}
function DeadlineCountdown({ date }: { date: Date }) {
const days = daysUntil(date)
if (days < 0) {
return (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
Closed
</Badge>
)
}
return (
<Badge
variant={days <= 2 ? 'destructive' : days <= 7 ? 'warning' : 'info'}
className="gap-1"
>
<Clock className="h-3 w-3" />
{days === 0 ? 'Closes today' : `${days}d left`}
</Badge>
)
}
function ProjectStateBar({
projectStates,
}: {
projectStates: PipelineRound['projectStates']
}) {
const total = projectStates.total
if (total === 0) return null
const segments = (
['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
).filter((key) => projectStates[key] > 0)
return (
<TooltipProvider>
<div className="space-y-2">
<div className="flex h-3 w-full overflow-hidden rounded-full bg-muted">
{segments.map((key) => {
const pct = (projectStates[key] / total) * 100
const color = stateColors[key]
return (
<Tooltip key={key}>
<TooltipTrigger asChild>
<motion.div
className={cn('h-full', color.bg)}
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
/>
</TooltipTrigger>
<TooltipContent>
{color.label}: {projectStates[key]} ({Math.round(pct)}%)
</TooltipContent>
</Tooltip>
)
})}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{segments.map((key) => {
const color = stateColors[key]
return (
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={cn('inline-block h-2 w-2 rounded-full', color.bg)} />
{color.label}: {projectStates[key]}
</div>
)
})}
</div>
</div>
</TooltipProvider>
)
}
function RoundTypeContent({ round }: { round: PipelineRound }) {
const { projectStates } = round
switch (round.roundType) {
case 'INTAKE':
return (
<p className="text-sm text-muted-foreground">
Projects are submitting documents. {projectStates.PASSED} auto-passed,{' '}
{projectStates.PENDING} pending.
</p>
)
case 'FILTERING': {
const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
const total = round.projectStates.total || round.filteringTotal
const pct = total > 0 ? Math.round((processed / total) * 100) : 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Filtering progress</span>
<span className="font-medium">{pct}%</span>
</div>
<Progress value={pct} gradient />
<div className="flex gap-4 text-xs text-muted-foreground">
<span className="text-emerald-600">{round.filteringPassed} passed</span>
<span className="text-red-600">{round.filteringRejected} failed</span>
<span className="text-amber-600">{round.filteringFlagged} flagged</span>
</div>
</div>
)
}
case 'EVALUATION': {
const pct =
round.evalTotal > 0
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
: 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Evaluation progress</span>
<span className="font-medium">
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
</span>
</div>
<Progress value={pct} gradient />
{round.evalDraft > 0 && (
<p className="text-xs text-amber-600">
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
</p>
)}
</div>
)
}
case 'SUBMISSION':
return (
<p className="text-sm text-muted-foreground">
{projectStates.COMPLETED} submissions completed, {projectStates.IN_PROGRESS} in
progress, {projectStates.PENDING} pending.
</p>
)
case 'MENTORING':
return (
<p className="text-sm text-muted-foreground">
Mentoring phase active. {round.assignmentCount} mentor assignment
{round.assignmentCount !== 1 ? 's' : ''} configured.{' '}
{projectStates.COMPLETED} projects completed mentoring.
</p>
)
case 'LIVE_FINAL':
return (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
Live finals round.{' '}
{round.liveSessionStatus
? `Session: ${formatEnumLabel(round.liveSessionStatus)}`
: 'No session started yet.'}
</p>
<p className="text-xs text-muted-foreground">
{projectStates.total} projects in round, {projectStates.COMPLETED} completed.
</p>
</div>
)
case 'DELIBERATION':
return (
<p className="text-sm text-muted-foreground">
Deliberation phase.{' '}
{round.deliberationCount > 0
? `${round.deliberationCount} deliberation session${round.deliberationCount !== 1 ? 's' : ''} recorded.`
: 'No deliberation sessions yet.'}
</p>
)
default:
return (
<p className="text-sm text-muted-foreground">
{projectStates.total} projects in this round.
</p>
)
}
}
export function ActiveRoundPanel({ round }: ActiveRoundPanelProps) {
const Icon = roundTypeIcons[round.roundType] || ClipboardCheck
return (
<Card>
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-blue/10">
<Icon className="h-5 w-5 text-brand-blue" />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="truncate">{round.name}</CardTitle>
<p className="mt-0.5 text-xs text-muted-foreground">
{formatEnumLabel(round.roundType)}
</p>
</div>
<div className="flex items-center gap-2">
<StatusBadge status={round.status} size="sm" />
{round.windowCloseAt && (
<DeadlineCountdown date={new Date(round.windowCloseAt)} />
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<ProjectStateBar projectStates={round.projectStates} />
<RoundTypeContent round={round} />
<div className="pt-1">
<Button asChild size="sm">
<Link href={`/admin/rounds/${round.id}`}>
Manage Round
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { motion } from 'motion/react'
import { Activity } from 'lucide-react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { formatRelativeTime } from '@/lib/utils'
import { formatAction, getActionIcon } from '@/components/dashboard/utils'
type ActivityFeedProps = {
activity: Array<{
id: string
action: string
entityType: string | null
timestamp: Date
user: { name: string | null } | null
}>
}
export function ActivityFeed({ activity }: ActivityFeedProps) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
<Activity className="h-4 w-4 text-brand-blue" />
</div>
<CardTitle className="text-base">Activity</CardTitle>
</div>
</CardHeader>
<CardContent>
{activity.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Activity className="h-8 w-8 text-muted-foreground/30" />
<p className="mt-2 text-xs text-muted-foreground">
No recent activity
</p>
</div>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[13px] top-2 bottom-2 w-px bg-border" />
<div className="space-y-3">
{activity.map((log, idx) => (
<motion.div
key={log.id}
initial={{ opacity: 0, x: 6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0.2 + idx * 0.03 }}
className="relative flex items-start gap-3"
>
<div className="relative z-10 flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-full border-2 border-background bg-muted">
{getActionIcon(log.action)}
</div>
<div className="flex-1 min-w-0 pt-0.5">
<p className="text-xs leading-relaxed">
<span className="font-semibold">{log.user?.name || 'System'}</span>
{' '}{formatAction(log.action, log.entityType)}
</p>
<p className="text-[11px] text-muted-foreground">
{formatRelativeTime(log.timestamp)}
</p>
</div>
</motion.div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
import { motion } from 'motion/react'
import { Layers } from 'lucide-react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { formatEnumLabel } from '@/lib/utils'
type CategoryBreakdownProps = {
categories: Array<{ competitionCategory: string | null; _count: number }>
issues: Array<{ oceanIssue: string | null; _count: number }>
}
export function CategoryBreakdown({ categories: rawCategories, issues: rawIssues }: CategoryBreakdownProps) {
const categories = rawCategories
.filter((c) => c.competitionCategory !== null)
.map((c) => ({
label: formatEnumLabel(c.competitionCategory!),
count: c._count,
}))
.sort((a, b) => b.count - a.count)
const issues = rawIssues
.filter((i) => i.oceanIssue !== null)
.map((i) => ({
label: formatEnumLabel(i.oceanIssue!),
count: i._count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 5)
const maxCategoryCount = Math.max(...categories.map((c) => c.count), 1)
const maxIssueCount = Math.max(...issues.map((i) => i.count), 1)
return (
<Card className="h-full">
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10">
<Layers className="h-4 w-4 text-violet-500" />
</div>
<CardTitle className="text-base">Categories</CardTitle>
</div>
</CardHeader>
<CardContent>
{categories.length === 0 && issues.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Layers className="h-8 w-8 text-muted-foreground/30" />
<p className="mt-2 text-xs text-muted-foreground">
No category data
</p>
</div>
) : (
<div className="space-y-5">
{categories.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Competition Type
</p>
{categories.map((cat) => (
<div key={cat.label} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="truncate mr-2">{cat.label}</span>
<span className="font-bold tabular-nums">{cat.count}</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-brand-blue to-brand-teal"
initial={{ width: 0 }}
animate={{ width: `${(cat.count / maxCategoryCount) * 100}%` }}
transition={{ duration: 0.6, delay: 0.3, ease: 'easeOut' }}
/>
</div>
</div>
))}
</div>
)}
{issues.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Top Issues
</p>
{issues.map((issue) => (
<div key={issue.label} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="truncate mr-2">{issue.label}</span>
<span className="font-bold tabular-nums">{issue.count}</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-teal-light"
initial={{ width: 0 }}
animate={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
transition={{ duration: 0.6, delay: 0.4, ease: 'easeOut' }}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { motion } from 'motion/react'
import { Workflow, ArrowRight } from 'lucide-react'
import {
PipelineRoundNode,
type PipelineRound,
} from '@/components/dashboard/pipeline-round-node'
function Connector({
prevStatus,
index,
}: {
prevStatus: string
index: number
}) {
const isCompleted =
prevStatus === 'ROUND_CLOSED' || prevStatus === 'ROUND_ARCHIVED'
return (
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.25, delay: 0.15 + index * 0.06 }}
className="flex items-center self-center origin-left"
>
<div
className={cn(
'h-0.5 w-6',
isCompleted ? 'bg-emerald-300' : 'bg-slate-200'
)}
/>
</motion.div>
)
}
export function CompetitionPipeline({
rounds,
}: {
rounds: PipelineRound[]
}) {
if (rounds.length === 0) {
return (
<Card className="border-dashed">
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-500/10">
<Workflow className="h-4 w-4 text-slate-500" />
</div>
<CardTitle className="text-base">Competition Pipeline</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
<Workflow className="h-7 w-7 text-muted-foreground/40" />
</div>
<p className="mt-3 text-sm font-medium text-muted-foreground">
No rounds configured yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Create rounds to visualize the competition pipeline.
</p>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-teal/10">
<Workflow className="h-4 w-4 text-brand-teal" />
</div>
<CardTitle className="text-base">Competition Pipeline</CardTitle>
</div>
<Link
href="/admin/rounds"
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wider text-brand-teal hover:text-brand-teal-light transition-colors"
>
All rounds <ArrowRight className="h-3 w-3" />
</Link>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto pb-2">
<div className="flex items-start gap-0 min-w-max">
{rounds.map((round, index) => (
<div key={round.id} className="flex items-start">
<PipelineRoundNode round={round} index={index} />
{index < rounds.length - 1 && (
<Connector
prevStatus={round.status}
index={index}
/>
)}
</div>
))}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
export function DashboardSkeleton() {
return (
<>
{/* Header skeleton */}
<Skeleton className="h-32 w-full rounded-2xl" />
{/* Stats row skeleton */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{[...Array(5)].map((_, i) => (
<Card key={i} className="border-l-4 border-l-muted">
<CardContent className="p-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="mt-2 h-8 w-12" />
<Skeleton className="mt-1 h-3 w-20" />
</CardContent>
</Card>
))}
</div>
{/* Two-column content skeleton */}
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-8">
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6 lg:col-span-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader><Skeleton className="h-5 w-32" /></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, j) => (
<Skeleton key={j} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* Bottom skeleton */}
<div className="grid gap-6 lg:grid-cols-12">
<Skeleton className="h-[400px] w-full rounded-lg lg:col-span-8" />
<Skeleton className="h-[400px] w-full rounded-lg lg:col-span-4" />
</div>
</>
)
}

View File

@@ -0,0 +1,218 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { cn } from '@/lib/utils'
import { motion } from 'motion/react'
import {
Upload,
Filter,
ClipboardCheck,
FileUp,
GraduationCap,
Radio,
Scale,
} from 'lucide-react'
type PipelineRound = {
id: string
name: string
slug: string
roundType:
| 'INTAKE'
| 'FILTERING'
| 'EVALUATION'
| 'SUBMISSION'
| 'MENTORING'
| 'LIVE_FINAL'
| 'DELIBERATION'
status:
| 'ROUND_DRAFT'
| 'ROUND_ACTIVE'
| 'ROUND_CLOSED'
| 'ROUND_ARCHIVED'
sortOrder: number
windowOpenAt: Date | null
windowCloseAt: Date | null
projectStates: {
PENDING: number
IN_PROGRESS: number
PASSED: number
REJECTED: number
COMPLETED: number
WITHDRAWN: number
total: number
}
assignmentCount: number
evalSubmitted: number
evalDraft: number
evalTotal: number
filteringPassed: number
filteringRejected: number
filteringFlagged: number
filteringTotal: number
liveSessionStatus: string | null
deliberationCount: number
}
const roundTypeConfig: Record<
string,
{ icon: typeof Upload; iconColor: string; iconBg: string }
> = {
INTAKE: { icon: Upload, iconColor: 'text-sky-600', iconBg: 'bg-sky-100' },
FILTERING: {
icon: Filter,
iconColor: 'text-amber-600',
iconBg: 'bg-amber-100',
},
EVALUATION: {
icon: ClipboardCheck,
iconColor: 'text-violet-600',
iconBg: 'bg-violet-100',
},
SUBMISSION: {
icon: FileUp,
iconColor: 'text-blue-600',
iconBg: 'bg-blue-100',
},
MENTORING: {
icon: GraduationCap,
iconColor: 'text-teal-600',
iconBg: 'bg-teal-100',
},
LIVE_FINAL: {
icon: Radio,
iconColor: 'text-red-600',
iconBg: 'bg-red-100',
},
DELIBERATION: {
icon: Scale,
iconColor: 'text-indigo-600',
iconBg: 'bg-indigo-100',
},
}
const statusStyles: Record<
string,
{ container: string; label: string }
> = {
ROUND_DRAFT: {
container:
'bg-slate-50 border-slate-200 text-slate-400 border-dashed',
label: 'Draft',
},
ROUND_ACTIVE: {
container:
'bg-blue-50 border-blue-300 text-blue-700 ring-2 ring-blue-400/30 shadow-lg shadow-blue-500/10',
label: 'Active',
},
ROUND_CLOSED: {
container: 'bg-emerald-50 border-emerald-200 text-emerald-600',
label: 'Closed',
},
ROUND_ARCHIVED: {
container: 'bg-slate-50/50 border-slate-100 text-slate-300',
label: 'Archived',
},
}
function getMetric(round: PipelineRound): string {
const { roundType, projectStates, filteringTotal, filteringPassed, evalTotal, evalSubmitted, assignmentCount, liveSessionStatus, deliberationCount } = round
switch (roundType) {
case 'INTAKE':
return `${projectStates.total} submitted`
case 'FILTERING':
return filteringTotal > 0
? `${filteringPassed}/${filteringTotal} passed`
: `${projectStates.total} to filter`
case 'EVALUATION':
return evalTotal > 0
? `${evalSubmitted}/${evalTotal} evaluated`
: `${assignmentCount} assignments`
case 'SUBMISSION':
return `${projectStates.COMPLETED} submitted`
case 'MENTORING':
return `${projectStates.COMPLETED ?? 0} mentored`
case 'LIVE_FINAL': {
const status = liveSessionStatus
return status ? status.charAt(0) + status.slice(1).toLowerCase() : `${projectStates.total} finalists`
}
case 'DELIBERATION':
return deliberationCount > 0
? `${deliberationCount} sessions`
: 'Not started'
default:
return `${projectStates.total} projects`
}
}
export function PipelineRoundNode({
round,
index,
}: {
round: PipelineRound
index: number
}) {
const typeConfig = roundTypeConfig[round.roundType] ?? roundTypeConfig.INTAKE
const Icon = typeConfig.icon
const status = statusStyles[round.status] ?? statusStyles.ROUND_DRAFT
const isActive = round.status === 'ROUND_ACTIVE'
const metric = getMetric(round)
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 + index * 0.06 }}
>
<Link
href={`/admin/rounds/${round.id}` as Route}
className="group block"
>
<div
className={cn(
'relative flex flex-col items-center rounded-xl border-2 p-3 transition-all hover:-translate-y-0.5 hover:shadow-md',
isActive ? 'w-44' : 'w-36',
status.container
)}
>
{/* Active ping indicator */}
{isActive && (
<span className="absolute -right-1 -top-1 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>
)}
{/* Icon */}
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg',
typeConfig.iconBg
)}
>
<Icon className={cn('h-4 w-4', typeConfig.iconColor)} />
</div>
{/* Name */}
<p className="mt-2 text-center text-xs font-semibold leading-tight line-clamp-2 group-hover:text-foreground transition-colors">
{round.name}
</p>
{/* Status label */}
<span className="mt-1.5 text-[10px] font-medium uppercase tracking-wider opacity-70">
{status.label}
</span>
{/* Metric */}
<p className="mt-1 text-[11px] font-medium tabular-nums opacity-80">
{metric}
</p>
</div>
</Link>
</motion.div>
)
}
export type { PipelineRound }

View File

@@ -0,0 +1,114 @@
'use client'
import Link from 'next/link'
import { motion } from 'motion/react'
import { ClipboardList, ArrowRight } from 'lucide-react'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
import { formatDateOnly, truncate } from '@/lib/utils'
type ProjectListCompactProps = {
projects: Array<{
id: string
title: string
teamName: string | null
country: string | null
competitionCategory: string | null
oceanIssue: string | null
logoKey: string | null
createdAt: Date
submittedAt: Date | null
status: string
}>
}
export function ProjectListCompact({ projects }: ProjectListCompactProps) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
<ClipboardList className="h-4 w-4 text-brand-blue" />
</div>
<div>
<CardTitle className="text-base">Recent Projects</CardTitle>
<CardDescription className="text-xs">Latest submissions</CardDescription>
</div>
</div>
<Link
href="/admin/projects"
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wider text-brand-teal hover:text-brand-teal-light transition-colors"
>
All projects <ArrowRight className="h-3 w-3" />
</Link>
</div>
</CardHeader>
<CardContent>
{projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
<ClipboardList className="h-7 w-7 text-muted-foreground/40" />
</div>
<p className="mt-3 text-sm font-medium text-muted-foreground">
No projects submitted yet
</p>
</div>
) : (
<div className="divide-y">
{projects.map((project, idx) => (
<motion.div
key={project.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, delay: 0.15 + idx * 0.04 }}
>
<Link
href={`/admin/projects/${project.id}`}
className="block"
>
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
{truncate(project.title, 50)}
</p>
<StatusBadge
status={project.status ?? 'SUBMITTED'}
size="sm"
className="shrink-0"
/>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{[
project.teamName,
project.country ? getCountryName(project.country) : null,
formatDateOnly(project.submittedAt || project.createdAt),
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
</div>
</div>
</Link>
</motion.div>
))}
</div>
)}
</CardContent>
</Card>
)
}

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

@@ -0,0 +1,94 @@
'use client'
import { motion } from 'motion/react'
type PipelineRound = {
id: string
name: string
roundType: string
status: string
assignmentCount: number
evalSubmitted: number
evalDraft: number
evalTotal: number
}
type RoundStatsEvaluationProps = {
round: PipelineRound
activeJurors: number
}
export function RoundStatsEvaluation({ round, activeJurors }: RoundStatsEvaluationProps) {
const { assignmentCount, evalSubmitted, evalDraft, evalTotal } = round
const completionPct = evalTotal > 0 ? ((evalSubmitted / evalTotal) * 100).toFixed(0) : '0'
const stats = [
{
value: assignmentCount,
label: 'Assignments',
detail: 'Jury-project pairs',
accent: 'text-brand-blue',
},
{
value: `${evalSubmitted}/${evalTotal}`,
label: 'Submitted',
detail: `${completionPct}% complete`,
accent: 'text-emerald-600',
},
{
value: evalDraft,
label: 'In draft',
detail: evalDraft > 0 ? 'Not yet submitted' : 'No drafts',
accent: evalDraft > 0 ? 'text-amber-600' : 'text-emerald-600',
},
{
value: activeJurors,
label: 'Active jurors',
detail: 'Evaluating',
accent: 'text-brand-teal',
},
]
return (
<>
{/* Round label */}
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
{round.name} &mdash; Evaluation
</p>
{/* Mobile: horizontal data strip */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
>
{stats.map((s, i) => (
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
<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>
</div>
))}
</motion.div>
{/* Desktop: editorial stat row */}
<div className="hidden md:block">
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
{stats.map((s, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.06 }}
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>
</motion.div>
))}
</div>
</div>
</>
)
}

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