Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Build and Push Docker Image / build (push) Failing after 5m5s
Details
Build and Push Docker Image / build (push) Failing after 5m5s
Details
Batch 1 - Quick Wins: - F1: Evaluation progress indicator with touch tracking in sticky status bar - F2: Export filtering results as CSV with dynamic AI column flattening - F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure) Batch 2 - Jury Experience: - F4: Countdown timer component with urgency colors + email reminder service with cron endpoint - F5: Conflict of interest declaration system (dialog, admin management, review workflow) Batch 3 - Admin & AI Enhancements: - F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording - F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns - F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking) Batch 4 - Form Flexibility & Applicant Portal: - F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility) - F10: Applicant portal (status timeline, per-round documents, mentor messaging) Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
002a9dbfc3
commit
699248e40b
53
CLAUDE.md
53
CLAUDE.md
|
|
@ -22,8 +22,11 @@ The platform is designed for future expansion into a comprehensive program manag
|
||||||
| First Admin | Database seed script |
|
| First Admin | Database seed script |
|
||||||
| Past Evaluations | Visible read-only after submit |
|
| Past Evaluations | Visible read-only after submit |
|
||||||
| Grace Period | Admin-configurable per juror/project |
|
| Grace Period | Admin-configurable per juror/project |
|
||||||
| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback |
|
| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback + geo-diversity, familiarity, COI scoring |
|
||||||
| AI Data Privacy | All data anonymized before sending to GPT |
|
| AI Data Privacy | All data anonymized before sending to GPT |
|
||||||
|
| Evaluation Criteria Types | `numeric`, `text`, `boolean`, `section_header` (backward-compatible) |
|
||||||
|
| COI Workflow | Mandatory declaration before evaluation, admin review |
|
||||||
|
| Evaluation Reminders | Cron-based email reminders with countdown urgency |
|
||||||
|
|
||||||
## Brand Identity
|
## Brand Identity
|
||||||
|
|
||||||
|
|
@ -83,14 +86,22 @@ mopc-platform/
|
||||||
│ │ ├── (admin)/ # Admin dashboard (protected)
|
│ │ ├── (admin)/ # Admin dashboard (protected)
|
||||||
│ │ ├── (jury)/ # Jury interface (protected)
|
│ │ ├── (jury)/ # Jury interface (protected)
|
||||||
│ │ ├── api/ # API routes
|
│ │ ├── api/ # API routes
|
||||||
│ │ │ └── trpc/ # tRPC endpoint
|
│ │ │ ├── trpc/ # tRPC endpoint
|
||||||
|
│ │ │ └── cron/
|
||||||
|
│ │ │ └── reminders/ # Cron endpoint for evaluation reminders (F4)
|
||||||
│ │ ├── layout.tsx # Root layout
|
│ │ ├── layout.tsx # Root layout
|
||||||
│ │ └── page.tsx # Home/landing
|
│ │ └── page.tsx # Home/landing
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── ui/ # shadcn/ui components
|
│ │ ├── ui/ # shadcn/ui components
|
||||||
│ │ ├── forms/ # Form components (evaluation, etc.)
|
│ │ ├── admin/ # Admin-specific components
|
||||||
|
│ │ │ └── evaluation-summary-card.tsx # AI summary display
|
||||||
|
│ │ ├── forms/ # Form components
|
||||||
|
│ │ │ ├── evaluation-form.tsx # With progress indicator (F1)
|
||||||
|
│ │ │ ├── coi-declaration-dialog.tsx # COI blocking dialog (F5)
|
||||||
|
│ │ │ └── evaluation-form-with-coi.tsx # COI-gated wrapper (F5)
|
||||||
│ │ ├── layouts/ # Layout components (sidebar, nav)
|
│ │ ├── layouts/ # Layout components (sidebar, nav)
|
||||||
│ │ └── shared/ # Shared components
|
│ │ └── shared/ # Shared components
|
||||||
|
│ │ └── countdown-timer.tsx # Live countdown with urgency (F4)
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── auth.ts # NextAuth configuration
|
│ │ ├── auth.ts # NextAuth configuration
|
||||||
│ │ ├── prisma.ts # Prisma client singleton
|
│ │ ├── prisma.ts # Prisma client singleton
|
||||||
|
|
@ -107,8 +118,14 @@ mopc-platform/
|
||||||
│ │ │ ├── evaluation.ts
|
│ │ │ ├── evaluation.ts
|
||||||
│ │ │ ├── audit.ts
|
│ │ │ ├── audit.ts
|
||||||
│ │ │ ├── settings.ts
|
│ │ │ ├── settings.ts
|
||||||
│ │ │ └── gracePeriod.ts
|
│ │ │ ├── gracePeriod.ts
|
||||||
|
│ │ │ ├── export.ts # CSV export incl. filtering results (F2)
|
||||||
|
│ │ │ ├── analytics.ts # Reports/analytics (observer access, F3)
|
||||||
|
│ │ │ └── mentor.ts # Mentor messaging endpoints (F10)
|
||||||
│ │ ├── services/ # Business logic services
|
│ │ ├── services/ # Business logic services
|
||||||
|
│ │ │ ├── smart-assignment.ts # With geo/familiarity/COI scoring (F8)
|
||||||
|
│ │ │ ├── evaluation-reminders.ts # Email reminder service (F4)
|
||||||
|
│ │ │ └── ai-evaluation-summary.ts # GPT summary generation (F7)
|
||||||
│ │ └── middleware/ # RBAC & auth middleware
|
│ │ └── middleware/ # RBAC & auth middleware
|
||||||
│ ├── hooks/ # React hooks
|
│ ├── hooks/ # React hooks
|
||||||
│ ├── types/ # Shared TypeScript types
|
│ ├── types/ # Shared TypeScript types
|
||||||
|
|
@ -240,8 +257,11 @@ SMTP_USER="noreply@monaco-opc.com"
|
||||||
SMTP_PASS="your-smtp-password"
|
SMTP_PASS="your-smtp-password"
|
||||||
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
|
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
|
||||||
|
|
||||||
# OpenAI (for smart assignment)
|
# OpenAI (for smart assignment and AI evaluation summaries)
|
||||||
OPENAI_API_KEY="your-openai-api-key"
|
OPENAI_API_KEY="your-openai-api-key"
|
||||||
|
|
||||||
|
# Cron (for scheduled evaluation reminders)
|
||||||
|
CRON_SECRET="your-cron-secret-key"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Architectural Decisions
|
## Key Architectural Decisions
|
||||||
|
|
@ -273,8 +293,10 @@ OPENAI_API_KEY="your-openai-api-key"
|
||||||
|------|------------|
|
|------|------------|
|
||||||
| **SUPER_ADMIN** | Full system access, all programs, user management |
|
| **SUPER_ADMIN** | Full system access, all programs, user management |
|
||||||
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
|
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
|
||||||
| **JURY_MEMBER** | View assigned projects only, submit evaluations |
|
| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
|
||||||
| **OBSERVER** | Read-only access to dashboards (optional) |
|
| **OBSERVER** | Read-only access to dashboards, all analytics/reports |
|
||||||
|
| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` |
|
||||||
|
| **APPLICANT** | View own project status, upload documents per round, message mentor |
|
||||||
|
|
||||||
## Important Constraints
|
## Important Constraints
|
||||||
|
|
||||||
|
|
@ -286,6 +308,12 @@ OPENAI_API_KEY="your-openai-api-key"
|
||||||
6. **Mobile responsiveness is mandatory** - every view must work on phones
|
6. **Mobile responsiveness is mandatory** - every view must work on phones
|
||||||
7. **File downloads require project authorization** - jury/mentor must be assigned to the project
|
7. **File downloads require project authorization** - jury/mentor must be assigned to the project
|
||||||
8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware
|
8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware
|
||||||
|
9. **COI declaration required before evaluation** - blocking dialog gates evaluation form; admin reviews COI declarations
|
||||||
|
10. **Evaluation form supports multiple criterion types** - `numeric`, `text`, `boolean`, `section_header`; defaults to `numeric` for backward compatibility
|
||||||
|
11. **Smart assignment respects COI** - jurors with declared conflicts are skipped entirely; geo-diversity penalty and prior-round familiarity bonus applied
|
||||||
|
12. **Cron endpoints protected by CRON_SECRET** - `/api/cron/reminders` validates secret header
|
||||||
|
13. **Project status changes tracked** - every status update creates a `ProjectStatusHistory` record
|
||||||
|
14. **Per-round document management** - `ProjectFile` supports `roundId` scoping and `isLate` deadline tracking
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
|
|
@ -326,9 +354,18 @@ The MOPC platform connects to these via environment variables.
|
||||||
- Progress dashboards
|
- Progress dashboards
|
||||||
- CSV export
|
- CSV export
|
||||||
- Audit logging
|
- Audit logging
|
||||||
|
- **F1: Evaluation progress indicator** - sticky status bar with percentage tracking across criteria, global score, decision, feedback
|
||||||
|
- **F2: Export filtering results as CSV** - dynamic AI column flattening from `aiScreeningJson`
|
||||||
|
- **F3: Observer access to reports/analytics** - all 8 analytics procedures use `observerProcedure`; observer reports page with round selector, tabs, charts
|
||||||
|
- **F4: Countdown timer + email reminders** - live countdown with urgency colors; `EvaluationRemindersService` with cron endpoint (`/api/cron/reminders`)
|
||||||
|
- **F5: Conflict of Interest declaration** - `ConflictOfInterest` model; blocking dialog before evaluation; admin COI review page
|
||||||
|
- **F6: Bulk status update UI** - checkbox selection, floating toolbar, `ProjectStatusHistory` tracking
|
||||||
|
- **F7: AI-powered evaluation summary** - `EvaluationSummary` model; GPT-generated strengths/weaknesses, themes, scoring stats
|
||||||
|
- **F8: Smart assignment improvements** - `geoDiversityPenalty`, `previousRoundFamiliarity`, `coiPenalty` scoring factors
|
||||||
|
- **F9: Evaluation form flexibility** - extended criterion types (`numeric`, `text`, `boolean`, `section_header`); conditional visibility, section grouping
|
||||||
|
- **F10: Applicant portal enhancements** - `ProjectStatusHistory` timeline; per-round document management (`roundId` + `isLate` on `ProjectFile`); `MentorMessage` model for mentor-applicant chat
|
||||||
|
|
||||||
### Out of Scope (Phase 2+)
|
### Out of Scope (Phase 2+)
|
||||||
- Auto-assignment algorithm
|
|
||||||
- Typeform/Notion integrations
|
- Typeform/Notion integrations
|
||||||
- WhatsApp notifications
|
- WhatsApp notifications
|
||||||
- Learning hub
|
- Learning hub
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,13 @@ RUN npx prisma generate
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application at build time (pre-compile everything)
|
# Build the application at build time (pre-compile everything)
|
||||||
|
# Dummy env vars needed so Next.js can collect page data without MinIO throwing
|
||||||
|
ENV MINIO_ACCESS_KEY=build-placeholder
|
||||||
|
ENV MINIO_SECRET_KEY=build-placeholder
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
# Clear build-time placeholders (runtime uses real values from docker-compose)
|
||||||
|
ENV MINIO_ACCESS_KEY=
|
||||||
|
ENV MINIO_SECRET_KEY=
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
|
||||||
|
|
@ -65,16 +65,25 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty
|
||||||
│ PRESENTATION LAYER │
|
│ PRESENTATION LAYER │
|
||||||
├─────────────────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
│ │ Admin Views │ │ Jury Views │ │ Auth Views │ │
|
│ │ Admin Views │ │ Jury Views │ │Applicant View│ │ Mentor Views │ │
|
||||||
│ │ │ │ │ │ │ │
|
│ │ │ │ │ │ │ │ │ │
|
||||||
│ │ - Dashboard │ │ - Project List │ │ - Login │ │
|
│ │ - Dashboard │ │ - Project Ls │ │ - Status │ │ - Assigned │ │
|
||||||
│ │ - Rounds │ │ - Project View │ │ - Magic Link │ │
|
│ │ - Rounds │ │ - Project Vw │ │ Tracker │ │ Projects │ │
|
||||||
│ │ - Projects │ │ - Evaluation │ │ - Verify │ │
|
│ │ - Projects │ │ - Evaluation │ │ - Document │ │ - Messaging │ │
|
||||||
│ │ - Jury Mgmt │ │ - My Progress │ │ │ │
|
│ │ - Jury Mgmt │ │ - My Progress│ │ Uploads │ │ │ │
|
||||||
│ │ - Assignments │ │ │ │ │ │
|
│ │ - Assignments│ │ - COI Decl. │ │ - Mentor │ │ │ │
|
||||||
│ │ - Reports │ │ │ │ │ │
|
│ │ - Reports │ │ - Countdown │ │ Chat │ │ │ │
|
||||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
│ │ - COI Review │ │ │ │ │ │ │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Auth Views │ │ Observer Views │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ - Login │ │ - Reports/ │ │
|
||||||
|
│ │ - Magic Link │ │ Analytics │ │
|
||||||
|
│ │ - Verify │ │ - Round Stats │ │
|
||||||
|
│ └──────────────────┘ └──────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ Built with: Next.js App Router + React Server Components + shadcn/ui │
|
│ Built with: Next.js App Router + React Server Components + shadcn/ui │
|
||||||
└─────────────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
@ -116,6 +125,11 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty
|
||||||
│ │ Service │ │ Service │ │ Service │ │ Service │ │
|
│ │ Service │ │ Service │ │ Service │ │ Service │ │
|
||||||
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
|
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ │
|
||||||
|
│ │ Evaluation │ │ AI Eval │ │
|
||||||
|
│ │ Reminders │ │ Summary │ │
|
||||||
|
│ └────────────┘ └────────────┘ │
|
||||||
|
│ │
|
||||||
└─────────────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
|
|
@ -151,8 +165,11 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty
|
||||||
|
|
||||||
| Component | Responsibility |
|
| Component | Responsibility |
|
||||||
|-----------|----------------|
|
|-----------|----------------|
|
||||||
| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards |
|
| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards, COI review, bulk status updates, AI evaluation summaries |
|
||||||
| **Jury Views** | View assigned projects, evaluate projects, track progress |
|
| **Jury Views** | View assigned projects, evaluate projects, track progress, COI declarations, countdown timer |
|
||||||
|
| **Applicant Views** | Project status tracker, document uploads (per-round with deadline policy), mentor chat |
|
||||||
|
| **Mentor Views** | View assigned projects, messaging with applicants |
|
||||||
|
| **Observer Views** | Read-only access to all reports/analytics (round selector, tabs, all chart components) |
|
||||||
| **Auth Views** | Login, magic link verification, session management |
|
| **Auth Views** | Login, magic link verification, session management |
|
||||||
| **Layouts** | Responsive navigation, sidebar, mobile adaptations |
|
| **Layouts** | Responsive navigation, sidebar, mobile adaptations |
|
||||||
| **UI Components** | shadcn/ui based, reusable, accessible |
|
| **UI Components** | shadcn/ui based, reusable, accessible |
|
||||||
|
|
@ -177,8 +194,10 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty
|
||||||
| **EvaluationService** | Form submission, autosave, scoring |
|
| **EvaluationService** | Form submission, autosave, scoring |
|
||||||
| **FileService** | MinIO uploads, pre-signed URLs |
|
| **FileService** | MinIO uploads, pre-signed URLs |
|
||||||
| **EmailService** | Magic links, notifications via Nodemailer |
|
| **EmailService** | Magic links, notifications via Nodemailer |
|
||||||
| **ExportService** | CSV/Excel generation |
|
| **ExportService** | CSV/Excel generation, filtering results export with AI column flattening |
|
||||||
| **AuditService** | Immutable event logging |
|
| **AuditService** | Immutable event logging |
|
||||||
|
| **EvaluationRemindersService** | Finds incomplete assignments, sends email reminders with countdown urgency |
|
||||||
|
| **AIEvaluationSummaryService** | Anonymizes evaluation data, generates GPT-powered summaries with scoring patterns, strengths/weaknesses |
|
||||||
|
|
||||||
### Data Layer
|
### Data Layer
|
||||||
|
|
||||||
|
|
@ -345,8 +364,14 @@ The platform includes two assignment modes:
|
||||||
```
|
```
|
||||||
Score = (expertise_match × 40) + (load_balance × 25) +
|
Score = (expertise_match × 40) + (load_balance × 25) +
|
||||||
(specialty_match × 20) + (diversity × 10) - (conflict × 100)
|
(specialty_match × 20) + (diversity × 10) - (conflict × 100)
|
||||||
|
- (geo_diversity_penalty) + (previous_round_familiarity) - (coi_penalty)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Additional Scoring Factors** (added in Phase 1 enhancements):
|
||||||
|
- **geoDiversityPenalty**: -15 per excess same-country assignment beyond threshold of 2
|
||||||
|
- **previousRoundFamiliarity**: +10 bonus for jurors who reviewed the same project in a prior round
|
||||||
|
- **coiPenalty**: Jurors with a declared Conflict of Interest are skipped entirely
|
||||||
|
|
||||||
## Admin Settings Panel
|
## Admin Settings Panel
|
||||||
|
|
||||||
Centralized configuration for:
|
Centralized configuration for:
|
||||||
|
|
@ -363,8 +388,10 @@ Centralized configuration for:
|
||||||
|------|------------|
|
|------|------------|
|
||||||
| **SUPER_ADMIN** | Full system access, all programs, user management |
|
| **SUPER_ADMIN** | Full system access, all programs, user management |
|
||||||
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
|
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
|
||||||
| **JURY_MEMBER** | View assigned projects only, submit evaluations |
|
| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
|
||||||
| **OBSERVER** | Read-only access to dashboards |
|
| **OBSERVER** | Read-only access to dashboards and all analytics/reports |
|
||||||
|
| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` |
|
||||||
|
| **APPLICANT** | View own project status, upload documents per round, message mentor |
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,9 @@ export const hasRole = (...roles: UserRole[]) =>
|
||||||
export const protectedProcedure = t.procedure.use(isAuthenticated)
|
export const protectedProcedure = t.procedure.use(isAuthenticated)
|
||||||
export const adminProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
|
export const adminProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
|
||||||
export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER'))
|
export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER'))
|
||||||
|
export const observerProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
|
||||||
|
export const mentorProcedure = t.procedure.use(hasRole('MENTOR'))
|
||||||
|
export const applicantProcedure = t.procedure.use(hasRole('APPLICANT'))
|
||||||
```
|
```
|
||||||
|
|
||||||
## Router Structure
|
## Router Structure
|
||||||
|
|
@ -136,7 +139,9 @@ src/server/routers/
|
||||||
├── export.ts # Export operations
|
├── export.ts # Export operations
|
||||||
├── audit.ts # Audit log access
|
├── audit.ts # Audit log access
|
||||||
├── settings.ts # Platform settings (admin)
|
├── settings.ts # Platform settings (admin)
|
||||||
└── gracePeriod.ts # Grace period management
|
├── gracePeriod.ts # Grace period management
|
||||||
|
├── analytics.ts # Reports & analytics (admin + observer)
|
||||||
|
└── mentor.ts # Mentor messaging endpoints
|
||||||
```
|
```
|
||||||
|
|
||||||
### Root Router
|
### Root Router
|
||||||
|
|
@ -1024,6 +1029,164 @@ export const gracePeriodRouter = router({
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Conflict of Interest Endpoints (Evaluation Router)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Added to src/server/routers/evaluation.ts
|
||||||
|
|
||||||
|
// Declare COI for an assignment (jury member)
|
||||||
|
declareCOI: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
assignmentId: z.string(),
|
||||||
|
hasConflict: z.boolean(),
|
||||||
|
conflictType: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Creates/updates ConflictOfInterest record
|
||||||
|
// Blocks evaluation access if hasConflict = true until reviewed
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get COI status for an assignment
|
||||||
|
getCOIStatus: protectedProcedure
|
||||||
|
.input(z.object({ assignmentId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Returns COI declaration status for the assignment
|
||||||
|
}),
|
||||||
|
|
||||||
|
// List all COI declarations for a round (admin only)
|
||||||
|
listCOIByRound: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Returns all COI declarations with user and project info
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Review a COI declaration (admin only)
|
||||||
|
reviewCOI: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
reviewNotes: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Marks COI as reviewed, logs audit event
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI Evaluation Summary Endpoints (Evaluation Router)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Added to src/server/routers/evaluation.ts
|
||||||
|
|
||||||
|
// Generate AI summary for a project's evaluations
|
||||||
|
generateSummary: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
roundId: z.string(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Anonymizes evaluation data, sends to GPT
|
||||||
|
// Generates strengths/weaknesses, themes, scoring patterns
|
||||||
|
// Stores EvaluationSummary record
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get existing summary for a project
|
||||||
|
getSummary: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
roundId: z.string(),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Returns EvaluationSummary with parsed summaryJson
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Generate summaries for all projects in a round
|
||||||
|
generateBulkSummaries: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Iterates through projects, generates summaries in batch
|
||||||
|
// Returns count of generated summaries
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Evaluation Reminders Endpoint
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Added to src/server/routers/evaluation.ts
|
||||||
|
|
||||||
|
// Trigger reminder emails for incomplete assignments (admin)
|
||||||
|
triggerReminders: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Calls EvaluationRemindersService
|
||||||
|
// Finds incomplete assignments, sends email reminders
|
||||||
|
// Logs to ReminderLog table
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron API Route
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/api/cron/reminders/route.ts
|
||||||
|
|
||||||
|
// POST /api/cron/reminders
|
||||||
|
// Protected by CRON_SECRET header validation
|
||||||
|
// Automatically finds rounds with approaching deadlines
|
||||||
|
// Sends reminder emails to jurors with incomplete evaluations
|
||||||
|
// Designed to be called by external cron scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export Router - Filtering Results
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Added to src/server/routers/export.ts
|
||||||
|
|
||||||
|
// Export filtering results as CSV
|
||||||
|
filteringResults: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Queries projects with evaluations and AI screening data
|
||||||
|
// Dynamically flattens aiScreeningJson columns
|
||||||
|
// Returns CSV-ready data
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Evaluation Form Schema - Extended Criterion Types
|
||||||
|
|
||||||
|
The evaluation form `criteriaJson` now supports extended criterion types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||||
|
|
||||||
|
type Criterion = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: CriterionType // Defaults to 'numeric' for backward compatibility
|
||||||
|
scale?: string // Only for 'numeric' type
|
||||||
|
weight?: number // Only for 'numeric' type
|
||||||
|
required?: boolean
|
||||||
|
description?: string
|
||||||
|
// Conditional visibility
|
||||||
|
visibleWhen?: {
|
||||||
|
criterionId: string
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
// Section grouping
|
||||||
|
section?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field components per type:
|
||||||
|
- **numeric**: Standard slider/number input with scale
|
||||||
|
- **text**: Free-text textarea field (`TextCriterionField`)
|
||||||
|
- **boolean**: Yes/No toggle (`BooleanCriterionField`)
|
||||||
|
- **section_header**: Non-input visual divider for form organization (`SectionHeader`)
|
||||||
|
|
||||||
## Authentication Flow
|
## Authentication Flow
|
||||||
|
|
||||||
### Magic Link Implementation
|
### Magic Link Implementation
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,72 @@ The MOPC platform uses PostgreSQL as its primary database, accessed via Prisma O
|
||||||
│ grantedBy │
|
│ grantedBy │
|
||||||
│ createdAt │
|
│ createdAt │
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ ReminderLog │
|
||||||
|
│─────────────────────│
|
||||||
|
│ id │
|
||||||
|
│ roundId │
|
||||||
|
│ userId │
|
||||||
|
│ assignmentId │
|
||||||
|
│ type │
|
||||||
|
│ sentAt │
|
||||||
|
│ emailTo │
|
||||||
|
│ status │
|
||||||
|
└─────────────────────┘
|
||||||
|
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ ConflictOfInterest │
|
||||||
|
│───────────────────────│
|
||||||
|
│ id │
|
||||||
|
│ assignmentId │
|
||||||
|
│ userId │
|
||||||
|
│ projectId │
|
||||||
|
│ roundId │
|
||||||
|
│ hasConflict │
|
||||||
|
│ conflictType │
|
||||||
|
│ description │
|
||||||
|
│ reviewedBy │
|
||||||
|
│ reviewedAt │
|
||||||
|
│ reviewNotes │
|
||||||
|
│ createdAt │
|
||||||
|
└───────────────────────┘
|
||||||
|
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ EvaluationSummary │
|
||||||
|
│───────────────────────│
|
||||||
|
│ id │
|
||||||
|
│ projectId │
|
||||||
|
│ roundId │
|
||||||
|
│ summaryJson │
|
||||||
|
│ model │
|
||||||
|
│ tokensUsed │
|
||||||
|
│ createdAt │
|
||||||
|
└───────────────────────┘
|
||||||
|
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ ProjectStatusHistory │
|
||||||
|
│───────────────────────│
|
||||||
|
│ id │
|
||||||
|
│ projectId │
|
||||||
|
│ oldStatus │
|
||||||
|
│ newStatus │
|
||||||
|
│ changedBy │
|
||||||
|
│ reason │
|
||||||
|
│ createdAt │
|
||||||
|
└───────────────────────┘
|
||||||
|
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ MentorMessage │
|
||||||
|
│───────────────────────│
|
||||||
|
│ id │
|
||||||
|
│ projectId │
|
||||||
|
│ senderId │
|
||||||
|
│ recipientId │
|
||||||
|
│ content │
|
||||||
|
│ readAt │
|
||||||
|
│ createdAt │
|
||||||
|
└───────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prisma Schema
|
## Prisma Schema
|
||||||
|
|
@ -137,6 +203,8 @@ enum UserRole {
|
||||||
PROGRAM_ADMIN
|
PROGRAM_ADMIN
|
||||||
JURY_MEMBER
|
JURY_MEMBER
|
||||||
OBSERVER
|
OBSERVER
|
||||||
|
MENTOR
|
||||||
|
APPLICANT
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserStatus {
|
enum UserStatus {
|
||||||
|
|
@ -357,12 +425,14 @@ model Project {
|
||||||
model ProjectFile {
|
model ProjectFile {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String
|
projectId String
|
||||||
|
roundId String? // Per-round document management (Phase B)
|
||||||
|
|
||||||
// File info
|
// File info
|
||||||
fileType FileType
|
fileType FileType
|
||||||
fileName String
|
fileName String
|
||||||
mimeType String
|
mimeType String
|
||||||
size Int // bytes
|
size Int // bytes
|
||||||
|
isLate Boolean @default(false) // Upload deadline policy tracking
|
||||||
|
|
||||||
// MinIO location
|
// MinIO location
|
||||||
bucket String
|
bucket String
|
||||||
|
|
@ -376,6 +446,7 @@ model ProjectFile {
|
||||||
// Indexes
|
// Indexes
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([fileType])
|
@@index([fileType])
|
||||||
|
@@index([roundId])
|
||||||
@@unique([bucket, objectKey])
|
@@unique([bucket, objectKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -475,6 +546,119 @@ model AuditLog {
|
||||||
@@index([entityType, entityId])
|
@@index([entityType, entityId])
|
||||||
@@index([timestamp])
|
@@index([timestamp])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REMINDER LOGGING
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model ReminderLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
roundId String
|
||||||
|
userId String
|
||||||
|
assignmentId String
|
||||||
|
type String // e.g., "DEADLINE_APPROACHING", "OVERDUE"
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
emailTo String
|
||||||
|
status String // "SENT", "FAILED"
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
@@index([roundId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([assignmentId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONFLICT OF INTEREST
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model ConflictOfInterest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
assignmentId String
|
||||||
|
userId String
|
||||||
|
projectId String
|
||||||
|
roundId String
|
||||||
|
hasConflict Boolean
|
||||||
|
conflictType String? // e.g., "FINANCIAL", "PERSONAL", "ORGANIZATIONAL"
|
||||||
|
description String? @db.Text
|
||||||
|
|
||||||
|
// Review fields (admin)
|
||||||
|
reviewedBy String?
|
||||||
|
reviewedAt DateTime?
|
||||||
|
reviewNotes String? @db.Text
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
@@unique([assignmentId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([roundId])
|
||||||
|
@@index([hasConflict])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// AI EVALUATION SUMMARY
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model EvaluationSummary {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
roundId String
|
||||||
|
summaryJson Json @db.JsonB // Strengths, weaknesses, themes, scoring stats
|
||||||
|
model String // e.g., "gpt-4o"
|
||||||
|
tokensUsed Int
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
@@unique([projectId, roundId])
|
||||||
|
@@index([roundId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PROJECT STATUS HISTORY
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model ProjectStatusHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
oldStatus String
|
||||||
|
newStatus String
|
||||||
|
changedBy String? // User who changed the status
|
||||||
|
reason String? @db.Text
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MENTOR MESSAGING
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model MentorMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
senderId String
|
||||||
|
recipientId String
|
||||||
|
content String @db.Text
|
||||||
|
readAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([senderId])
|
||||||
|
@@index([recipientId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Indexing Strategy
|
## Indexing Strategy
|
||||||
|
|
@ -505,6 +689,22 @@ model AuditLog {
|
||||||
| Evaluation | `submittedAt` | Sort by submission time |
|
| Evaluation | `submittedAt` | Sort by submission time |
|
||||||
| AuditLog | `timestamp` | Time-based queries |
|
| AuditLog | `timestamp` | Time-based queries |
|
||||||
| AuditLog | `entityType, entityId` | Entity history |
|
| AuditLog | `entityType, entityId` | Entity history |
|
||||||
|
| ProjectFile | `roundId` | Per-round document listing |
|
||||||
|
| ReminderLog | `roundId` | Reminders per round |
|
||||||
|
| ReminderLog | `userId` | User reminder history |
|
||||||
|
| ReminderLog | `assignmentId` | Assignment reminder tracking |
|
||||||
|
| ConflictOfInterest | `assignmentId` (unique) | COI per assignment |
|
||||||
|
| ConflictOfInterest | `userId` | User COI declarations |
|
||||||
|
| ConflictOfInterest | `roundId` | COI per round |
|
||||||
|
| ConflictOfInterest | `hasConflict` | Filter active conflicts |
|
||||||
|
| EvaluationSummary | `projectId, roundId` (unique) | One summary per project per round |
|
||||||
|
| EvaluationSummary | `roundId` | Summaries per round |
|
||||||
|
| ProjectStatusHistory | `projectId` | Status change timeline |
|
||||||
|
| ProjectStatusHistory | `createdAt` | Chronological ordering |
|
||||||
|
| MentorMessage | `projectId` | Messages per project |
|
||||||
|
| MentorMessage | `senderId` | Sent messages |
|
||||||
|
| MentorMessage | `recipientId` | Received messages |
|
||||||
|
| MentorMessage | `createdAt` | Chronological ordering |
|
||||||
|
|
||||||
### JSON Field Indexes
|
### JSON Field Indexes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,19 @@ model User {
|
||||||
notifications InAppNotification[] @relation("UserNotifications")
|
notifications InAppNotification[] @relation("UserNotifications")
|
||||||
notificationSettingsUpdated NotificationEmailSetting[] @relation("NotificationSettingUpdater")
|
notificationSettingsUpdated NotificationEmailSetting[] @relation("NotificationSettingUpdater")
|
||||||
|
|
||||||
|
// Reminder logs
|
||||||
|
reminderLogs ReminderLog[]
|
||||||
|
|
||||||
|
// Conflict of interest
|
||||||
|
conflictsOfInterest ConflictOfInterest[]
|
||||||
|
coiReviews ConflictOfInterest[] @relation("COIReviewedBy")
|
||||||
|
|
||||||
|
// Evaluation summaries
|
||||||
|
generatedSummaries EvaluationSummary[] @relation("EvaluationSummaryGeneratedBy")
|
||||||
|
|
||||||
|
// Mentor messages
|
||||||
|
mentorMessages MentorMessage[] @relation("MentorMessageSender")
|
||||||
|
|
||||||
// NextAuth relations
|
// NextAuth relations
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|
@ -377,6 +390,8 @@ model Round {
|
||||||
filteringJobs FilteringJob[]
|
filteringJobs FilteringJob[]
|
||||||
assignmentJobs AssignmentJob[]
|
assignmentJobs AssignmentJob[]
|
||||||
taggingJobs TaggingJob[]
|
taggingJobs TaggingJob[]
|
||||||
|
reminderLogs ReminderLog[]
|
||||||
|
projectFiles ProjectFile[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -480,6 +495,9 @@ model Project {
|
||||||
awardVotes AwardVote[]
|
awardVotes AwardVote[]
|
||||||
wonAwards SpecialAward[] @relation("AwardWinner")
|
wonAwards SpecialAward[] @relation("AwardWinner")
|
||||||
projectTags ProjectTag[]
|
projectTags ProjectTag[]
|
||||||
|
statusHistory ProjectStatusHistory[]
|
||||||
|
mentorMessages MentorMessage[]
|
||||||
|
evaluationSummaries EvaluationSummary[]
|
||||||
|
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -494,6 +512,7 @@ model Project {
|
||||||
model ProjectFile {
|
model ProjectFile {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String
|
projectId String
|
||||||
|
roundId String? // Which round this file was submitted for
|
||||||
|
|
||||||
// File info
|
// File info
|
||||||
fileType FileType
|
fileType FileType
|
||||||
|
|
@ -505,13 +524,17 @@ model ProjectFile {
|
||||||
bucket String
|
bucket String
|
||||||
objectKey String
|
objectKey String
|
||||||
|
|
||||||
|
isLate Boolean @default(false) // Uploaded after round deadline
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
round Round? @relation(fields: [roundId], references: [id])
|
||||||
|
|
||||||
@@unique([bucket, objectKey])
|
@@unique([bucket, objectKey])
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
|
@@index([roundId])
|
||||||
@@index([fileType])
|
@@index([fileType])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -543,6 +566,7 @@ model Assignment {
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
evaluation Evaluation?
|
evaluation Evaluation?
|
||||||
|
conflictOfInterest ConflictOfInterest?
|
||||||
|
|
||||||
@@unique([userId, projectId, roundId])
|
@@unique([userId, projectId, roundId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
|
@ -1297,3 +1321,109 @@ model AwardVote {
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REMINDER LOG (Evaluation Deadline Reminders)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model ReminderLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
roundId String
|
||||||
|
userId String
|
||||||
|
type String // "3_DAYS", "24H", "1H"
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([roundId, userId, type])
|
||||||
|
@@index([roundId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONFLICT OF INTEREST
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model ConflictOfInterest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
assignmentId String @unique
|
||||||
|
userId String
|
||||||
|
projectId String
|
||||||
|
roundId String
|
||||||
|
hasConflict Boolean @default(false)
|
||||||
|
conflictType String? // "financial", "personal", "organizational", "other"
|
||||||
|
description String? @db.Text
|
||||||
|
declaredAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Admin review
|
||||||
|
reviewedById String?
|
||||||
|
reviewedAt DateTime?
|
||||||
|
reviewAction String? // "cleared", "reassigned", "noted"
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
reviewedBy User? @relation("COIReviewedBy", fields: [reviewedById], references: [id])
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([roundId, hasConflict])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// AI EVALUATION SUMMARY
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model EvaluationSummary {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
roundId String
|
||||||
|
summaryJson Json @db.JsonB
|
||||||
|
generatedAt DateTime @default(now())
|
||||||
|
generatedById String
|
||||||
|
model String
|
||||||
|
tokensUsed Int
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
generatedBy User @relation("EvaluationSummaryGeneratedBy", fields: [generatedById], references: [id])
|
||||||
|
|
||||||
|
@@unique([projectId, roundId])
|
||||||
|
@@index([roundId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PROJECT STATUS HISTORY
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model ProjectStatusHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
status ProjectStatus
|
||||||
|
changedAt DateTime @default(now())
|
||||||
|
changedBy String?
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([projectId, changedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MENTOR MESSAGES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
model MentorMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
senderId String
|
||||||
|
message String @db.Text
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
sender User @relation("MentorMessageSender", fields: [senderId], references: [id])
|
||||||
|
|
||||||
|
@@index([projectId, createdAt])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { FileViewer } from '@/components/shared/file-viewer'
|
||||||
import { FileUpload } from '@/components/shared/file-upload'
|
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 {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Edit,
|
Edit,
|
||||||
|
|
@ -635,6 +636,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Evaluation Summary */}
|
||||||
|
{project.roundId && stats && stats.totalEvaluations > 0 && (
|
||||||
|
<EvaluationSummaryCard
|
||||||
|
projectId={projectId}
|
||||||
|
roundId={project.roundId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Layers,
|
Layers,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
X,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -75,6 +77,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { truncate } from '@/lib/utils'
|
import { truncate } from '@/lib/utils'
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
|
|
@ -346,6 +349,77 @@ export default function ProjectsPage() {
|
||||||
? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100)
|
? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100)
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
|
// Bulk selection state
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [bulkStatus, setBulkStatus] = useState<string>('')
|
||||||
|
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
||||||
|
|
||||||
|
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setBulkStatus('')
|
||||||
|
setBulkConfirmOpen(false)
|
||||||
|
utils.project.list.invalidate()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || 'Failed to update projects')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleToggleSelect = (id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (!data) return
|
||||||
|
const allVisible = data.projects.map((p) => p.id)
|
||||||
|
const allSelected = allVisible.every((id) => selectedIds.has(id))
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
allVisible.forEach((id) => next.delete(id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
allVisible.forEach((id) => next.add(id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkApply = () => {
|
||||||
|
if (!bulkStatus || selectedIds.size === 0) return
|
||||||
|
setBulkConfirmOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkConfirm = () => {
|
||||||
|
if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return
|
||||||
|
bulkUpdateStatus.mutate({
|
||||||
|
ids: Array.from(selectedIds),
|
||||||
|
roundId: filters.roundId,
|
||||||
|
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if all visible projects are selected
|
||||||
|
const allVisibleSelected = data
|
||||||
|
? data.projects.length > 0 && data.projects.every((p) => selectedIds.has(p.id))
|
||||||
|
: false
|
||||||
|
const someVisibleSelected = data
|
||||||
|
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
|
||||||
|
: false
|
||||||
|
|
||||||
const deleteProject = trpc.project.delete.useMutation({
|
const deleteProject = trpc.project.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Project deleted successfully')
|
toast.success('Project deleted successfully')
|
||||||
|
|
@ -468,6 +542,15 @@ export default function ProjectsPage() {
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
{filters.roundId && (
|
||||||
|
<TableHead className="w-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
aria-label="Select all projects"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
<TableHead>Project</TableHead>
|
<TableHead>Project</TableHead>
|
||||||
<TableHead>Round</TableHead>
|
<TableHead>Round</TableHead>
|
||||||
<TableHead>Files</TableHead>
|
<TableHead>Files</TableHead>
|
||||||
|
|
@ -484,6 +567,16 @@ export default function ProjectsPage() {
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
|
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
|
||||||
>
|
>
|
||||||
|
{filters.roundId && (
|
||||||
|
<TableCell className="relative z-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(project.id)}
|
||||||
|
onCheckedChange={() => handleToggleSelect(project.id)}
|
||||||
|
aria-label={`Select ${project.title}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/projects/${project.id}`}
|
href={`/admin/projects/${project.id}`}
|
||||||
|
|
@ -577,14 +670,23 @@ export default function ProjectsPage() {
|
||||||
{/* Mobile card view */}
|
{/* Mobile card view */}
|
||||||
<div className="space-y-4 md:hidden">
|
<div className="space-y-4 md:hidden">
|
||||||
{data.projects.map((project) => (
|
{data.projects.map((project) => (
|
||||||
|
<div key={project.id} className="relative">
|
||||||
|
{filters.roundId && (
|
||||||
|
<div className="absolute left-3 top-4 z-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(project.id)}
|
||||||
|
onCheckedChange={() => handleToggleSelect(project.id)}
|
||||||
|
aria-label={`Select ${project.title}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
key={project.id}
|
|
||||||
href={`/admin/projects/${project.id}`}
|
href={`/admin/projects/${project.id}`}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
<Card className="transition-colors hover:bg-muted/50">
|
<Card className="transition-colors hover:bg-muted/50">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
|
||||||
<ProjectLogo
|
<ProjectLogo
|
||||||
project={project}
|
project={project}
|
||||||
size="md"
|
size="md"
|
||||||
|
|
@ -627,6 +729,7 @@ export default function ProjectsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -641,6 +744,93 @@ export default function ProjectsPage() {
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Bulk Action Floating Toolbar */}
|
||||||
|
{selectedIds.size > 0 && filters.roundId && (
|
||||||
|
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
|
||||||
|
<Card className="border-2 shadow-lg">
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-sm">
|
||||||
|
{selectedIds.size} selected
|
||||||
|
</Badge>
|
||||||
|
<Select value={bulkStatus} onValueChange={setBulkStatus}>
|
||||||
|
<SelectTrigger className="w-full sm:w-[180px]">
|
||||||
|
<SelectValue placeholder="Set status..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s.replace('_', ' ')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBulkApply}
|
||||||
|
disabled={!bulkStatus || bulkUpdateStatus.isPending}
|
||||||
|
>
|
||||||
|
{bulkUpdateStatus.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setBulkStatus('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk Status Update Confirmation Dialog */}
|
||||||
|
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
You are about to change the status of{' '}
|
||||||
|
<strong>{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}</strong>{' '}
|
||||||
|
to <Badge variant={statusColors[bulkStatus] || 'secondary'}>{bulkStatus.replace('_', ' ')}</Badge>.
|
||||||
|
</p>
|
||||||
|
{bulkStatus === 'REJECTED' && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Warning: Rejected projects will be marked as eliminated. This will send notifications to the project teams.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={bulkUpdateStatus.isPending}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleBulkConfirm}
|
||||||
|
className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||||
|
disabled={bulkUpdateStatus.isPending}
|
||||||
|
>
|
||||||
|
{bulkUpdateStatus.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Suspense, use, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ShieldAlert,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
MoreHorizontal,
|
||||||
|
ShieldCheck,
|
||||||
|
UserX,
|
||||||
|
StickyNote,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function COIManagementContent({ roundId }: { roundId: string }) {
|
||||||
|
const [conflictsOnly, setConflictsOnly] = useState(false)
|
||||||
|
|
||||||
|
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
|
||||||
|
const { data: coiList, isLoading: loadingCOI } = trpc.evaluation.listCOIByRound.useQuery({
|
||||||
|
roundId,
|
||||||
|
hasConflictOnly: conflictsOnly || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const reviewCOI = trpc.evaluation.reviewCOI.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.evaluation.listCOIByRound.invalidate({ roundId })
|
||||||
|
toast.success(`COI marked as "${data.reviewAction}"`)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || 'Failed to review COI')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (loadingRound || loadingCOI) {
|
||||||
|
return <COISkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||||
|
<p className="mt-2 font-medium">Round Not Found</p>
|
||||||
|
<Button asChild className="mt-4">
|
||||||
|
<Link href="/admin/rounds">Back to Rounds</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflictCount = coiList?.filter((c) => c.hasConflict).length ?? 0
|
||||||
|
const totalCount = coiList?.length ?? 0
|
||||||
|
const reviewedCount = coiList?.filter((c) => c.reviewAction).length ?? 0
|
||||||
|
|
||||||
|
const getReviewBadge = (reviewAction: string | null) => {
|
||||||
|
switch (reviewAction) {
|
||||||
|
case 'cleared':
|
||||||
|
return (
|
||||||
|
<Badge variant="default" className="bg-green-600">
|
||||||
|
<ShieldCheck className="mr-1 h-3 w-3" />
|
||||||
|
Cleared
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'reassigned':
|
||||||
|
return (
|
||||||
|
<Badge variant="default" className="bg-blue-600">
|
||||||
|
<UserX className="mr-1 h-3 w-3" />
|
||||||
|
Reassigned
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'noted':
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<StickyNote className="mr-1 h-3 w-3" />
|
||||||
|
Noted
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-amber-600 border-amber-300">
|
||||||
|
Pending Review
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConflictTypeBadge = (type: string | null) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'financial':
|
||||||
|
return <Badge variant="destructive">Financial</Badge>
|
||||||
|
case 'personal':
|
||||||
|
return <Badge variant="secondary">Personal</Badge>
|
||||||
|
case 'organizational':
|
||||||
|
return <Badge variant="outline">Organizational</Badge>
|
||||||
|
case 'other':
|
||||||
|
return <Badge variant="outline">Other</Badge>
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href={`/admin/rounds/${roundId}`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Round
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
|
||||||
|
{round.program.name}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
|
||||||
|
{round.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-6 w-6" />
|
||||||
|
Conflict of Interest Declarations
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Declarations</CardTitle>
|
||||||
|
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Conflicts Declared</CardTitle>
|
||||||
|
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-amber-600">{conflictCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Reviewed</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{reviewedCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COI Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">Declarations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Review and manage conflict of interest declarations from jury members
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="conflicts-only"
|
||||||
|
checked={conflictsOnly}
|
||||||
|
onCheckedChange={setConflictsOnly}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="conflicts-only" className="text-sm">
|
||||||
|
Conflicts only
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{coiList && coiList.length > 0 ? (
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Juror</TableHead>
|
||||||
|
<TableHead>Conflict</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="w-12">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{coiList.map((coi) => (
|
||||||
|
<TableRow key={coi.id}>
|
||||||
|
<TableCell className="font-medium max-w-[200px] truncate">
|
||||||
|
{coi.assignment.project.title}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{coi.user.name || coi.user.email}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{coi.hasConflict ? (
|
||||||
|
<Badge variant="destructive">Yes</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-green-600 border-green-300">
|
||||||
|
No
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{coi.hasConflict ? getConflictTypeBadge(coi.conflictType) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px]">
|
||||||
|
{coi.description ? (
|
||||||
|
<span className="text-sm text-muted-foreground truncate block">
|
||||||
|
{coi.description}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{coi.hasConflict ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{getReviewBadge(coi.reviewAction)}
|
||||||
|
{coi.reviewedBy && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
by {coi.reviewedBy.name || coi.reviewedBy.email}
|
||||||
|
{coi.reviewedAt && (
|
||||||
|
<> {formatDistanceToNow(new Date(coi.reviewedAt), { addSuffix: true })}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">N/A</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{coi.hasConflict && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={reviewCOI.isPending}
|
||||||
|
>
|
||||||
|
{reviewCOI.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
reviewCOI.mutate({
|
||||||
|
id: coi.id,
|
||||||
|
reviewAction: 'cleared',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||||
|
Clear
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
reviewCOI.mutate({
|
||||||
|
id: coi.id,
|
||||||
|
reviewAction: 'reassigned',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UserX className="mr-2 h-4 w-4" />
|
||||||
|
Reassign
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
reviewCOI.mutate({
|
||||||
|
id: coi.id,
|
||||||
|
reviewAction: 'noted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StickyNote className="mr-2 h-4 w-4" />
|
||||||
|
Note
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<ShieldAlert className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No Declarations Yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{conflictsOnly
|
||||||
|
? 'No conflicts of interest have been declared for this round'
|
||||||
|
: 'No jury members have submitted COI declarations for this round yet'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function COISkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-36" />
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-8 w-80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function COIManagementPage({ params }: PageProps) {
|
||||||
|
const { id } = use(params)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<COISkeleton />}>
|
||||||
|
<COIManagementContent roundId={id} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -418,7 +418,45 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Team Notification - removed from schema, feature not implemented */}
|
{/* Upload Deadline Policy */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Upload Deadline Policy</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Control how file uploads are handled after the round starts
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Select
|
||||||
|
value={(roundSettings.uploadDeadlinePolicy as string) || ''}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setRoundSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
uploadDeadlinePolicy: value || undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Default (no restriction)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="NONE">
|
||||||
|
Default - No restriction
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="BLOCK">
|
||||||
|
Block uploads after round starts
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ALLOW_LATE">
|
||||||
|
Allow late uploads (marked as late)
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
When set to “Block”, applicants cannot upload files after the voting start date.
|
||||||
|
When set to “Allow late”, uploads are accepted but flagged as late submissions.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Evaluation Criteria */}
|
{/* Evaluation Criteria */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import {
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Loader2,
|
Loader2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Download,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
|
@ -109,6 +110,41 @@ export default function FilteringResultsPage({
|
||||||
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
||||||
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
|
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
|
||||||
|
|
||||||
|
const exportResults = trpc.export.filteringResults.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
const result = await exportResults.refetch()
|
||||||
|
if (result.data) {
|
||||||
|
const { data: rows, columns } = result.data
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
columns.join(','),
|
||||||
|
...rows.map((row) =>
|
||||||
|
columns
|
||||||
|
.map((col) => {
|
||||||
|
const value = row[col as keyof typeof row]
|
||||||
|
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
return value ?? ''
|
||||||
|
})
|
||||||
|
.join(',')
|
||||||
|
),
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `filtering-results-${new Date().toISOString().split('T')[0]}.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleRow = (id: string) => {
|
const toggleRow = (id: string) => {
|
||||||
const next = new Set(expandedRows)
|
const next = new Set(expandedRows)
|
||||||
if (next.has(id)) next.delete(id)
|
if (next.has(id)) next.delete(id)
|
||||||
|
|
@ -166,6 +202,7 @@ export default function FilteringResultsPage({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Filtering Results
|
Filtering Results
|
||||||
|
|
@ -174,6 +211,19 @@ export default function FilteringResultsPage({
|
||||||
Review and override filtering outcomes
|
Review and override filtering outcomes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exportResults.isFetching}
|
||||||
|
>
|
||||||
|
{exportResults.isFetching ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Outcome Filter Tabs */}
|
{/* Outcome Filter Tabs */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||||
|
|
@ -125,6 +126,22 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
const startJob = trpc.filtering.startJob.useMutation()
|
const startJob = trpc.filtering.startJob.useMutation()
|
||||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||||
|
|
||||||
|
// AI summary bulk generation
|
||||||
|
const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.errors.length > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`Generated ${data.generated} of ${data.total} summaries. ${data.errors.length} failed.`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.success(`Generated ${data.generated} AI summaries successfully`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || 'Failed to generate AI summaries')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Set active job from latest job on load
|
// Set active job from latest job on load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
||||||
|
|
@ -764,6 +781,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
Jury Assignments
|
Jury Assignments
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
|
||||||
|
disabled={bulkSummaries.isPending}
|
||||||
|
>
|
||||||
|
{bulkSummaries.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||||
|
|
||||||
async function JuryDashboardContent() {
|
async function JuryDashboardContent() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
@ -105,6 +106,27 @@ async function JuryDashboardContent() {
|
||||||
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
|
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Get grace periods for this user
|
||||||
|
const gracePeriods = await prisma.gracePeriod.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
extendedUntil: { gte: new Date() },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
roundId: true,
|
||||||
|
extendedUntil: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build a map of roundId -> latest extendedUntil
|
||||||
|
const graceByRound = new Map<string, Date>()
|
||||||
|
for (const gp of gracePeriods) {
|
||||||
|
const existing = graceByRound.get(gp.roundId)
|
||||||
|
if (!existing || gp.extendedUntil > existing) {
|
||||||
|
graceByRound.set(gp.roundId, gp.extendedUntil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get active rounds (voting window is open)
|
// Get active rounds (voting window is open)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const activeRounds = Object.values(assignmentsByRound).filter(
|
const activeRounds = Object.values(assignmentsByRound).filter(
|
||||||
|
|
@ -221,9 +243,15 @@ async function JuryDashboardContent() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{round.votingEndAt && (
|
{round.votingEndAt && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
Deadline: {formatDateOnly(round.votingEndAt)}
|
<CountdownTimer
|
||||||
</p>
|
deadline={graceByRound.get(round.id) ?? new Date(round.votingEndAt)}
|
||||||
|
label="Deadline:"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({formatDateOnly(round.votingEndAt)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button asChild size="sm" className="w-full sm:w-auto">
|
<Button asChild size="sm" className="w-full sm:w-auto">
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { EvaluationForm } from '@/components/forms/evaluation-form'
|
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
||||||
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
|
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
|
||||||
import { isFuture, isPast } from 'date-fns'
|
import { isFuture, isPast } from 'date-fns'
|
||||||
|
|
||||||
|
|
@ -21,9 +21,20 @@ interface Criterion {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
scale: number
|
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||||
|
scale?: number
|
||||||
weight?: number
|
weight?: number
|
||||||
required?: boolean
|
required?: boolean
|
||||||
|
maxLength?: number
|
||||||
|
placeholder?: string
|
||||||
|
trueLabel?: string
|
||||||
|
falseLabel?: string
|
||||||
|
condition?: {
|
||||||
|
criterionId: string
|
||||||
|
operator: 'equals' | 'greaterThan' | 'lessThan'
|
||||||
|
value: number | string | boolean
|
||||||
|
}
|
||||||
|
sectionId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function EvaluateContent({ projectId }: { projectId: string }) {
|
async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||||
|
|
@ -133,6 +144,14 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||||
redirect(`/jury/projects/${projectId}/evaluation`)
|
redirect(`/jury/projects/${projectId}/evaluation`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check COI status
|
||||||
|
const coiRecord = await prisma.conflictOfInterest.findUnique({
|
||||||
|
where: { assignmentId: assignment.id },
|
||||||
|
})
|
||||||
|
const coiStatus = coiRecord
|
||||||
|
? { hasConflict: coiRecord.hasConflict, declared: true }
|
||||||
|
: { hasConflict: false, declared: false }
|
||||||
|
|
||||||
// Get evaluation form criteria
|
// Get evaluation form criteria
|
||||||
const evaluationForm = round.evaluationForms[0]
|
const evaluationForm = round.evaluationForms[0]
|
||||||
if (!evaluationForm) {
|
if (!evaluationForm) {
|
||||||
|
|
@ -247,8 +266,8 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Evaluation Form */}
|
{/* Evaluation Form with COI Gate */}
|
||||||
<EvaluationForm
|
<EvaluationFormWithCOI
|
||||||
assignmentId={assignment.id}
|
assignmentId={assignment.id}
|
||||||
evaluationId={evaluation?.id || null}
|
evaluationId={evaluation?.id || null}
|
||||||
projectTitle={project.title}
|
projectTitle={project.title}
|
||||||
|
|
@ -258,7 +277,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||||
? {
|
? {
|
||||||
criterionScoresJson: evaluation.criterionScoresJson as Record<
|
criterionScoresJson: evaluation.criterionScoresJson as Record<
|
||||||
string,
|
string,
|
||||||
number
|
number | string | boolean
|
||||||
> | null,
|
> | null,
|
||||||
globalScore: evaluation.globalScore,
|
globalScore: evaluation.globalScore,
|
||||||
binaryDecision: evaluation.binaryDecision,
|
binaryDecision: evaluation.binaryDecision,
|
||||||
|
|
@ -269,6 +288,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||||
}
|
}
|
||||||
isVotingOpen={effectiveVotingOpen}
|
isVotingOpen={effectiveVotingOpen}
|
||||||
deadline={round.votingEndAt}
|
deadline={round.votingEndAt}
|
||||||
|
coiStatus={coiStatus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { FileViewer } from '@/components/shared/file-viewer'
|
import { FileViewer } from '@/components/shared/file-viewer'
|
||||||
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -30,6 +31,7 @@ import {
|
||||||
Calendar,
|
Calendar,
|
||||||
FileText,
|
FileText,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly, getInitials } from '@/lib/utils'
|
import { formatDateOnly, getInitials } from '@/lib/utils'
|
||||||
|
|
||||||
|
|
@ -52,6 +54,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
projectId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: mentorMessages, isLoading: messagesLoading } = trpc.mentor.getMessages.useQuery({
|
||||||
|
projectId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const sendMessage = trpc.mentor.sendMessage.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.mentor.getMessages.invalidate({ projectId })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ProjectDetailSkeleton />
|
return <ProjectDetailSkeleton />
|
||||||
}
|
}
|
||||||
|
|
@ -363,6 +376,30 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Messaging Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Messages
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Communicate with the project team
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MentorChat
|
||||||
|
messages={mentorMessages || []}
|
||||||
|
currentUserId={project.mentorAssignment?.mentor?.id || ''}
|
||||||
|
onSendMessage={async (message) => {
|
||||||
|
await sendMessage.mutateAsync({ projectId, message })
|
||||||
|
}}
|
||||||
|
isLoading={messagesLoading}
|
||||||
|
isSending={sendMessage.isPending}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Suspense } from 'react'
|
'use client'
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -20,80 +20,72 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { FileSpreadsheet, BarChart3, Users, ClipboardList } from 'lucide-react'
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
FileSpreadsheet,
|
||||||
|
BarChart3,
|
||||||
|
Users,
|
||||||
|
ClipboardList,
|
||||||
|
CheckCircle2,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
ScoreDistributionChart,
|
||||||
|
EvaluationTimelineChart,
|
||||||
|
StatusBreakdownChart,
|
||||||
|
JurorWorkloadChart,
|
||||||
|
ProjectRankingsChart,
|
||||||
|
CriteriaScoresChart,
|
||||||
|
} from '@/components/charts'
|
||||||
|
|
||||||
async function ReportsContent() {
|
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||||
// Get rounds with evaluation stats
|
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||||
const rounds = await prisma.round.findMany({
|
|
||||||
include: {
|
|
||||||
program: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
projects: true,
|
|
||||||
assignments: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
assignments: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
evaluation: {
|
|
||||||
select: { id: true, status: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Calculate completion stats for each round
|
const rounds = programs?.flatMap(p =>
|
||||||
const roundStats = rounds.map((round) => {
|
p.rounds.map(r => ({
|
||||||
const totalAssignments = round._count.assignments
|
...r,
|
||||||
const completedEvaluations = round.assignments.filter(
|
programName: `${p.year} Edition`,
|
||||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
}))
|
||||||
).length
|
) || []
|
||||||
const completionRate =
|
|
||||||
totalAssignments > 0
|
|
||||||
? Math.round((completedEvaluations / totalAssignments) * 100)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
return {
|
const { data: overviewStats, isLoading: statsLoading } =
|
||||||
...round,
|
trpc.analytics.getOverviewStats.useQuery(
|
||||||
totalAssignments,
|
{ roundId: selectedRoundId! },
|
||||||
completedEvaluations,
|
{ enabled: !!selectedRoundId }
|
||||||
completionRate,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0)
|
|
||||||
const totalAssignments = roundStats.reduce(
|
|
||||||
(acc, r) => acc + r.totalAssignments,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
const totalEvaluations = roundStats.reduce(
|
|
||||||
(acc, r) => acc + r.completedEvaluations,
|
|
||||||
0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (rounds.length === 0) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-6">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<FileSpreadsheet className="h-12 w-12 text-muted-foreground/50" />
|
{[...Array(4)].map((_, i) => (
|
||||||
<p className="mt-2 font-medium">No data to report</p>
|
<Card key={i}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<CardHeader className="space-y-0 pb-2">
|
||||||
Reports will appear here once rounds are created
|
<Skeleton className="h-4 w-20" />
|
||||||
</p>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-24" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalProjects = rounds.reduce((acc, r) => acc + (r._count?.projects || 0), 0)
|
||||||
|
const activeRounds = rounds.filter((r) => r.status === 'ACTIVE').length
|
||||||
|
const totalPrograms = programs?.length || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
|
|
@ -106,7 +98,7 @@ async function ReportsContent() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{rounds.length}</div>
|
<div className="text-2xl font-bold">{rounds.length}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{rounds.filter((r) => r.status === 'ACTIVE').length} active
|
{activeRounds} active
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -122,14 +114,71 @@ async function ReportsContent() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{activeRounds}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Currently active</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Programs</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalPrograms}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Total programs</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Round-specific overview stats */}
|
||||||
|
{selectedRoundId && (
|
||||||
|
<>
|
||||||
|
{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">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>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{totalAssignments}</div>
|
<div className="text-2xl font-bold">{overviewStats.assignmentCount}</div>
|
||||||
<p className="text-xs text-muted-foreground">Total assignments</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{overviewStats.jurorCount} jurors
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -139,11 +188,26 @@ async function ReportsContent() {
|
||||||
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
|
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{totalEvaluations}</div>
|
<div className="text-2xl font-bold">{overviewStats.evaluationCount}</div>
|
||||||
<p className="text-xs text-muted-foreground">Completed</p>
|
<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" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rounds Table - Desktop */}
|
{/* Rounds Table - Desktop */}
|
||||||
<Card className="hidden md:block">
|
<Card className="hidden md:block">
|
||||||
|
|
@ -158,12 +222,11 @@ async function ReportsContent() {
|
||||||
<TableHead>Round</TableHead>
|
<TableHead>Round</TableHead>
|
||||||
<TableHead>Program</TableHead>
|
<TableHead>Program</TableHead>
|
||||||
<TableHead>Projects</TableHead>
|
<TableHead>Projects</TableHead>
|
||||||
<TableHead>Progress</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{roundStats.map((round) => (
|
{rounds.map((round) => (
|
||||||
<TableRow key={round.id}>
|
<TableRow key={round.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -175,21 +238,8 @@ async function ReportsContent() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{round.program.name}</TableCell>
|
<TableCell>{round.programName}</TableCell>
|
||||||
<TableCell>{round._count.projects}</TableCell>
|
<TableCell>{round._count?.projects || '-'}</TableCell>
|
||||||
<TableCell>
|
|
||||||
<div className="min-w-[120px] space-y-1">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>
|
|
||||||
{round.completedEvaluations}/{round.totalAssignments}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{round.completionRate}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={round.completionRate} className="h-2" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
|
|
@ -213,7 +263,7 @@ async function ReportsContent() {
|
||||||
{/* Rounds Cards - Mobile */}
|
{/* Rounds Cards - Mobile */}
|
||||||
<div className="space-y-4 md:hidden">
|
<div className="space-y-4 md:hidden">
|
||||||
<h2 className="text-lg font-semibold">Round Reports</h2>
|
<h2 className="text-lg font-semibold">Round Reports</h2>
|
||||||
{roundStats.map((round) => (
|
{rounds.map((round) => (
|
||||||
<Card key={round.id}>
|
<Card key={round.id}>
|
||||||
<CardContent className="pt-4 space-y-3">
|
<CardContent className="pt-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -230,24 +280,14 @@ async function ReportsContent() {
|
||||||
{round.status}
|
{round.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{round.program.name}</p>
|
<p className="text-sm text-muted-foreground">{round.programName}</p>
|
||||||
{round.votingEndAt && (
|
{round.votingEndAt && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Ends: {formatDateOnly(round.votingEndAt)}
|
Ends: {formatDateOnly(round.votingEndAt)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="text-sm">
|
||||||
<span>{round._count.projects} projects</span>
|
<span>{round._count?.projects || 0} projects</span>
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{round.completedEvaluations}/{round.totalAssignments} evaluations
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>Progress</span>
|
|
||||||
<span className="text-muted-foreground">{round.completionRate}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={round.completionRate} className="h-2" />
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -257,40 +297,136 @@ async function ReportsContent() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportsSkeleton() {
|
function AnalyticsTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||||
|
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||||
|
trpc.analytics.getScoreDistribution.useQuery(
|
||||||
|
{ roundId: selectedRoundId },
|
||||||
|
{ enabled: !!selectedRoundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: timeline, isLoading: timelineLoading } =
|
||||||
|
trpc.analytics.getEvaluationTimeline.useQuery(
|
||||||
|
{ roundId: selectedRoundId },
|
||||||
|
{ enabled: !!selectedRoundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||||
|
trpc.analytics.getStatusBreakdown.useQuery(
|
||||||
|
{ roundId: selectedRoundId },
|
||||||
|
{ enabled: !!selectedRoundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: jurorWorkload, isLoading: workloadLoading } =
|
||||||
|
trpc.analytics.getJurorWorkload.useQuery(
|
||||||
|
{ roundId: selectedRoundId },
|
||||||
|
{ enabled: !!selectedRoundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: projectRankings, isLoading: rankingsLoading } =
|
||||||
|
trpc.analytics.getProjectRankings.useQuery(
|
||||||
|
{ roundId: selectedRoundId, limit: 15 },
|
||||||
|
{ enabled: !!selectedRoundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||||
|
trpc.analytics.getCriteriaScores.useQuery(
|
||||||
|
{ roundId: selectedRoundId },
|
||||||
|
{ enabled: !!selectedRoundId }
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
{/* Row 1: Score Distribution & Status Breakdown */}
|
||||||
{[...Array(4)].map((_, i) => (
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card key={i}>
|
{scoreLoading ? (
|
||||||
<CardHeader className="space-y-0 pb-2">
|
<Skeleton className="h-[350px]" />
|
||||||
<Skeleton className="h-4 w-20" />
|
) : scoreDistribution ? (
|
||||||
</CardHeader>
|
<ScoreDistributionChart
|
||||||
<CardContent>
|
data={scoreDistribution.distribution}
|
||||||
<Skeleton className="h-8 w-16" />
|
averageScore={scoreDistribution.averageScore}
|
||||||
<Skeleton className="mt-2 h-3 w-24" />
|
totalScores={scoreDistribution.totalScores}
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
) : null}
|
||||||
))}
|
|
||||||
|
{statusLoading ? (
|
||||||
|
<Skeleton className="h-[350px]" />
|
||||||
|
) : statusBreakdown ? (
|
||||||
|
<StatusBreakdownChart data={statusBreakdown} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Evaluation Timeline */}
|
||||||
|
{timelineLoading ? (
|
||||||
|
<Skeleton className="h-[350px]" />
|
||||||
|
) : timeline?.length ? (
|
||||||
|
<EvaluationTimelineChart data={timeline} />
|
||||||
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
<Skeleton className="h-6 w-32" />
|
<p className="text-muted-foreground">
|
||||||
<Skeleton className="h-4 w-48" />
|
No evaluation data available yet
|
||||||
</CardHeader>
|
</p>
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-16 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ObserverReportsPage() {
|
export default function ObserverReportsPage() {
|
||||||
|
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||||
|
|
||||||
|
const rounds = programs?.flatMap(p =>
|
||||||
|
p.rounds.map(r => ({
|
||||||
|
...r,
|
||||||
|
programName: `${p.year} Edition`,
|
||||||
|
}))
|
||||||
|
) || []
|
||||||
|
|
||||||
|
// Set default selected round
|
||||||
|
if (rounds.length && !selectedRoundId) {
|
||||||
|
setSelectedRoundId(rounds[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -301,10 +437,62 @@ export default function ObserverReportsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Round Selector */}
|
||||||
<Suspense fallback={<ReportsSkeleton />}>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<ReportsContent />
|
<label className="text-sm font-medium">Select Round:</label>
|
||||||
</Suspense>
|
{roundsLoading ? (
|
||||||
|
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||||
|
) : rounds.length > 0 ? (
|
||||||
|
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||||
|
<SelectTrigger className="w-full sm:w-[300px]">
|
||||||
|
<SelectValue placeholder="Select a round" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rounds.map((round) => (
|
||||||
|
<SelectItem key={round.id} value={round.id}>
|
||||||
|
{round.programName} - {round.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No rounds available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs defaultValue="overview" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview" className="gap-2">
|
||||||
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
|
Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics" className="gap-2" disabled={!selectedRoundId}>
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
Analytics
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<OverviewTab selectedRoundId={selectedRoundId} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="analytics">
|
||||||
|
{selectedRoundId ? (
|
||||||
|
<AnalyticsTab selectedRoundId={selectedRoundId} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<BarChart3 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 from the dropdown above to view analytics
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
|
|
@ -16,17 +17,21 @@ import {
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||||
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FileText,
|
FileText,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
Download,
|
Download,
|
||||||
Video,
|
Video,
|
||||||
File,
|
File,
|
||||||
Users,
|
Users,
|
||||||
Crown,
|
Crown,
|
||||||
|
MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||||
|
|
@ -64,12 +69,25 @@ export function SubmissionDetailClient() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const projectId = params.id as string
|
const projectId = params.id as string
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
|
|
||||||
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
|
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
|
||||||
{ projectId },
|
{ projectId },
|
||||||
{ enabled: !!session?.user }
|
{ enabled: !!session?.user }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
|
||||||
|
{ projectId },
|
||||||
|
{ enabled: !!session?.user && activeTab === 'mentor' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.applicant.getMentorMessages.invalidate({ projectId })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
|
@ -148,6 +166,18 @@ export function SubmissionDetailClient() {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
|
<TabsTrigger value="documents">Documents</TabsTrigger>
|
||||||
|
<TabsTrigger value="mentor" className="gap-1.5">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
|
Mentor
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Details Tab */}
|
||||||
|
<TabsContent value="details">
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
|
@ -184,49 +214,6 @@ export function SubmissionDetailClient() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Files */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Uploaded Documents</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Documents submitted with your application
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{project.files.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-center py-4">
|
|
||||||
No documents uploaded
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{project.files.map((file) => {
|
|
||||||
const Icon = fileTypeIcons[file.fileType] || File
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg border"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{file.fileName}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{fileTypeLabels[file.fileType] || file.fileType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" disabled>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -330,6 +317,88 @@ export function SubmissionDetailClient() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Documents Tab */}
|
||||||
|
<TabsContent value="documents">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Uploaded Documents</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Documents submitted with your application
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{project.files.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-center py-4">
|
||||||
|
No documents uploaded
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{project.files.map((file) => {
|
||||||
|
const Icon = fileTypeIcons[file.fileType] || File
|
||||||
|
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">{file.fileName}</p>
|
||||||
|
{fileRecord.isLate && (
|
||||||
|
<Badge variant="warning" className="text-xs gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Submitted late
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{fileTypeLabels[file.fileType] || file.fileType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" disabled>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Mentor Tab */}
|
||||||
|
<TabsContent value="mentor">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Mentor Communication
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Chat with your assigned mentor
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MentorChat
|
||||||
|
messages={mentorMessages || []}
|
||||||
|
currentUserId={session?.user?.id || ''}
|
||||||
|
onSendMessage={async (message) => {
|
||||||
|
await sendMessage.mutateAsync({ projectId, message })
|
||||||
|
}}
|
||||||
|
isLoading={messagesLoading}
|
||||||
|
isSending={sendMessage.isPending}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { processEvaluationReminders } from '@/server/services/evaluation-reminders'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const cronSecret = request.headers.get('x-cron-secret')
|
||||||
|
|
||||||
|
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await processEvaluationReminders()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
sent: result.sent,
|
||||||
|
errors: result.errors,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cron reminder processing failed:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Target,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
|
||||||
|
interface EvaluationSummaryCardProps {
|
||||||
|
projectId: string
|
||||||
|
roundId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoringPatterns {
|
||||||
|
averageGlobalScore: number | null
|
||||||
|
consensus: number
|
||||||
|
criterionAverages: Record<string, number>
|
||||||
|
evaluatorCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeItem {
|
||||||
|
theme: string
|
||||||
|
sentiment: 'positive' | 'negative' | 'mixed'
|
||||||
|
frequency: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryJson {
|
||||||
|
overallAssessment: string
|
||||||
|
strengths: string[]
|
||||||
|
weaknesses: string[]
|
||||||
|
themes: ThemeItem[]
|
||||||
|
recommendation: string
|
||||||
|
scoringPatterns: ScoringPatterns
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentimentColors: Record<string, { badge: 'default' | 'secondary' | 'destructive'; bg: string }> = {
|
||||||
|
positive: { badge: 'default', bg: 'bg-green-500/10 text-green-700' },
|
||||||
|
negative: { badge: 'destructive', bg: 'bg-red-500/10 text-red-700' },
|
||||||
|
mixed: { badge: 'secondary', bg: 'bg-amber-500/10 text-amber-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EvaluationSummaryCard({
|
||||||
|
projectId,
|
||||||
|
roundId,
|
||||||
|
}: EvaluationSummaryCardProps) {
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: summary,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = trpc.evaluation.getSummary.useQuery({ projectId, roundId })
|
||||||
|
|
||||||
|
const generateMutation = trpc.evaluation.generateSummary.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('AI summary generated successfully')
|
||||||
|
refetch()
|
||||||
|
setIsGenerating(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || 'Failed to generate summary')
|
||||||
|
setIsGenerating(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
setIsGenerating(true)
|
||||||
|
generateMutation.mutate({ projectId, roundId })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No summary exists yet
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
AI Evaluation Summary
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate an AI-powered analysis of jury evaluations
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
|
<Sparkles className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
No summary generated yet. Click below to analyze submitted evaluations.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleGenerate} disabled={isGenerating}>
|
||||||
|
{isGenerating ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isGenerating ? 'Generating...' : 'Generate Summary'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryData = summary.summaryJson as unknown as SummaryJson
|
||||||
|
const patterns = summaryData.scoringPatterns
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
AI Evaluation Summary
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-2 mt-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Generated {formatDistanceToNow(new Date(summary.generatedAt), { addSuffix: true })}
|
||||||
|
{' '}using {summary.model}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={isGenerating}>
|
||||||
|
{isGenerating ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Regenerate Summary</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will replace the existing AI summary with a new one.
|
||||||
|
This uses your OpenAI API quota.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleGenerate}>
|
||||||
|
Regenerate
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Scoring Stats */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||||
|
<Target className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{patterns.averageGlobalScore !== null
|
||||||
|
? patterns.averageGlobalScore.toFixed(1)
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Avg Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{Math.round(patterns.consensus * 100)}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Consensus</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||||
|
<Users className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{patterns.evaluatorCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Evaluators</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Assessment */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Overall Assessment</p>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{summaryData.overallAssessment}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Strengths & Weaknesses */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{summaryData.strengths.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2 text-green-700">Strengths</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{summaryData.strengths.map((s, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||||
|
{s}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summaryData.weaknesses.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2 text-amber-700">Weaknesses</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{summaryData.weaknesses.map((w, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-amber-500 flex-shrink-0" />
|
||||||
|
{w}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Themes */}
|
||||||
|
{summaryData.themes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Key Themes</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{summaryData.themes.map((theme, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between p-2 rounded-lg border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className={sentimentColors[theme.sentiment]?.bg}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{theme.sentiment}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">{theme.theme}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{theme.frequency} mention{theme.frequency !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Criterion Averages */}
|
||||||
|
{Object.keys(patterns.criterionAverages).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Criterion Averages</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(patterns.criterionAverages).map(([label, avg]) => (
|
||||||
|
<div key={label} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground flex-1 min-w-0 truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<div className="w-24 h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
style={{ width: `${(avg / 10) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium w-8 text-right">
|
||||||
|
{avg.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendation */}
|
||||||
|
{summaryData.recommendation && (
|
||||||
|
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">
|
||||||
|
<p className="text-sm font-medium text-blue-900 mb-1">
|
||||||
|
Recommendation
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
{summaryData.recommendation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Loader2, ShieldAlert } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface COIDeclarationDialogProps {
|
||||||
|
open: boolean
|
||||||
|
assignmentId: string
|
||||||
|
projectTitle: string
|
||||||
|
onComplete: (hasConflict: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function COIDeclarationDialog({
|
||||||
|
open,
|
||||||
|
assignmentId,
|
||||||
|
projectTitle,
|
||||||
|
onComplete,
|
||||||
|
}: COIDeclarationDialogProps) {
|
||||||
|
const [hasConflict, setHasConflict] = useState<boolean | null>(null)
|
||||||
|
const [conflictType, setConflictType] = useState<string>('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
|
||||||
|
const declareCOI = trpc.evaluation.declareCOI.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.hasConflict) {
|
||||||
|
toast.info('Conflict of interest recorded. An admin will review your declaration.')
|
||||||
|
}
|
||||||
|
onComplete(data.hasConflict)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || 'Failed to submit COI declaration')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (hasConflict === null) return
|
||||||
|
|
||||||
|
declareCOI.mutate({
|
||||||
|
assignmentId,
|
||||||
|
hasConflict,
|
||||||
|
conflictType: hasConflict ? conflictType : undefined,
|
||||||
|
description: hasConflict ? description : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
hasConflict !== null &&
|
||||||
|
(!hasConflict || (hasConflict && conflictType)) &&
|
||||||
|
!declareCOI.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open}>
|
||||||
|
<AlertDialogContent className="max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
||||||
|
Conflict of Interest Declaration
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Before evaluating “{projectTitle}”, please declare whether
|
||||||
|
you have any conflict of interest with this project.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
Do you have a conflict of interest with this project?
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={hasConflict === false ? 'default' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setHasConflict(false)}
|
||||||
|
>
|
||||||
|
No Conflict
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={hasConflict === true ? 'destructive' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setHasConflict(true)}
|
||||||
|
>
|
||||||
|
Yes, I Have a Conflict
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasConflict && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="conflict-type">Type of Conflict</Label>
|
||||||
|
<Select value={conflictType} onValueChange={setConflictType}>
|
||||||
|
<SelectTrigger id="conflict-type">
|
||||||
|
<SelectValue placeholder="Select conflict type..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="financial">Financial Interest</SelectItem>
|
||||||
|
<SelectItem value="personal">Personal Relationship</SelectItem>
|
||||||
|
<SelectItem value="organizational">Organizational Affiliation</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="conflict-description">
|
||||||
|
Description <span className="text-muted-foreground">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="conflict-description"
|
||||||
|
placeholder="Briefly describe the nature of your conflict..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
{declareCOI.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{hasConflict === null
|
||||||
|
? 'Select an option'
|
||||||
|
: hasConflict
|
||||||
|
? 'Submit Declaration'
|
||||||
|
: 'Confirm No Conflict'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -45,16 +45,42 @@ import {
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
|
Type,
|
||||||
|
ToggleLeft,
|
||||||
|
Hash,
|
||||||
|
Heading,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||||
|
|
||||||
|
export interface CriterionCondition {
|
||||||
|
criterionId: string
|
||||||
|
operator: 'equals' | 'greaterThan' | 'lessThan'
|
||||||
|
value: number | string | boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface Criterion {
|
export interface Criterion {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
scale: number // 5 or 10
|
type?: CriterionType // defaults to 'numeric'
|
||||||
|
// Numeric fields
|
||||||
|
scale?: number // 5 or 10
|
||||||
weight?: number
|
weight?: number
|
||||||
required: boolean
|
required?: boolean
|
||||||
|
// Text fields
|
||||||
|
maxLength?: number
|
||||||
|
placeholder?: string
|
||||||
|
// Boolean fields
|
||||||
|
trueLabel?: string
|
||||||
|
falseLabel?: string
|
||||||
|
// Conditional visibility
|
||||||
|
condition?: CriterionCondition
|
||||||
|
// Section grouping
|
||||||
|
sectionId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EvaluationFormBuilderProps {
|
interface EvaluationFormBuilderProps {
|
||||||
|
|
@ -67,17 +93,34 @@ function generateId(): string {
|
||||||
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultCriterion(): Criterion {
|
function createDefaultCriterion(type: CriterionType = 'numeric'): Criterion {
|
||||||
return {
|
const base: Criterion = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
label: '',
|
label: '',
|
||||||
description: '',
|
description: '',
|
||||||
scale: 5,
|
type,
|
||||||
weight: 1,
|
}
|
||||||
required: true,
|
switch (type) {
|
||||||
|
case 'numeric':
|
||||||
|
return { ...base, scale: 5, weight: 1, required: true }
|
||||||
|
case 'text':
|
||||||
|
return { ...base, maxLength: 1000, placeholder: '', required: true }
|
||||||
|
case 'boolean':
|
||||||
|
return { ...base, trueLabel: 'Yes', falseLabel: 'No', required: true }
|
||||||
|
case 'section_header':
|
||||||
|
return { ...base, required: false }
|
||||||
|
default:
|
||||||
|
return { ...base, scale: 5, weight: 1, required: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CRITERION_TYPE_OPTIONS: { value: CriterionType; label: string; icon: typeof Hash }[] = [
|
||||||
|
{ value: 'numeric', label: 'Numeric Score', icon: Hash },
|
||||||
|
{ value: 'text', label: 'Text Response', icon: Type },
|
||||||
|
{ value: 'boolean', label: 'Yes / No', icon: ToggleLeft },
|
||||||
|
{ value: 'section_header', label: 'Section Header', icon: Heading },
|
||||||
|
]
|
||||||
|
|
||||||
export function EvaluationFormBuilder({
|
export function EvaluationFormBuilder({
|
||||||
initialCriteria = [],
|
initialCriteria = [],
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -97,8 +140,8 @@ export function EvaluationFormBuilder({
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add new criterion
|
// Add new criterion
|
||||||
const addCriterion = useCallback(() => {
|
const addCriterion = useCallback((type: CriterionType = 'numeric') => {
|
||||||
const newCriterion = createDefaultCriterion()
|
const newCriterion = createDefaultCriterion(type)
|
||||||
const newCriteria = [...criteria, newCriterion]
|
const newCriteria = [...criteria, newCriterion]
|
||||||
updateCriteria(newCriteria)
|
updateCriteria(newCriteria)
|
||||||
setEditingId(newCriterion.id)
|
setEditingId(newCriterion.id)
|
||||||
|
|
@ -190,13 +233,24 @@ export function EvaluationFormBuilder({
|
||||||
{isEditing && editDraft ? (
|
{isEditing && editDraft ? (
|
||||||
// Edit mode
|
// Edit mode
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Type indicator */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{CRITERION_TYPE_OPTIONS.find((t) => t.value === (editDraft.type || 'numeric'))?.label ?? 'Numeric Score'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`label-${criterion.id}`}>Label *</Label>
|
<Label htmlFor={`label-${criterion.id}`}>Label *</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`label-${criterion.id}`}
|
id={`label-${criterion.id}`}
|
||||||
value={editDraft.label}
|
value={editDraft.label}
|
||||||
onChange={(e) => updateDraft({ label: e.target.value })}
|
onChange={(e) => updateDraft({ label: e.target.value })}
|
||||||
placeholder="e.g., Innovation"
|
placeholder={
|
||||||
|
(editDraft.type || 'numeric') === 'section_header'
|
||||||
|
? 'e.g., Technical Assessment'
|
||||||
|
: 'e.g., Innovation'
|
||||||
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
@ -217,11 +271,13 @@ export function EvaluationFormBuilder({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Type-specific fields */}
|
||||||
|
{(editDraft.type || 'numeric') === 'numeric' && (
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`scale-${criterion.id}`}>Scale</Label>
|
<Label htmlFor={`scale-${criterion.id}`}>Scale</Label>
|
||||||
<Select
|
<Select
|
||||||
value={String(editDraft.scale)}
|
value={String(editDraft.scale ?? 5)}
|
||||||
onValueChange={(v) => updateDraft({ scale: parseInt(v) })}
|
onValueChange={(v) => updateDraft({ scale: parseInt(v) })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|
@ -266,7 +322,7 @@ export function EvaluationFormBuilder({
|
||||||
<Label>Required</Label>
|
<Label>Required</Label>
|
||||||
<div className="flex items-center h-10">
|
<div className="flex items-center h-10">
|
||||||
<Switch
|
<Switch
|
||||||
checked={editDraft.required}
|
checked={editDraft.required ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateDraft({ required: checked })
|
updateDraft({ required: checked })
|
||||||
}
|
}
|
||||||
|
|
@ -275,6 +331,186 @@ export function EvaluationFormBuilder({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(editDraft.type || 'numeric') === 'text' && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`maxLength-${criterion.id}`}>Max Length</Label>
|
||||||
|
<Input
|
||||||
|
id={`maxLength-${criterion.id}`}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
value={editDraft.maxLength ?? 1000}
|
||||||
|
onChange={(e) => updateDraft({ maxLength: parseInt(e.target.value) || 1000 })}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`placeholder-${criterion.id}`}>Placeholder</Label>
|
||||||
|
<Input
|
||||||
|
id={`placeholder-${criterion.id}`}
|
||||||
|
value={editDraft.placeholder || ''}
|
||||||
|
onChange={(e) => updateDraft({ placeholder: e.target.value })}
|
||||||
|
placeholder="Enter placeholder text..."
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Required</Label>
|
||||||
|
<div className="flex items-center h-10">
|
||||||
|
<Switch
|
||||||
|
checked={editDraft.required ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateDraft({ required: checked })
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(editDraft.type || 'numeric') === 'boolean' && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`trueLabel-${criterion.id}`}>Yes Label</Label>
|
||||||
|
<Input
|
||||||
|
id={`trueLabel-${criterion.id}`}
|
||||||
|
value={editDraft.trueLabel || 'Yes'}
|
||||||
|
onChange={(e) => updateDraft({ trueLabel: e.target.value })}
|
||||||
|
placeholder="Yes"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`falseLabel-${criterion.id}`}>No Label</Label>
|
||||||
|
<Input
|
||||||
|
id={`falseLabel-${criterion.id}`}
|
||||||
|
value={editDraft.falseLabel || 'No'}
|
||||||
|
onChange={(e) => updateDraft({ falseLabel: e.target.value })}
|
||||||
|
placeholder="No"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Required</Label>
|
||||||
|
<div className="flex items-center h-10">
|
||||||
|
<Switch
|
||||||
|
checked={editDraft.required ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateDraft({ required: checked })
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Condition builder - available for all types except section_header */}
|
||||||
|
{(editDraft.type || 'numeric') !== 'section_header' && criteria.filter((c) => c.id !== editDraft.id).length > 0 && (
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Conditional Visibility</Label>
|
||||||
|
{editDraft.condition ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateDraft({ condition: undefined })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-3 w-3" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
updateDraft({
|
||||||
|
condition: {
|
||||||
|
criterionId: criteria.filter((c) => c.id !== editDraft.id)[0]?.id ?? '',
|
||||||
|
operator: 'equals',
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Add Condition
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editDraft.condition && (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3 p-3 rounded-md bg-muted/50">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">When criterion</Label>
|
||||||
|
<Select
|
||||||
|
value={editDraft.condition.criterionId}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateDraft({ condition: { ...editDraft.condition!, criterionId: v } })
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{criteria
|
||||||
|
.filter((c) => c.id !== editDraft.id && (c.type || 'numeric') !== 'section_header')
|
||||||
|
.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.label || '(Untitled)'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Operator</Label>
|
||||||
|
<Select
|
||||||
|
value={editDraft.condition.operator}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateDraft({ condition: { ...editDraft.condition!, operator: v as 'equals' | 'greaterThan' | 'lessThan' } })
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="equals">equals</SelectItem>
|
||||||
|
<SelectItem value="greaterThan">greater than</SelectItem>
|
||||||
|
<SelectItem value="lessThan">less than</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Value</Label>
|
||||||
|
<Input
|
||||||
|
value={String(editDraft.condition.value)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value
|
||||||
|
const parsed = Number(raw)
|
||||||
|
updateDraft({
|
||||||
|
condition: {
|
||||||
|
...editDraft.condition!,
|
||||||
|
value: isNaN(parsed) ? (raw === 'true' ? true : raw === 'false' ? false : raw) : parsed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Edit actions */}
|
{/* Edit actions */}
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
|
@ -310,22 +546,37 @@ export function EvaluationFormBuilder({
|
||||||
{/* Criterion info */}
|
{/* Criterion info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-medium truncate">
|
<span className={cn(
|
||||||
|
'font-medium truncate',
|
||||||
|
(criterion.type || 'numeric') === 'section_header' && 'text-base font-semibold'
|
||||||
|
)}>
|
||||||
{criterion.label || '(Untitled)'}
|
{criterion.label || '(Untitled)'}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
{(() => {
|
||||||
1-{criterion.scale}
|
const type = criterion.type || 'numeric'
|
||||||
|
const TypeIcon = CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.icon ?? Hash
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-xs gap-1">
|
||||||
|
<TypeIcon className="h-3 w-3" />
|
||||||
|
{type === 'numeric' ? `1-${criterion.scale ?? 5}` : CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
{criterion.weight && criterion.weight !== 1 && (
|
)
|
||||||
|
})()}
|
||||||
|
{criterion.weight && criterion.weight !== 1 && (criterion.type || 'numeric') === 'numeric' && (
|
||||||
<Badge variant="outline" className="shrink-0 text-xs">
|
<Badge variant="outline" className="shrink-0 text-xs">
|
||||||
{criterion.weight}x
|
{criterion.weight}x
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{criterion.required && (
|
{criterion.required && (criterion.type || 'numeric') !== 'section_header' && (
|
||||||
<Badge variant="default" className="shrink-0 text-xs">
|
<Badge variant="default" className="shrink-0 text-xs">
|
||||||
Required
|
Required
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{criterion.condition && (
|
||||||
|
<Badge variant="outline" className="shrink-0 text-xs text-amber-600 border-amber-300">
|
||||||
|
Conditional
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{criterion.description && (
|
{criterion.description && (
|
||||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||||
|
|
@ -418,17 +669,20 @@ export function EvaluationFormBuilder({
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{CRITERION_TYPE_OPTIONS.map(({ value, label, icon: Icon }) => (
|
||||||
<Button
|
<Button
|
||||||
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={addCriterion}
|
onClick={() => addCriterion(value)}
|
||||||
disabled={editingId !== null}
|
disabled={editingId !== null}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Icon className="mr-1 h-4 w-4" />
|
||||||
Add Criterion
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
{criteria.length > 0 && (
|
{criteria.length > 0 && (
|
||||||
<PreviewDialog criteria={criteria} />
|
<PreviewDialog criteria={criteria} />
|
||||||
|
|
@ -458,7 +712,21 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{criteria.map((criterion) => (
|
{criteria.map((criterion) => {
|
||||||
|
const type = criterion.type || 'numeric'
|
||||||
|
|
||||||
|
if (type === 'section_header') {
|
||||||
|
return (
|
||||||
|
<div key={criterion.id} className="border-b pb-2 pt-4">
|
||||||
|
<h3 className="font-semibold text-lg">{criterion.label}</h3>
|
||||||
|
{criterion.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<Card key={criterion.id}>
|
<Card key={criterion.id}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
|
@ -474,6 +742,7 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{type === 'numeric' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground w-4">1</span>
|
<span className="text-xs text-muted-foreground w-4">1</span>
|
||||||
|
|
@ -484,18 +753,17 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground w-4">
|
<span className="text-xs text-muted-foreground w-4">
|
||||||
{criterion.scale}
|
{criterion.scale ?? 5}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map(
|
{Array.from({ length: criterion.scale ?? 5 }, (_, i) => i + 1).map(
|
||||||
(num) => (
|
(num) => (
|
||||||
<div
|
<div
|
||||||
key={num}
|
key={num}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-9 h-9 rounded-md text-sm font-medium flex items-center justify-center',
|
'w-9 h-9 rounded-md text-sm font-medium flex items-center justify-center',
|
||||||
num <= Math.ceil(criterion.scale / 2)
|
num <= Math.ceil((criterion.scale ?? 5) / 2)
|
||||||
? 'bg-primary/20 text-primary'
|
? 'bg-primary/20 text-primary'
|
||||||
: 'bg-muted'
|
: 'bg-muted'
|
||||||
)}
|
)}
|
||||||
|
|
@ -506,9 +774,32 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{type === 'text' && (
|
||||||
|
<Textarea
|
||||||
|
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||||
|
rows={3}
|
||||||
|
maxLength={criterion.maxLength ?? 1000}
|
||||||
|
disabled
|
||||||
|
className="opacity-60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === 'boolean' && (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1 h-12 rounded-md border flex items-center justify-center text-sm font-medium bg-muted/50">
|
||||||
|
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||||
|
{criterion.trueLabel || 'Yes'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-12 rounded-md border flex items-center justify-center text-sm font-medium bg-muted/50">
|
||||||
|
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||||
|
{criterion.falseLabel || 'No'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{criteria.length === 0 && (
|
{criteria.length === 0 && (
|
||||||
<p className="text-center text-muted-foreground py-8">
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { EvaluationForm } from './evaluation-form'
|
||||||
|
import { COIDeclarationDialog } from './coi-declaration-dialog'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { ShieldAlert } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Criterion {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||||
|
scale?: number
|
||||||
|
weight?: number
|
||||||
|
required?: boolean
|
||||||
|
maxLength?: number
|
||||||
|
placeholder?: string
|
||||||
|
trueLabel?: string
|
||||||
|
falseLabel?: string
|
||||||
|
condition?: {
|
||||||
|
criterionId: string
|
||||||
|
operator: 'equals' | 'greaterThan' | 'lessThan'
|
||||||
|
value: number | string | boolean
|
||||||
|
}
|
||||||
|
sectionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EvaluationFormWithCOIProps {
|
||||||
|
assignmentId: string
|
||||||
|
evaluationId: string | null
|
||||||
|
projectTitle: string
|
||||||
|
criteria: Criterion[]
|
||||||
|
initialData?: {
|
||||||
|
criterionScoresJson: Record<string, number | string | boolean> | null
|
||||||
|
globalScore: number | null
|
||||||
|
binaryDecision: boolean | null
|
||||||
|
feedbackText: string | null
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
isVotingOpen: boolean
|
||||||
|
deadline?: Date | null
|
||||||
|
coiStatus: {
|
||||||
|
hasConflict: boolean
|
||||||
|
declared: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EvaluationFormWithCOI({
|
||||||
|
assignmentId,
|
||||||
|
evaluationId,
|
||||||
|
projectTitle,
|
||||||
|
criteria,
|
||||||
|
initialData,
|
||||||
|
isVotingOpen,
|
||||||
|
deadline,
|
||||||
|
coiStatus,
|
||||||
|
}: EvaluationFormWithCOIProps) {
|
||||||
|
const [coiDeclared, setCOIDeclared] = useState(coiStatus.declared)
|
||||||
|
const [hasConflict, setHasConflict] = useState(coiStatus.hasConflict)
|
||||||
|
|
||||||
|
const handleCOIComplete = (conflictDeclared: boolean) => {
|
||||||
|
setCOIDeclared(true)
|
||||||
|
setHasConflict(conflictDeclared)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show COI dialog if not yet declared
|
||||||
|
if (!coiDeclared) {
|
||||||
|
return (
|
||||||
|
<COIDeclarationDialog
|
||||||
|
open={true}
|
||||||
|
assignmentId={assignmentId}
|
||||||
|
projectTitle={projectTitle}
|
||||||
|
onComplete={handleCOIComplete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show warning banner if conflict was declared
|
||||||
|
if (hasConflict) {
|
||||||
|
return (
|
||||||
|
<Card className="border-amber-500 bg-amber-500/5">
|
||||||
|
<CardContent className="flex items-center gap-3 py-6">
|
||||||
|
<ShieldAlert className="h-6 w-6 text-amber-600 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
Conflict of Interest Declared
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||||
|
You declared a conflict of interest for this project. An admin will
|
||||||
|
review your declaration. You cannot evaluate this project while the
|
||||||
|
conflict is under review.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No conflict - show the evaluation form
|
||||||
|
return (
|
||||||
|
<EvaluationForm
|
||||||
|
assignmentId={assignmentId}
|
||||||
|
evaluationId={evaluationId}
|
||||||
|
projectTitle={projectTitle}
|
||||||
|
criteria={criteria}
|
||||||
|
initialData={initialData}
|
||||||
|
isVotingOpen={isVotingOpen}
|
||||||
|
deadline={deadline}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useTransition } from 'react'
|
import { useState, useEffect, useCallback, useTransition, useMemo } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
|
@ -14,6 +14,7 @@ import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -38,15 +39,36 @@ import {
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||||
|
|
||||||
// Define criterion type from the evaluation form JSON
|
// Define criterion type from the evaluation form JSON
|
||||||
|
type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||||
|
|
||||||
|
interface CriterionCondition {
|
||||||
|
criterionId: string
|
||||||
|
operator: 'equals' | 'greaterThan' | 'lessThan'
|
||||||
|
value: number | string | boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface Criterion {
|
interface Criterion {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
scale: number // max value (e.g., 5 or 10)
|
type?: CriterionType // defaults to 'numeric'
|
||||||
|
// Numeric
|
||||||
|
scale?: number // max value (e.g., 5 or 10)
|
||||||
weight?: number
|
weight?: number
|
||||||
required?: boolean
|
required?: boolean
|
||||||
|
// Text
|
||||||
|
maxLength?: number
|
||||||
|
placeholder?: string
|
||||||
|
// Boolean
|
||||||
|
trueLabel?: string
|
||||||
|
falseLabel?: string
|
||||||
|
// Conditional visibility
|
||||||
|
condition?: CriterionCondition
|
||||||
|
// Section grouping
|
||||||
|
sectionId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EvaluationFormProps {
|
interface EvaluationFormProps {
|
||||||
|
|
@ -55,7 +77,7 @@ interface EvaluationFormProps {
|
||||||
projectTitle: string
|
projectTitle: string
|
||||||
criteria: Criterion[]
|
criteria: Criterion[]
|
||||||
initialData?: {
|
initialData?: {
|
||||||
criterionScoresJson: Record<string, number> | null
|
criterionScoresJson: Record<string, number | string | boolean> | null
|
||||||
globalScore: number | null
|
globalScore: number | null
|
||||||
binaryDecision: boolean | null
|
binaryDecision: boolean | null
|
||||||
feedbackText: string | null
|
feedbackText: string | null
|
||||||
|
|
@ -65,15 +87,52 @@ interface EvaluationFormProps {
|
||||||
deadline?: Date | null
|
deadline?: Date | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEvaluationSchema = (criteria: Criterion[]) =>
|
const createEvaluationSchema = (criteria: Criterion[]) => {
|
||||||
z.object({
|
const criterionFields: Record<string, z.ZodTypeAny> = {}
|
||||||
criterionScores: z.record(z.number()),
|
for (const c of criteria) {
|
||||||
|
const type = c.type || 'numeric'
|
||||||
|
if (type === 'section_header') continue
|
||||||
|
if (type === 'numeric') {
|
||||||
|
criterionFields[c.id] = z.number()
|
||||||
|
} else if (type === 'text') {
|
||||||
|
criterionFields[c.id] = z.string()
|
||||||
|
} else if (type === 'boolean') {
|
||||||
|
criterionFields[c.id] = z.boolean()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return z.object({
|
||||||
|
criterionScores: z.object(criterionFields).passthrough(),
|
||||||
globalScore: z.number().int().min(1).max(10),
|
globalScore: z.number().int().min(1).max(10),
|
||||||
binaryDecision: z.boolean(),
|
binaryDecision: z.boolean(),
|
||||||
feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'),
|
feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type EvaluationFormData = z.infer<ReturnType<typeof createEvaluationSchema>>
|
type EvaluationFormData = {
|
||||||
|
criterionScores: Record<string, number | string | boolean>
|
||||||
|
globalScore: number
|
||||||
|
binaryDecision: boolean
|
||||||
|
feedbackText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluate whether a condition is met based on current form values */
|
||||||
|
function evaluateCondition(
|
||||||
|
condition: CriterionCondition,
|
||||||
|
scores: Record<string, number | string | boolean>
|
||||||
|
): boolean {
|
||||||
|
const val = scores[condition.criterionId]
|
||||||
|
if (val === undefined) return false
|
||||||
|
switch (condition.operator) {
|
||||||
|
case 'equals':
|
||||||
|
return val === condition.value
|
||||||
|
case 'greaterThan':
|
||||||
|
return typeof val === 'number' && typeof condition.value === 'number' && val > condition.value
|
||||||
|
case 'lessThan':
|
||||||
|
return typeof val === 'number' && typeof condition.value === 'number' && val < condition.value
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function EvaluationForm({
|
export function EvaluationForm({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
|
|
@ -89,10 +148,41 @@ export function EvaluationForm({
|
||||||
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||||
|
|
||||||
|
// Progress tracking state
|
||||||
|
const [touchedCriteria, setTouchedCriteria] = useState<Set<string>>(() => {
|
||||||
|
const initial = new Set<string>()
|
||||||
|
if (initialData?.criterionScoresJson) {
|
||||||
|
for (const key of Object.keys(initialData.criterionScoresJson)) {
|
||||||
|
initial.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return initial
|
||||||
|
})
|
||||||
|
const [globalScoreTouched, setGlobalScoreTouched] = useState(
|
||||||
|
() => initialData?.globalScore != null
|
||||||
|
)
|
||||||
|
const [decisionTouched, setDecisionTouched] = useState(
|
||||||
|
() => initialData?.binaryDecision != null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compute which criteria are scorable (not section headers)
|
||||||
|
const scorableCriteria = useMemo(
|
||||||
|
() => criteria.filter((c) => (c.type || 'numeric') !== 'section_header'),
|
||||||
|
[criteria]
|
||||||
|
)
|
||||||
|
|
||||||
// Initialize criterion scores with existing data or defaults
|
// Initialize criterion scores with existing data or defaults
|
||||||
const defaultCriterionScores: Record<string, number> = {}
|
const defaultCriterionScores: Record<string, number | string | boolean> = {}
|
||||||
criteria.forEach((c) => {
|
scorableCriteria.forEach((c) => {
|
||||||
defaultCriterionScores[c.id] = initialData?.criterionScoresJson?.[c.id] ?? Math.ceil(c.scale / 2)
|
const type = c.type || 'numeric'
|
||||||
|
const existing = initialData?.criterionScoresJson?.[c.id]
|
||||||
|
if (type === 'numeric') {
|
||||||
|
defaultCriterionScores[c.id] = typeof existing === 'number' ? existing : Math.ceil((c.scale ?? 5) / 2)
|
||||||
|
} else if (type === 'text') {
|
||||||
|
defaultCriterionScores[c.id] = typeof existing === 'string' ? existing : ''
|
||||||
|
} else if (type === 'boolean') {
|
||||||
|
defaultCriterionScores[c.id] = typeof existing === 'boolean' ? existing : false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = useForm<EvaluationFormData>({
|
const form = useForm<EvaluationFormData>({
|
||||||
|
|
@ -109,6 +199,46 @@ export function EvaluationForm({
|
||||||
const { watch, handleSubmit, control, formState } = form
|
const { watch, handleSubmit, control, formState } = form
|
||||||
const { errors, isValid, isDirty } = formState
|
const { errors, isValid, isDirty } = formState
|
||||||
|
|
||||||
|
// Progress tracking callbacks
|
||||||
|
const onCriterionTouch = useCallback((criterionId: string) => {
|
||||||
|
setTouchedCriteria((prev) => {
|
||||||
|
if (prev.has(criterionId)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(criterionId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Compute progress - section_headers count as always complete (skip them)
|
||||||
|
const feedbackValue = watch('feedbackText')
|
||||||
|
const watchedScores = watch('criterionScores')
|
||||||
|
const progress = useMemo(() => {
|
||||||
|
// Only count scorable criteria (not section headers)
|
||||||
|
const totalItems = scorableCriteria.length + 3
|
||||||
|
|
||||||
|
// Count completed criteria
|
||||||
|
let criteriaDone = 0
|
||||||
|
for (const c of scorableCriteria) {
|
||||||
|
const type = c.type || 'numeric'
|
||||||
|
if (type === 'numeric') {
|
||||||
|
if (touchedCriteria.has(c.id)) criteriaDone++
|
||||||
|
} else if (type === 'text') {
|
||||||
|
const val = watchedScores?.[c.id]
|
||||||
|
if (typeof val === 'string' && val.length > 0) criteriaDone++
|
||||||
|
} else if (type === 'boolean') {
|
||||||
|
if (touchedCriteria.has(c.id)) criteriaDone++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedItems =
|
||||||
|
criteriaDone +
|
||||||
|
(globalScoreTouched ? 1 : 0) +
|
||||||
|
(decisionTouched ? 1 : 0) +
|
||||||
|
(feedbackValue.length >= 10 ? 1 : 0)
|
||||||
|
const percentage = Math.round((completedItems / totalItems) * 100)
|
||||||
|
return { totalItems, completedItems, percentage, criteriaDone, criteriaTotal: scorableCriteria.length }
|
||||||
|
}, [scorableCriteria, touchedCriteria, watchedScores, globalScoreTouched, decisionTouched, feedbackValue])
|
||||||
|
|
||||||
// tRPC mutations
|
// tRPC mutations
|
||||||
const startEvaluation = trpc.evaluation.start.useMutation()
|
const startEvaluation = trpc.evaluation.start.useMutation()
|
||||||
const autosave = trpc.evaluation.autosave.useMutation()
|
const autosave = trpc.evaluation.autosave.useMutation()
|
||||||
|
|
@ -206,10 +336,20 @@ export function EvaluationForm({
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b">
|
<div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
|
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
|
||||||
{projectTitle}
|
{projectTitle}
|
||||||
</h2>
|
</h2>
|
||||||
|
{!isReadOnly && deadline && (
|
||||||
|
<CountdownTimer deadline={new Date(deadline)} />
|
||||||
|
)}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<ProgressIndicator
|
||||||
|
percentage={progress.percentage}
|
||||||
|
criteriaDone={progress.criteriaDone}
|
||||||
|
criteriaTotal={progress.criteriaTotal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
|
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -273,14 +413,61 @@ export function EvaluationForm({
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{criteria.map((criterion) => (
|
{criteria.map((criterion) => {
|
||||||
<CriterionField
|
const type = criterion.type || 'numeric'
|
||||||
|
|
||||||
|
// Evaluate conditional visibility
|
||||||
|
if (criterion.condition) {
|
||||||
|
const conditionMet = evaluateCondition(criterion.condition, watchedScores ?? {})
|
||||||
|
if (!conditionMet) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section header
|
||||||
|
if (type === 'section_header') {
|
||||||
|
return <SectionHeaderField key={criterion.id} criterion={criterion} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric (default)
|
||||||
|
if (type === 'numeric') {
|
||||||
|
return (
|
||||||
|
<NumericCriterionField
|
||||||
key={criterion.id}
|
key={criterion.id}
|
||||||
criterion={criterion}
|
criterion={criterion}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
|
onTouch={onCriterionTouch}
|
||||||
/>
|
/>
|
||||||
))}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text
|
||||||
|
if (type === 'text') {
|
||||||
|
return (
|
||||||
|
<TextCriterionField
|
||||||
|
key={criterion.id}
|
||||||
|
criterion={criterion}
|
||||||
|
control={control}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
onTouch={onCriterionTouch}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return (
|
||||||
|
<BooleanCriterionField
|
||||||
|
key={criterion.id}
|
||||||
|
criterion={criterion}
|
||||||
|
control={control}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
onTouch={onCriterionTouch}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
@ -309,7 +496,10 @@ export function EvaluationForm({
|
||||||
max={10}
|
max={10}
|
||||||
step={1}
|
step={1}
|
||||||
value={[field.value]}
|
value={[field.value]}
|
||||||
onValueChange={(v) => field.onChange(v[0])}
|
onValueChange={(v) => {
|
||||||
|
field.onChange(v[0])
|
||||||
|
setGlobalScoreTouched(true)
|
||||||
|
}}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
className="py-4"
|
className="py-4"
|
||||||
/>
|
/>
|
||||||
|
|
@ -318,7 +508,12 @@ export function EvaluationForm({
|
||||||
<button
|
<button
|
||||||
key={num}
|
key={num}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !isReadOnly && field.onChange(num)}
|
onClick={() => {
|
||||||
|
if (!isReadOnly) {
|
||||||
|
field.onChange(num)
|
||||||
|
setGlobalScoreTouched(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
'w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
||||||
|
|
@ -359,7 +554,12 @@ export function EvaluationForm({
|
||||||
'flex-1 h-20',
|
'flex-1 h-20',
|
||||||
field.value && 'bg-green-600 hover:bg-green-700'
|
field.value && 'bg-green-600 hover:bg-green-700'
|
||||||
)}
|
)}
|
||||||
onClick={() => !isReadOnly && field.onChange(true)}
|
onClick={() => {
|
||||||
|
if (!isReadOnly) {
|
||||||
|
field.onChange(true)
|
||||||
|
setDecisionTouched(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
>
|
>
|
||||||
<ThumbsUp className="mr-2 h-6 w-6" />
|
<ThumbsUp className="mr-2 h-6 w-6" />
|
||||||
|
|
@ -372,7 +572,12 @@ export function EvaluationForm({
|
||||||
'flex-1 h-20',
|
'flex-1 h-20',
|
||||||
!field.value && 'bg-red-600 hover:bg-red-700'
|
!field.value && 'bg-red-600 hover:bg-red-700'
|
||||||
)}
|
)}
|
||||||
onClick={() => !isReadOnly && field.onChange(false)}
|
onClick={() => {
|
||||||
|
if (!isReadOnly) {
|
||||||
|
field.onChange(false)
|
||||||
|
setDecisionTouched(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
>
|
>
|
||||||
<ThumbsDown className="mr-2 h-6 w-6" />
|
<ThumbsDown className="mr-2 h-6 w-6" />
|
||||||
|
|
@ -484,16 +689,31 @@ export function EvaluationForm({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Criterion field component
|
// Section header component (no input)
|
||||||
function CriterionField({
|
function SectionHeaderField({ criterion }: { criterion: Criterion }) {
|
||||||
|
return (
|
||||||
|
<div className="border-b pb-2 pt-4 first:pt-0">
|
||||||
|
<h3 className="font-semibold text-lg">{criterion.label}</h3>
|
||||||
|
{criterion.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric criterion field component (original behavior)
|
||||||
|
function NumericCriterionField({
|
||||||
criterion,
|
criterion,
|
||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
|
onTouch,
|
||||||
}: {
|
}: {
|
||||||
criterion: Criterion
|
criterion: Criterion
|
||||||
control: any
|
control: any
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
|
onTouch: (criterionId: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const scale = criterion.scale ?? 5
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name={`criterionScores.${criterion.id}`}
|
name={`criterionScores.${criterion.id}`}
|
||||||
|
|
@ -510,7 +730,7 @@ function CriterionField({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="shrink-0">
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{field.value}/{criterion.scale}
|
{field.value}/{scale}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -518,23 +738,31 @@ function CriterionField({
|
||||||
<span className="text-xs text-muted-foreground w-4">1</span>
|
<span className="text-xs text-muted-foreground w-4">1</span>
|
||||||
<Slider
|
<Slider
|
||||||
min={1}
|
min={1}
|
||||||
max={criterion.scale}
|
max={scale}
|
||||||
step={1}
|
step={1}
|
||||||
value={[field.value]}
|
value={[typeof field.value === 'number' ? field.value : Math.ceil(scale / 2)]}
|
||||||
onValueChange={(v) => field.onChange(v[0])}
|
onValueChange={(v) => {
|
||||||
|
field.onChange(v[0])
|
||||||
|
onTouch(criterion.id)
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground w-4">{criterion.scale}</span>
|
<span className="text-xs text-muted-foreground w-4">{scale}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Visual rating buttons */}
|
{/* Visual rating buttons */}
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map((num) => (
|
{Array.from({ length: scale }, (_, i) => i + 1).map((num) => (
|
||||||
<button
|
<button
|
||||||
key={num}
|
key={num}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !disabled && field.onChange(num)}
|
onClick={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
field.onChange(num)
|
||||||
|
onTouch(criterion.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||||
|
|
@ -556,6 +784,159 @@ function CriterionField({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text criterion field component
|
||||||
|
function TextCriterionField({
|
||||||
|
criterion,
|
||||||
|
control,
|
||||||
|
disabled,
|
||||||
|
onTouch,
|
||||||
|
}: {
|
||||||
|
criterion: Criterion
|
||||||
|
control: any
|
||||||
|
disabled: boolean
|
||||||
|
onTouch: (criterionId: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={`criterionScores.${criterion.id}`}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-base font-medium">{criterion.label}</Label>
|
||||||
|
{criterion.required && (
|
||||||
|
<Badge variant="destructive" className="text-xs">Required</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{criterion.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{criterion.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={typeof field.value === 'string' ? field.value : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value)
|
||||||
|
onTouch(criterion.id)
|
||||||
|
}}
|
||||||
|
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||||
|
rows={3}
|
||||||
|
maxLength={criterion.maxLength ?? 1000}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{typeof field.value === 'string' ? field.value.length : 0}
|
||||||
|
{criterion.maxLength ? ` / ${criterion.maxLength}` : ''} characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean criterion field component
|
||||||
|
function BooleanCriterionField({
|
||||||
|
criterion,
|
||||||
|
control,
|
||||||
|
disabled,
|
||||||
|
onTouch,
|
||||||
|
}: {
|
||||||
|
criterion: Criterion
|
||||||
|
control: any
|
||||||
|
disabled: boolean
|
||||||
|
onTouch: (criterionId: string) => void
|
||||||
|
}) {
|
||||||
|
const trueLabel = criterion.trueLabel || 'Yes'
|
||||||
|
const falseLabel = criterion.falseLabel || 'No'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={`criterionScores.${criterion.id}`}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-base font-medium">{criterion.label}</Label>
|
||||||
|
{criterion.required && (
|
||||||
|
<Badge variant="destructive" className="text-xs">Required</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{criterion.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{criterion.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={field.value === true ? 'default' : 'outline'}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 h-12',
|
||||||
|
field.value === true && 'bg-green-600 hover:bg-green-700'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
field.onChange(true)
|
||||||
|
onTouch(criterion.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||||
|
{trueLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={field.value === false ? 'default' : 'outline'}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 h-12',
|
||||||
|
field.value === false && 'bg-red-600 hover:bg-red-700'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
field.onChange(false)
|
||||||
|
onTouch(criterion.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||||
|
{falseLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress indicator component
|
||||||
|
function ProgressIndicator({
|
||||||
|
percentage,
|
||||||
|
criteriaDone,
|
||||||
|
criteriaTotal,
|
||||||
|
}: {
|
||||||
|
percentage: number
|
||||||
|
criteriaDone: number
|
||||||
|
criteriaTotal: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Progress value={percentage} className="w-16 sm:w-24 h-2" />
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
<span className="sm:hidden">{percentage}%</span>
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{criteriaDone} of {criteriaTotal} criteria scored · {percentage}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Autosave indicator component
|
// Autosave indicator component
|
||||||
function AutosaveIndicator({
|
function AutosaveIndicator({
|
||||||
status,
|
status,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Clock, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface CountdownTimerProps {
|
||||||
|
deadline: Date
|
||||||
|
label?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeRemaining {
|
||||||
|
days: number
|
||||||
|
hours: number
|
||||||
|
minutes: number
|
||||||
|
seconds: number
|
||||||
|
totalMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeRemaining(deadline: Date): TimeRemaining {
|
||||||
|
const totalMs = deadline.getTime() - Date.now()
|
||||||
|
if (totalMs <= 0) {
|
||||||
|
return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Math.floor((totalMs / 1000) % 60)
|
||||||
|
const minutes = Math.floor((totalMs / 1000 / 60) % 60)
|
||||||
|
const hours = Math.floor((totalMs / (1000 * 60 * 60)) % 24)
|
||||||
|
const days = Math.floor(totalMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
return { days, hours, minutes, seconds, totalMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(time: TimeRemaining): string {
|
||||||
|
if (time.totalMs <= 0) return 'Deadline passed'
|
||||||
|
|
||||||
|
const { days, hours, minutes, seconds } = time
|
||||||
|
|
||||||
|
// Less than 1 hour: show minutes and seconds
|
||||||
|
if (days === 0 && hours === 0) {
|
||||||
|
return `${minutes}m ${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Less than 24 hours: show hours and minutes
|
||||||
|
if (days === 0) {
|
||||||
|
return `${hours}h ${minutes}m ${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
// More than 24 hours: show days, hours, minutes
|
||||||
|
return `${days}d ${hours}h ${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Urgency = 'expired' | 'critical' | 'warning' | 'normal'
|
||||||
|
|
||||||
|
function getUrgency(totalMs: number): Urgency {
|
||||||
|
if (totalMs <= 0) return 'expired'
|
||||||
|
if (totalMs < 60 * 60 * 1000) return 'critical' // < 1 hour
|
||||||
|
if (totalMs < 24 * 60 * 60 * 1000) return 'warning' // < 24 hours
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
const urgencyStyles: Record<Urgency, string> = {
|
||||||
|
expired: 'text-muted-foreground bg-muted',
|
||||||
|
critical: 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-950/50 dark:border-red-900',
|
||||||
|
warning: 'text-amber-700 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50 dark:border-amber-900',
|
||||||
|
normal: 'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountdownTimer({ deadline, label, className }: CountdownTimerProps) {
|
||||||
|
const [time, setTime] = useState<TimeRemaining>(() => getTimeRemaining(deadline))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const remaining = getTimeRemaining(deadline)
|
||||||
|
setTime(remaining)
|
||||||
|
if (remaining.totalMs <= 0) {
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [deadline])
|
||||||
|
|
||||||
|
const urgency = getUrgency(time.totalMs)
|
||||||
|
const displayText = formatCountdown(time)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||||
|
urgencyStyles[urgency],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{urgency === 'critical' ? (
|
||||||
|
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Clock className="h-3 w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
{label && <span className="hidden sm:inline">{label}</span>}
|
||||||
|
<span className="tabular-nums">{displayText}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Send, MessageSquare } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
createdAt: Date | string
|
||||||
|
isRead: boolean
|
||||||
|
sender: {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MentorChatProps {
|
||||||
|
messages: Message[]
|
||||||
|
currentUserId: string
|
||||||
|
onSendMessage: (message: string) => Promise<void>
|
||||||
|
isLoading?: boolean
|
||||||
|
isSending?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentorChat({
|
||||||
|
messages,
|
||||||
|
currentUserId,
|
||||||
|
onSendMessage,
|
||||||
|
isLoading,
|
||||||
|
isSending,
|
||||||
|
className,
|
||||||
|
}: MentorChatProps) {
|
||||||
|
const [newMessage, setNewMessage] = useState('')
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const text = newMessage.trim()
|
||||||
|
if (!text || isSending) return
|
||||||
|
setNewMessage('')
|
||||||
|
await onSendMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (date: Date | string) => {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
return d.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-3', className)}>
|
||||||
|
<Skeleton className="h-16 w-3/4" />
|
||||||
|
<Skeleton className="h-16 w-3/4 ml-auto" />
|
||||||
|
<Skeleton className="h-16 w-3/4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col', className)}>
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto max-h-[400px] space-y-3 p-4">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<MessageSquare className="h-10 w-10 mb-3 opacity-50" />
|
||||||
|
<p className="text-sm font-medium">No messages yet</p>
|
||||||
|
<p className="text-xs mt-1">Send a message to start the conversation</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => {
|
||||||
|
const isOwn = msg.sender.id === currentUserId
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
isOwn ? 'justify-end' : 'justify-start'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[80%] rounded-lg px-4 py-2.5',
|
||||||
|
isOwn
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isOwn && (
|
||||||
|
<p className={cn(
|
||||||
|
'text-xs font-medium mb-1',
|
||||||
|
isOwn ? 'text-primary-foreground/70' : 'text-foreground/70'
|
||||||
|
)}>
|
||||||
|
{msg.sender.name || msg.sender.email}
|
||||||
|
{msg.sender.role === 'MENTOR' && (
|
||||||
|
<span className="ml-1.5 text-[10px] font-normal opacity-70">
|
||||||
|
Mentor
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words">
|
||||||
|
{msg.message}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] mt-1',
|
||||||
|
isOwn
|
||||||
|
? 'text-primary-foreground/60'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatTime(msg.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="border-t p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
className="min-h-[40px] max-h-[120px] resize-none"
|
||||||
|
rows={1}
|
||||||
|
disabled={isSending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!newMessage.trim() || isSending}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1.5">
|
||||||
|
Press Enter to send, Shift+Enter for new line
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { CheckCircle, Circle, Clock } from 'lucide-react'
|
import { CheckCircle, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
||||||
|
|
||||||
interface TimelineItem {
|
interface TimelineItem {
|
||||||
status: string
|
status: string
|
||||||
label: string
|
label: string
|
||||||
date: Date | string | null
|
date: Date | string | null
|
||||||
completed: boolean
|
completed: boolean
|
||||||
|
isTerminal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatusTrackerProps {
|
interface StatusTrackerProps {
|
||||||
|
|
@ -39,6 +40,8 @@ export function StatusTracker({
|
||||||
const isCurrent =
|
const isCurrent =
|
||||||
isCompleted && !timeline[index + 1]?.completed
|
isCompleted && !timeline[index + 1]?.completed
|
||||||
const isPending = !isCompleted
|
const isPending = !isCompleted
|
||||||
|
const isRejected = item.status === 'REJECTED' && item.isTerminal
|
||||||
|
const isWinner = item.status === 'WINNER' && isCompleted
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.status} className="relative flex gap-4">
|
<div key={item.status} className="relative flex gap-4">
|
||||||
|
|
@ -47,14 +50,26 @@ export function StatusTracker({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-[15px] top-[32px] h-full w-0.5',
|
'absolute left-[15px] top-[32px] h-full w-0.5',
|
||||||
isCompleted ? 'bg-primary' : 'bg-muted'
|
isRejected
|
||||||
|
? 'bg-destructive/30'
|
||||||
|
: isCompleted
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-muted'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
|
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
|
||||||
{isCompleted ? (
|
{isRejected ? (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
) : isWinner ? (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-500 text-white">
|
||||||
|
<Trophy className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
) : isCompleted ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-8 w-8 items-center justify-center rounded-full',
|
'flex h-8 w-8 items-center justify-center rounded-full',
|
||||||
|
|
@ -82,23 +97,35 @@ export function StatusTracker({
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-medium',
|
'font-medium',
|
||||||
isPending && 'text-muted-foreground'
|
isRejected && 'text-destructive',
|
||||||
|
isWinner && 'text-yellow-600',
|
||||||
|
isPending && !isRejected && 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
{isCurrent && (
|
{isCurrent && !isRejected && !isWinner && (
|
||||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
|
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
|
||||||
Current
|
Current
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isRejected && (
|
||||||
|
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded-full">
|
||||||
|
Final
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isWinner && (
|
||||||
|
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
|
||||||
|
Winner
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{item.date && (
|
{item.date && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{formatDate(item.date)}
|
{formatDate(item.date)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isPending && !isCurrent && (
|
{isPending && !isCurrent && !isRejected && (
|
||||||
<p className="text-sm text-muted-foreground">Pending</p>
|
<p className="text-sm text-muted-foreground">Pending</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
|
||||||
|
|
||||||
export const analyticsRouter = router({
|
export const analyticsRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get score distribution for a round (histogram data)
|
* Get score distribution for a round (histogram data)
|
||||||
*/
|
*/
|
||||||
getScoreDistribution: adminProcedure
|
getScoreDistribution: observerProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||||
|
|
@ -50,7 +50,7 @@ export const analyticsRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get evaluation completion over time (timeline data)
|
* Get evaluation completion over time (timeline data)
|
||||||
*/
|
*/
|
||||||
getEvaluationTimeline: adminProcedure
|
getEvaluationTimeline: observerProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||||
|
|
@ -96,7 +96,7 @@ export const analyticsRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get juror workload distribution
|
* Get juror workload distribution
|
||||||
*/
|
*/
|
||||||
getJurorWorkload: adminProcedure
|
getJurorWorkload: observerProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const assignments = await ctx.prisma.assignment.findMany({
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
|
|
@ -145,7 +145,7 @@ export const analyticsRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get project rankings with average scores
|
* Get project rankings with average scores
|
||||||
*/
|
*/
|
||||||
getProjectRankings: adminProcedure
|
getProjectRankings: observerProcedure
|
||||||
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
|
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
|
@ -213,7 +213,7 @@ export const analyticsRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get status breakdown (pie chart data)
|
* Get status breakdown (pie chart data)
|
||||||
*/
|
*/
|
||||||
getStatusBreakdown: adminProcedure
|
getStatusBreakdown: observerProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const projects = await ctx.prisma.project.groupBy({
|
const projects = await ctx.prisma.project.groupBy({
|
||||||
|
|
@ -231,7 +231,7 @@ export const analyticsRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get overview stats for dashboard
|
* Get overview stats for dashboard
|
||||||
*/
|
*/
|
||||||
getOverviewStats: adminProcedure
|
getOverviewStats: observerProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const [
|
const [
|
||||||
|
|
@ -281,7 +281,7 @@ export const analyticsRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get criteria-level score distribution
|
* Get criteria-level score distribution
|
||||||
*/
|
*/
|
||||||
getCriteriaScores: adminProcedure
|
getCriteriaScores: observerProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
// Get active evaluation form for this round
|
// Get active evaluation form for this round
|
||||||
|
|
@ -343,7 +343,7 @@ export const analyticsRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get geographic distribution of projects by country
|
* Get geographic distribution of projects by country
|
||||||
*/
|
*/
|
||||||
getGeographicDistribution: adminProcedure
|
getGeographicDistribution: observerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
programId: z.string(),
|
programId: z.string(),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
|
||||||
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
||||||
import { getPresignedUrl } from '@/lib/minio'
|
import { getPresignedUrl } from '@/lib/minio'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
import { createNotification } from '../services/in-app-notification'
|
||||||
|
|
||||||
// Bucket for applicant submissions
|
// Bucket for applicant submissions
|
||||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||||
|
|
@ -231,6 +232,7 @@ export const applicantRouter = router({
|
||||||
fileName: z.string(),
|
fileName: z.string(),
|
||||||
mimeType: z.string(),
|
mimeType: z.string(),
|
||||||
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
||||||
|
roundId: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|
@ -248,6 +250,9 @@ export const applicantRouter = router({
|
||||||
id: input.projectId,
|
id: input.projectId,
|
||||||
submittedByUserId: ctx.user.id,
|
submittedByUserId: ctx.user.id,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
|
@ -257,8 +262,38 @@ export const applicantRouter = router({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can't upload if already submitted
|
// Check round upload deadline policy if roundId provided
|
||||||
if (project.submittedAt) {
|
let isLate = false
|
||||||
|
const targetRoundId = input.roundId || project.roundId
|
||||||
|
if (targetRoundId) {
|
||||||
|
const round = input.roundId
|
||||||
|
? await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { votingStartAt: true, settingsJson: true },
|
||||||
|
})
|
||||||
|
: project.round
|
||||||
|
|
||||||
|
if (round) {
|
||||||
|
const settings = round.settingsJson as Record<string, unknown> | null
|
||||||
|
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
|
||||||
|
const now = new Date()
|
||||||
|
const roundStarted = round.votingStartAt && now > round.votingStartAt
|
||||||
|
|
||||||
|
if (roundStarted && uploadPolicy === 'BLOCK') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Uploads are blocked after the round has started',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roundStarted && uploadPolicy === 'ALLOW_LATE') {
|
||||||
|
isLate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't upload if already submitted (unless round allows it)
|
||||||
|
if (project.submittedAt && !isLate) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Cannot modify a submitted project',
|
message: 'Cannot modify a submitted project',
|
||||||
|
|
@ -275,6 +310,8 @@ export const applicantRouter = router({
|
||||||
url,
|
url,
|
||||||
bucket: SUBMISSIONS_BUCKET,
|
bucket: SUBMISSIONS_BUCKET,
|
||||||
objectKey,
|
objectKey,
|
||||||
|
isLate,
|
||||||
|
roundId: targetRoundId,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -291,6 +328,8 @@ export const applicantRouter = router({
|
||||||
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
||||||
bucket: z.string(),
|
bucket: z.string(),
|
||||||
objectKey: z.string(),
|
objectKey: z.string(),
|
||||||
|
roundId: z.string().optional(),
|
||||||
|
isLate: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|
@ -317,13 +356,14 @@ export const applicantRouter = router({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectId, ...fileData } = input
|
const { projectId, roundId, isLate, ...fileData } = input
|
||||||
|
|
||||||
// Delete existing file of same type if exists
|
// Delete existing file of same type, scoped by roundId if provided
|
||||||
await ctx.prisma.projectFile.deleteMany({
|
await ctx.prisma.projectFile.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
projectId,
|
projectId,
|
||||||
fileType: input.fileType,
|
fileType: input.fileType,
|
||||||
|
...(roundId ? { roundId } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -332,6 +372,8 @@ export const applicantRouter = router({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
...fileData,
|
...fileData,
|
||||||
|
roundId: roundId || null,
|
||||||
|
isLate: isLate || false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -380,6 +422,48 @@ export const applicantRouter = router({
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status timeline from ProjectStatusHistory
|
||||||
|
*/
|
||||||
|
getStatusTimeline: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Verify user has access to this project
|
||||||
|
const project = await ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.projectId,
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{
|
||||||
|
teamMembers: {
|
||||||
|
some: { userId: ctx.user.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Project not found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await ctx.prisma.projectStatusHistory.findMany({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
orderBy: { changedAt: 'asc' },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
changedAt: true,
|
||||||
|
changedBy: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return history
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get submission status timeline
|
* Get submission status timeline
|
||||||
*/
|
*/
|
||||||
|
|
@ -412,6 +496,9 @@ export const applicantRouter = router({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
wonAwards: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -425,39 +512,88 @@ export const applicantRouter = router({
|
||||||
// Get the project status
|
// Get the project status
|
||||||
const currentStatus = project.status ?? 'SUBMITTED'
|
const currentStatus = project.status ?? 'SUBMITTED'
|
||||||
|
|
||||||
// Build timeline
|
// Fetch actual status history
|
||||||
|
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
orderBy: { changedAt: 'asc' },
|
||||||
|
select: { status: true, changedAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build a map of status -> earliest changedAt
|
||||||
|
const statusDateMap = new Map<string, Date>()
|
||||||
|
for (const entry of statusHistory) {
|
||||||
|
if (!statusDateMap.has(entry.status)) {
|
||||||
|
statusDateMap.set(entry.status, entry.changedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRejected = currentStatus === 'REJECTED'
|
||||||
|
const hasWonAward = project.wonAwards.length > 0
|
||||||
|
|
||||||
|
// Build timeline - handle REJECTED as terminal state
|
||||||
const timeline = [
|
const timeline = [
|
||||||
{
|
{
|
||||||
status: 'CREATED',
|
status: 'CREATED',
|
||||||
label: 'Application Started',
|
label: 'Application Started',
|
||||||
date: project.createdAt,
|
date: project.createdAt,
|
||||||
completed: true,
|
completed: true,
|
||||||
|
isTerminal: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
label: 'Application Submitted',
|
label: 'Application Submitted',
|
||||||
date: project.submittedAt,
|
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
|
||||||
completed: !!project.submittedAt,
|
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
|
||||||
|
isTerminal: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 'UNDER_REVIEW',
|
status: 'UNDER_REVIEW',
|
||||||
label: 'Under Review',
|
label: 'Under Review',
|
||||||
date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
|
date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
|
||||||
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
|
(currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
|
||||||
|
completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
|
||||||
|
isTerminal: false,
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isRejected) {
|
||||||
|
// For rejected projects, show REJECTED as the terminal red step
|
||||||
|
timeline.push({
|
||||||
|
status: 'REJECTED',
|
||||||
|
label: 'Not Selected',
|
||||||
|
date: statusDateMap.get('REJECTED') || null,
|
||||||
|
completed: true,
|
||||||
|
isTerminal: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Normal progression
|
||||||
|
timeline.push(
|
||||||
{
|
{
|
||||||
status: 'SEMIFINALIST',
|
status: 'SEMIFINALIST',
|
||||||
label: 'Semi-finalist',
|
label: 'Semi-finalist',
|
||||||
date: null, // Would need status change tracking
|
date: statusDateMap.get('SEMIFINALIST') || null,
|
||||||
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
|
completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
|
||||||
|
isTerminal: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 'FINALIST',
|
status: 'FINALIST',
|
||||||
label: 'Finalist',
|
label: 'Finalist',
|
||||||
date: null,
|
date: statusDateMap.get('FINALIST') || null,
|
||||||
completed: ['FINALIST', 'WINNER'].includes(currentStatus),
|
completed: currentStatus === 'FINALIST' || hasWonAward,
|
||||||
|
isTerminal: false,
|
||||||
},
|
},
|
||||||
]
|
)
|
||||||
|
|
||||||
|
if (hasWonAward) {
|
||||||
|
timeline.push({
|
||||||
|
status: 'WINNER',
|
||||||
|
label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
|
||||||
|
date: null,
|
||||||
|
completed: true,
|
||||||
|
isTerminal: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
|
|
@ -714,4 +850,130 @@ export const applicantRouter = router({
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the assigned mentor
|
||||||
|
*/
|
||||||
|
sendMentorMessage: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
message: z.string().min(1).max(5000),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Verify user is part of this project team
|
||||||
|
const project = await ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.projectId,
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{
|
||||||
|
teamMembers: {
|
||||||
|
some: { userId: ctx.user.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
mentorAssignment: { select: { mentorId: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Project not found or you do not have access',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project.mentorAssignment) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'No mentor assigned to this project',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentorMessage = await ctx.prisma.mentorMessage.create({
|
||||||
|
data: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
senderId: ctx.user.id,
|
||||||
|
message: input.message,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify the mentor
|
||||||
|
await createNotification({
|
||||||
|
userId: project.mentorAssignment.mentorId,
|
||||||
|
type: 'MENTOR_MESSAGE',
|
||||||
|
title: 'New Message',
|
||||||
|
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
|
||||||
|
linkUrl: `/mentor/projects/${input.projectId}`,
|
||||||
|
linkLabel: 'View Message',
|
||||||
|
priority: 'normal',
|
||||||
|
metadata: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
projectName: project.title,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return mentorMessage
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mentor messages for a project (applicant side)
|
||||||
|
*/
|
||||||
|
getMentorMessages: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Verify user is part of this project team
|
||||||
|
const project = await ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.projectId,
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{
|
||||||
|
teamMembers: {
|
||||||
|
some: { userId: ctx.user.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Project not found or you do not have access',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await ctx.prisma.mentorMessage.findMany({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
select: { id: true, name: true, email: true, role: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark unread messages from mentor as read
|
||||||
|
await ctx.prisma.mentorMessage.updateMany({
|
||||||
|
where: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
senderId: { not: ctx.user.id },
|
||||||
|
isRead: false,
|
||||||
|
},
|
||||||
|
data: { isRead: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
||||||
|
import { processEvaluationReminders } from '../services/evaluation-reminders'
|
||||||
|
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
||||||
|
|
||||||
export const evaluationRouter = router({
|
export const evaluationRouter = router({
|
||||||
/**
|
/**
|
||||||
|
|
@ -89,7 +92,7 @@ export const evaluationRouter = router({
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
criterionScoresJson: z.record(z.number()).optional(),
|
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])).optional(),
|
||||||
globalScore: z.number().int().min(1).max(10).optional().nullable(),
|
globalScore: z.number().int().min(1).max(10).optional().nullable(),
|
||||||
binaryDecision: z.boolean().optional().nullable(),
|
binaryDecision: z.boolean().optional().nullable(),
|
||||||
feedbackText: z.string().optional().nullable(),
|
feedbackText: z.string().optional().nullable(),
|
||||||
|
|
@ -134,7 +137,7 @@ export const evaluationRouter = router({
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
criterionScoresJson: z.record(z.number()),
|
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
|
||||||
globalScore: z.number().int().min(1).max(10),
|
globalScore: z.number().int().min(1).max(10),
|
||||||
binaryDecision: z.boolean(),
|
binaryDecision: z.boolean(),
|
||||||
feedbackText: z.string().min(10),
|
feedbackText: z.string().min(10),
|
||||||
|
|
@ -325,4 +328,297 @@ export const evaluationRouter = router({
|
||||||
orderBy: { submittedAt: 'desc' },
|
orderBy: { submittedAt: 'desc' },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Conflict of Interest (COI) Endpoints
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare a conflict of interest for an assignment
|
||||||
|
*/
|
||||||
|
declareCOI: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
assignmentId: z.string(),
|
||||||
|
hasConflict: z.boolean(),
|
||||||
|
conflictType: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Look up the assignment to get projectId, roundId, userId
|
||||||
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
||||||
|
where: { id: input.assignmentId },
|
||||||
|
include: {
|
||||||
|
project: { select: { title: true } },
|
||||||
|
round: { select: { name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (assignment.userId !== ctx.user.id) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert COI record
|
||||||
|
const coi = await ctx.prisma.conflictOfInterest.upsert({
|
||||||
|
where: { assignmentId: input.assignmentId },
|
||||||
|
create: {
|
||||||
|
assignmentId: input.assignmentId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
roundId: assignment.roundId,
|
||||||
|
hasConflict: input.hasConflict,
|
||||||
|
conflictType: input.hasConflict ? input.conflictType : null,
|
||||||
|
description: input.hasConflict ? input.description : null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
hasConflict: input.hasConflict,
|
||||||
|
conflictType: input.hasConflict ? input.conflictType : null,
|
||||||
|
description: input.hasConflict ? input.description : null,
|
||||||
|
declaredAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify admins if conflict declared
|
||||||
|
if (input.hasConflict) {
|
||||||
|
await notifyAdmins({
|
||||||
|
type: NotificationTypes.JURY_INACTIVE,
|
||||||
|
title: 'Conflict of Interest Declared',
|
||||||
|
message: `${ctx.user.name || ctx.user.email} declared a conflict of interest (${input.conflictType || 'unspecified'}) for project "${assignment.project.title}" in ${assignment.round.name}.`,
|
||||||
|
linkUrl: `/admin/rounds/${assignment.roundId}/coi`,
|
||||||
|
linkLabel: 'Review COI',
|
||||||
|
priority: 'high',
|
||||||
|
metadata: {
|
||||||
|
assignmentId: input.assignmentId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
roundId: assignment.roundId,
|
||||||
|
conflictType: input.conflictType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'COI_DECLARED',
|
||||||
|
entityType: 'ConflictOfInterest',
|
||||||
|
entityId: coi.id,
|
||||||
|
detailsJson: {
|
||||||
|
assignmentId: input.assignmentId,
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
roundId: assignment.roundId,
|
||||||
|
hasConflict: input.hasConflict,
|
||||||
|
conflictType: input.conflictType,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return coi
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get COI status for an assignment
|
||||||
|
*/
|
||||||
|
getCOIStatus: protectedProcedure
|
||||||
|
.input(z.object({ assignmentId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.conflictOfInterest.findUnique({
|
||||||
|
where: { assignmentId: input.assignmentId },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List COI declarations for a round (admin only)
|
||||||
|
*/
|
||||||
|
listCOIByRound: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
hasConflictOnly: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.conflictOfInterest.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
...(input.hasConflictOnly && { hasConflict: true }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
assignment: {
|
||||||
|
include: {
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reviewedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
orderBy: { declaredAt: 'desc' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review a COI declaration (admin only)
|
||||||
|
*/
|
||||||
|
reviewCOI: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
reviewAction: z.enum(['cleared', 'reassigned', 'noted']),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const coi = await ctx.prisma.conflictOfInterest.update({
|
||||||
|
where: { id: input.id },
|
||||||
|
data: {
|
||||||
|
reviewedById: ctx.user.id,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewAction: input.reviewAction,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'COI_REVIEWED',
|
||||||
|
entityType: 'ConflictOfInterest',
|
||||||
|
entityId: input.id,
|
||||||
|
detailsJson: {
|
||||||
|
reviewAction: input.reviewAction,
|
||||||
|
assignmentId: coi.assignmentId,
|
||||||
|
userId: coi.userId,
|
||||||
|
projectId: coi.projectId,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return coi
|
||||||
|
}),
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Reminder Triggers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger reminder check for a specific round (admin only)
|
||||||
|
*/
|
||||||
|
triggerReminders: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const result = await processEvaluationReminders(input.roundId)
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'REMINDERS_TRIGGERED',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: {
|
||||||
|
sent: result.sent,
|
||||||
|
errors: result.errors,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AI Evaluation Summary Endpoints
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an AI-powered evaluation summary for a project (admin only)
|
||||||
|
*/
|
||||||
|
generateSummary: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
roundId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return generateSummary({
|
||||||
|
projectId: input.projectId,
|
||||||
|
roundId: input.roundId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an existing evaluation summary for a project (admin only)
|
||||||
|
*/
|
||||||
|
getSummary: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
roundId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.evaluationSummary.findUnique({
|
||||||
|
where: {
|
||||||
|
projectId_roundId: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
roundId: input.roundId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate summaries for all projects in a round with submitted evaluations (admin only)
|
||||||
|
*/
|
||||||
|
generateBulkSummaries: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Find all projects in the round with at least 1 submitted evaluation
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
assignments: {
|
||||||
|
some: {
|
||||||
|
evaluation: {
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let generated = 0
|
||||||
|
const errors: Array<{ projectId: string; error: string }> = []
|
||||||
|
|
||||||
|
// Generate summaries sequentially to avoid rate limits
|
||||||
|
for (const project of projects) {
|
||||||
|
try {
|
||||||
|
await generateSummary({
|
||||||
|
projectId: project.id,
|
||||||
|
roundId: input.roundId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
})
|
||||||
|
generated++
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
projectId: project.id,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: projects.length,
|
||||||
|
generated,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,103 @@ export const exportRouter = router({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export filtering results as CSV data
|
||||||
|
*/
|
||||||
|
filteringResults: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const results = await ctx.prisma.filteringResult.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
teamName: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
country: true,
|
||||||
|
oceanIssue: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { project: { title: 'asc' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collect all unique AI screening keys across all results
|
||||||
|
const aiKeys = new Set<string>()
|
||||||
|
results.forEach((r) => {
|
||||||
|
if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') {
|
||||||
|
const screening = r.aiScreeningJson as Record<string, Record<string, unknown>>
|
||||||
|
for (const ruleResult of Object.values(screening)) {
|
||||||
|
if (ruleResult && typeof ruleResult === 'object') {
|
||||||
|
Object.keys(ruleResult).forEach((k) => aiKeys.add(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedAiKeys = Array.from(aiKeys).sort()
|
||||||
|
|
||||||
|
const data = results.map((r) => {
|
||||||
|
// Flatten AI screening - take first rule result's values
|
||||||
|
const aiFlat: Record<string, unknown> = {}
|
||||||
|
if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') {
|
||||||
|
const screening = r.aiScreeningJson as Record<string, Record<string, unknown>>
|
||||||
|
const firstEntry = Object.values(screening)[0]
|
||||||
|
if (firstEntry && typeof firstEntry === 'object') {
|
||||||
|
for (const key of sortedAiKeys) {
|
||||||
|
const val = firstEntry[key]
|
||||||
|
aiFlat[`ai_${key}`] = val !== undefined ? String(val) : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectTitle: r.project.title,
|
||||||
|
teamName: r.project.teamName ?? '',
|
||||||
|
category: r.project.competitionCategory ?? '',
|
||||||
|
country: r.project.country ?? '',
|
||||||
|
oceanIssue: r.project.oceanIssue ?? '',
|
||||||
|
tags: r.project.tags.join(', '),
|
||||||
|
outcome: r.outcome,
|
||||||
|
finalOutcome: r.finalOutcome ?? '',
|
||||||
|
overrideReason: r.overrideReason ?? '',
|
||||||
|
...aiFlat,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build columns list
|
||||||
|
const baseColumns = [
|
||||||
|
'projectTitle',
|
||||||
|
'teamName',
|
||||||
|
'category',
|
||||||
|
'country',
|
||||||
|
'oceanIssue',
|
||||||
|
'tags',
|
||||||
|
'outcome',
|
||||||
|
'finalOutcome',
|
||||||
|
'overrideReason',
|
||||||
|
]
|
||||||
|
const aiColumns = sortedAiKeys.map((k) => `ai_${k}`)
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'EXPORT',
|
||||||
|
entityType: 'FilteringResult',
|
||||||
|
detailsJson: { roundId: input.roundId, count: data.length },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
columns: [...baseColumns, ...aiColumns],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export audit logs as CSV data
|
* Export audit logs as CSV data
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,14 @@ export const fileRouter = router({
|
||||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
// Find the file record to get the project
|
// Find the file record to get the project and round info
|
||||||
const file = await ctx.prisma.projectFile.findFirst({
|
const file = await ctx.prisma.projectFile.findFirst({
|
||||||
where: { bucket: input.bucket, objectKey: input.objectKey },
|
where: { bucket: input.bucket, objectKey: input.objectKey },
|
||||||
select: { projectId: true },
|
select: {
|
||||||
|
projectId: true,
|
||||||
|
roundId: true,
|
||||||
|
round: { select: { programId: true, sortOrder: true } },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|
@ -33,24 +37,55 @@ export const fileRouter = router({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is assigned as jury or mentor for this project
|
// Check if user is assigned as jury, mentor, or team member for this project
|
||||||
const [juryAssignment, mentorAssignment] = await Promise.all([
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
ctx.prisma.assignment.findFirst({
|
ctx.prisma.assignment.findFirst({
|
||||||
where: { userId: ctx.user.id, projectId: file.projectId },
|
where: { userId: ctx.user.id, projectId: file.projectId },
|
||||||
select: { id: true },
|
select: { id: true, roundId: true },
|
||||||
}),
|
}),
|
||||||
ctx.prisma.mentorAssignment.findFirst({
|
ctx.prisma.mentorAssignment.findFirst({
|
||||||
where: { mentorId: ctx.user.id, projectId: file.projectId },
|
where: { mentorId: ctx.user.id, projectId: file.projectId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
}),
|
}),
|
||||||
|
ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: file.projectId,
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!juryAssignment && !mentorAssignment) {
|
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'You do not have access to this file',
|
message: 'You do not have access to this file',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For jury members, verify round-scoped access:
|
||||||
|
// File must belong to the jury's assigned round or a prior round in the same program
|
||||||
|
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
|
||||||
|
const assignedRound = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: juryAssignment.roundId },
|
||||||
|
select: { programId: true, sortOrder: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignedRound) {
|
||||||
|
const sameProgram = assignedRound.programId === file.round.programId
|
||||||
|
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
|
||||||
|
|
||||||
|
if (!sameProgram || !priorOrSameRound) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You do not have access to this file',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
|
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
|
||||||
|
|
@ -189,12 +224,15 @@ export const fileRouter = router({
|
||||||
* Checks that the user is authorized to view the project's files
|
* Checks that the user is authorized to view the project's files
|
||||||
*/
|
*/
|
||||||
listByProject: protectedProcedure
|
listByProject: protectedProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
roundId: z.string().optional(),
|
||||||
|
}))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
const [juryAssignment, mentorAssignment] = await Promise.all([
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
ctx.prisma.assignment.findFirst({
|
ctx.prisma.assignment.findFirst({
|
||||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|
@ -203,9 +241,19 @@ export const fileRouter = router({
|
||||||
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
}),
|
}),
|
||||||
|
ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.projectId,
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!juryAssignment && !mentorAssignment) {
|
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'You do not have access to this project\'s files',
|
message: 'You do not have access to this project\'s files',
|
||||||
|
|
@ -213,9 +261,127 @@ export const fileRouter = router({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { projectId: input.projectId }
|
||||||
|
if (input.roundId) {
|
||||||
|
where.roundId = input.roundId
|
||||||
|
}
|
||||||
|
|
||||||
return ctx.prisma.projectFile.findMany({
|
return ctx.prisma.projectFile.findMany({
|
||||||
where: { projectId: input.projectId },
|
where,
|
||||||
|
include: {
|
||||||
|
round: { select: { id: true, name: true, sortOrder: true } },
|
||||||
|
},
|
||||||
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
|
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files for a project grouped by round
|
||||||
|
* Returns files for the specified round + all prior rounds in the same program
|
||||||
|
*/
|
||||||
|
listByProjectForRound: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
roundId: z.string(),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
|
ctx.prisma.assignment.findFirst({
|
||||||
|
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||||
|
select: { id: true, roundId: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.mentorAssignment.findFirst({
|
||||||
|
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.projectId,
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You do not have access to this project\'s files',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the target round with its program and sortOrder
|
||||||
|
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { programId: true, sortOrder: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all rounds in the same program with sortOrder <= target
|
||||||
|
const eligibleRounds = await ctx.prisma.round.findMany({
|
||||||
|
where: {
|
||||||
|
programId: targetRound.programId,
|
||||||
|
sortOrder: { lte: targetRound.sortOrder },
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, sortOrder: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
|
||||||
|
|
||||||
|
// Get files for these rounds (or files with no roundId)
|
||||||
|
const files = await ctx.prisma.projectFile.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
OR: [
|
||||||
|
{ roundId: { in: eligibleRoundIds } },
|
||||||
|
{ roundId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
round: { select: { id: true, name: true, sortOrder: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group by round
|
||||||
|
const grouped: Array<{
|
||||||
|
roundId: string | null
|
||||||
|
roundName: string
|
||||||
|
sortOrder: number
|
||||||
|
files: typeof files
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
// Add "General" group for files with no round
|
||||||
|
const generalFiles = files.filter((f) => !f.roundId)
|
||||||
|
if (generalFiles.length > 0) {
|
||||||
|
grouped.push({
|
||||||
|
roundId: null,
|
||||||
|
roundName: 'General',
|
||||||
|
sortOrder: -1,
|
||||||
|
files: generalFiles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add groups for each round
|
||||||
|
for (const round of eligibleRounds) {
|
||||||
|
const roundFiles = files.filter((f) => f.roundId === round.id)
|
||||||
|
if (roundFiles.length > 0) {
|
||||||
|
grouped.push({
|
||||||
|
roundId: round.id,
|
||||||
|
roundName: round.name,
|
||||||
|
sortOrder: round.sortOrder,
|
||||||
|
files: roundFiles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -635,6 +635,109 @@ export const mentorRouter = router({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the project team (mentor side)
|
||||||
|
*/
|
||||||
|
sendMessage: mentorProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
message: z.string().min(1).max(5000),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Verify the mentor is assigned to this project
|
||||||
|
const assignment = await ctx.prisma.mentorAssignment.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
mentorId: ctx.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||||
|
|
||||||
|
if (!assignment && !isAdmin) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You are not assigned to mentor this project',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentorMessage = await ctx.prisma.mentorMessage.create({
|
||||||
|
data: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
senderId: ctx.user.id,
|
||||||
|
message: input.message,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify project team members
|
||||||
|
await notifyProjectTeam(input.projectId, {
|
||||||
|
type: 'MENTOR_MESSAGE',
|
||||||
|
title: 'New Message from Mentor',
|
||||||
|
message: `${ctx.user.name || 'Your mentor'} sent you a message`,
|
||||||
|
linkUrl: `/my-submission/${input.projectId}`,
|
||||||
|
linkLabel: 'View Message',
|
||||||
|
priority: 'normal',
|
||||||
|
metadata: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return mentorMessage
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get messages for a project (mentor side)
|
||||||
|
*/
|
||||||
|
getMessages: mentorProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Verify the mentor is assigned to this project
|
||||||
|
const assignment = await ctx.prisma.mentorAssignment.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
mentorId: ctx.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||||
|
|
||||||
|
if (!assignment && !isAdmin) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You are not assigned to mentor this project',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await ctx.prisma.mentorMessage.findMany({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
select: { id: true, name: true, email: true, role: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark unread messages from the team as read
|
||||||
|
await ctx.prisma.mentorMessage.updateMany({
|
||||||
|
where: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
senderId: { not: ctx.user.id },
|
||||||
|
isRead: false,
|
||||||
|
},
|
||||||
|
data: { isRead: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all mentor assignments (admin)
|
* List all mentor assignments (admin)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,17 @@ export const projectRouter = router({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Record status change in history
|
||||||
|
if (status) {
|
||||||
|
await ctx.prisma.projectStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
projectId: id,
|
||||||
|
status,
|
||||||
|
changedBy: ctx.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Send notifications if status changed
|
// Send notifications if status changed
|
||||||
if (status) {
|
if (status) {
|
||||||
// Get round details for notification
|
// Get round details for notification
|
||||||
|
|
@ -648,6 +659,17 @@ export const projectRouter = router({
|
||||||
data: { status: input.status },
|
data: { status: input.status },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Record status change in history for each project
|
||||||
|
if (matchingIds.length > 0) {
|
||||||
|
await ctx.prisma.projectStatusHistory.createMany({
|
||||||
|
data: matchingIds.map((projectId) => ({
|
||||||
|
projectId,
|
||||||
|
status: input.status,
|
||||||
|
changedBy: ctx.user.id,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
|
|
|
||||||
|
|
@ -443,9 +443,25 @@ export const roundRouter = router({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
label: z.string().min(1),
|
label: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
scale: z.number().int().min(1).max(10),
|
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).default('numeric'),
|
||||||
|
// Numeric fields
|
||||||
|
scale: z.number().int().min(1).max(10).optional(),
|
||||||
weight: z.number().optional(),
|
weight: z.number().optional(),
|
||||||
required: z.boolean(),
|
required: z.boolean().optional(),
|
||||||
|
// Text fields
|
||||||
|
maxLength: z.number().int().min(1).max(10000).optional(),
|
||||||
|
placeholder: z.string().optional(),
|
||||||
|
// Boolean fields
|
||||||
|
trueLabel: z.string().optional(),
|
||||||
|
falseLabel: z.string().optional(),
|
||||||
|
// Conditional visibility
|
||||||
|
condition: z.object({
|
||||||
|
criterionId: z.string(),
|
||||||
|
operator: z.enum(['equals', 'greaterThan', 'lessThan']),
|
||||||
|
value: z.union([z.number(), z.string(), z.boolean()]),
|
||||||
|
}).optional(),
|
||||||
|
// Section grouping
|
||||||
|
sectionId: z.string().optional(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
/**
|
||||||
|
* AI-Powered Evaluation Summary Service
|
||||||
|
*
|
||||||
|
* Generates AI summaries of jury evaluations for a project in a given round.
|
||||||
|
* Combines OpenAI analysis with server-side scoring pattern calculations.
|
||||||
|
*
|
||||||
|
* GDPR Compliance:
|
||||||
|
* - All evaluation data is anonymized before AI processing
|
||||||
|
* - No juror names, emails, or identifiers are sent to OpenAI
|
||||||
|
* - Only scores, feedback text, and binary decisions are included
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { getOpenAI, getConfiguredModel, buildCompletionParams, AI_MODELS } from '@/lib/openai'
|
||||||
|
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||||
|
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||||
|
import { sanitizeText } from './anonymization'
|
||||||
|
import type { PrismaClient, Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EvaluationForSummary {
|
||||||
|
id: string
|
||||||
|
criterionScoresJson: Record<string, number> | null
|
||||||
|
globalScore: number | null
|
||||||
|
binaryDecision: boolean | null
|
||||||
|
feedbackText: string | null
|
||||||
|
assignment: {
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnonymizedEvaluation {
|
||||||
|
criterionScores: Record<string, number> | null
|
||||||
|
globalScore: number | null
|
||||||
|
binaryDecision: boolean | null
|
||||||
|
feedbackText: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CriterionDef {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIResponsePayload {
|
||||||
|
overallAssessment: string
|
||||||
|
strengths: string[]
|
||||||
|
weaknesses: string[]
|
||||||
|
themes: Array<{
|
||||||
|
theme: string
|
||||||
|
sentiment: 'positive' | 'negative' | 'mixed'
|
||||||
|
frequency: number
|
||||||
|
}>
|
||||||
|
recommendation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoringPatterns {
|
||||||
|
averageGlobalScore: number | null
|
||||||
|
consensus: number
|
||||||
|
criterionAverages: Record<string, number>
|
||||||
|
evaluatorCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvaluationSummaryResult {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
roundId: string
|
||||||
|
summaryJson: AIResponsePayload & { scoringPatterns: ScoringPatterns }
|
||||||
|
generatedAt: Date
|
||||||
|
model: string
|
||||||
|
tokensUsed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Anonymization ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip juror names/emails from evaluations, keeping only scores and feedback.
|
||||||
|
*/
|
||||||
|
export function anonymizeEvaluations(
|
||||||
|
evaluations: EvaluationForSummary[]
|
||||||
|
): AnonymizedEvaluation[] {
|
||||||
|
return evaluations.map((ev) => ({
|
||||||
|
criterionScores: ev.criterionScoresJson as Record<string, number> | null,
|
||||||
|
globalScore: ev.globalScore,
|
||||||
|
binaryDecision: ev.binaryDecision,
|
||||||
|
feedbackText: ev.feedbackText ? sanitizeText(ev.feedbackText) : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Prompt Building ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the OpenAI prompt for evaluation summary generation.
|
||||||
|
*/
|
||||||
|
export function buildSummaryPrompt(
|
||||||
|
anonymizedEvaluations: AnonymizedEvaluation[],
|
||||||
|
projectTitle: string,
|
||||||
|
criteriaLabels: string[]
|
||||||
|
): string {
|
||||||
|
const sanitizedTitle = sanitizeText(projectTitle)
|
||||||
|
|
||||||
|
return `You are analyzing jury evaluations for a project competition.
|
||||||
|
|
||||||
|
PROJECT: "${sanitizedTitle}"
|
||||||
|
|
||||||
|
EVALUATION CRITERIA: ${criteriaLabels.join(', ')}
|
||||||
|
|
||||||
|
EVALUATIONS (${anonymizedEvaluations.length} total):
|
||||||
|
${JSON.stringify(anonymizedEvaluations, null, 2)}
|
||||||
|
|
||||||
|
Analyze these evaluations and return a JSON object with this exact structure:
|
||||||
|
{
|
||||||
|
"overallAssessment": "A 2-3 sentence summary of how the project was evaluated overall",
|
||||||
|
"strengths": ["strength 1", "strength 2", ...],
|
||||||
|
"weaknesses": ["weakness 1", "weakness 2", ...],
|
||||||
|
"themes": [
|
||||||
|
{ "theme": "theme name", "sentiment": "positive" | "negative" | "mixed", "frequency": <number of evaluators mentioning this> }
|
||||||
|
],
|
||||||
|
"recommendation": "A brief recommendation based on the evaluation consensus"
|
||||||
|
}
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Base your analysis only on the provided evaluation data
|
||||||
|
- Identify common themes across evaluator feedback
|
||||||
|
- Note areas of agreement and disagreement
|
||||||
|
- Keep the assessment objective and balanced
|
||||||
|
- Do not include any personal identifiers`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scoring Patterns (Server-Side) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute scoring patterns from evaluations without AI.
|
||||||
|
*/
|
||||||
|
export function computeScoringPatterns(
|
||||||
|
evaluations: EvaluationForSummary[],
|
||||||
|
criteriaLabels: CriterionDef[]
|
||||||
|
): ScoringPatterns {
|
||||||
|
const globalScores = evaluations
|
||||||
|
.map((e) => e.globalScore)
|
||||||
|
.filter((s): s is number => s !== null)
|
||||||
|
|
||||||
|
// Average global score
|
||||||
|
const averageGlobalScore =
|
||||||
|
globalScores.length > 0
|
||||||
|
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Consensus: 1 - normalized standard deviation (1.0 = full consensus)
|
||||||
|
let consensus = 1
|
||||||
|
if (globalScores.length > 1 && averageGlobalScore !== null) {
|
||||||
|
const variance =
|
||||||
|
globalScores.reduce(
|
||||||
|
(sum, score) => sum + Math.pow(score - averageGlobalScore, 2),
|
||||||
|
0
|
||||||
|
) / globalScores.length
|
||||||
|
const stdDev = Math.sqrt(variance)
|
||||||
|
// Normalize by the scoring scale (1-10, so max possible std dev is ~4.5)
|
||||||
|
consensus = Math.max(0, 1 - stdDev / 4.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criterion averages
|
||||||
|
const criterionAverages: Record<string, number> = {}
|
||||||
|
for (const criterion of criteriaLabels) {
|
||||||
|
const scores: number[] = []
|
||||||
|
for (const ev of evaluations) {
|
||||||
|
const criterionScores = ev.criterionScoresJson as Record<string, number> | null
|
||||||
|
if (criterionScores && criterionScores[criterion.id] !== undefined) {
|
||||||
|
scores.push(criterionScores[criterion.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scores.length > 0) {
|
||||||
|
criterionAverages[criterion.label] =
|
||||||
|
scores.reduce((a, b) => a + b, 0) / scores.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
averageGlobalScore,
|
||||||
|
consensus: Math.round(consensus * 100) / 100,
|
||||||
|
criterionAverages,
|
||||||
|
evaluatorCount: evaluations.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Orchestrator ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an AI-powered evaluation summary for a project in a round.
|
||||||
|
*/
|
||||||
|
export async function generateSummary({
|
||||||
|
projectId,
|
||||||
|
roundId,
|
||||||
|
userId,
|
||||||
|
prisma,
|
||||||
|
}: {
|
||||||
|
projectId: string
|
||||||
|
roundId: string
|
||||||
|
userId: string
|
||||||
|
prisma: PrismaClient
|
||||||
|
}): Promise<EvaluationSummaryResult> {
|
||||||
|
// 1. Fetch project with evaluations and form criteria
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
roundId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch submitted evaluations for this project in this round
|
||||||
|
const evaluations = await prisma.evaluation.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
assignment: {
|
||||||
|
projectId,
|
||||||
|
roundId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
criterionScoresJson: true,
|
||||||
|
globalScore: true,
|
||||||
|
binaryDecision: true,
|
||||||
|
feedbackText: true,
|
||||||
|
assignment: {
|
||||||
|
select: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (evaluations.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'No submitted evaluations found for this project in this round',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get evaluation form criteria for this round
|
||||||
|
const form = await prisma.evaluationForm.findFirst({
|
||||||
|
where: { roundId, isActive: true },
|
||||||
|
select: { criteriaJson: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const criteria: CriterionDef[] = form?.criteriaJson
|
||||||
|
? (form.criteriaJson as unknown as CriterionDef[])
|
||||||
|
: []
|
||||||
|
const criteriaLabels = criteria.map((c) => c.label)
|
||||||
|
|
||||||
|
// 2. Anonymize evaluations
|
||||||
|
const typedEvaluations = evaluations as unknown as EvaluationForSummary[]
|
||||||
|
const anonymized = anonymizeEvaluations(typedEvaluations)
|
||||||
|
|
||||||
|
// 3. Build prompt and call OpenAI
|
||||||
|
const openai = await getOpenAI()
|
||||||
|
if (!openai) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'OpenAI is not configured. Please set up your API key in Settings.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await getConfiguredModel(AI_MODELS.QUICK)
|
||||||
|
const prompt = buildSummaryPrompt(anonymized, project.title, criteriaLabels)
|
||||||
|
|
||||||
|
let aiResponse: AIResponsePayload
|
||||||
|
let tokensUsed = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = buildCompletionParams(model, {
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: prompt },
|
||||||
|
],
|
||||||
|
jsonMode: true,
|
||||||
|
temperature: 0.3,
|
||||||
|
maxTokens: 2000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create(params)
|
||||||
|
const usage = extractTokenUsage(response)
|
||||||
|
tokensUsed = usage.totalTokens
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('Empty response from AI')
|
||||||
|
}
|
||||||
|
|
||||||
|
aiResponse = JSON.parse(content) as AIResponsePayload
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
const parseError = createParseError(error.message)
|
||||||
|
logAIError('EvaluationSummary', 'generateSummary', parseError)
|
||||||
|
|
||||||
|
await logAIUsage({
|
||||||
|
userId,
|
||||||
|
action: 'EVALUATION_SUMMARY',
|
||||||
|
entityType: 'Project',
|
||||||
|
entityId: projectId,
|
||||||
|
model,
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: tokensUsed,
|
||||||
|
itemsProcessed: 0,
|
||||||
|
status: 'ERROR',
|
||||||
|
errorMessage: parseError.message,
|
||||||
|
})
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'Failed to parse AI response. Please try again.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const classified = classifyAIError(error)
|
||||||
|
logAIError('EvaluationSummary', 'generateSummary', classified)
|
||||||
|
|
||||||
|
await logAIUsage({
|
||||||
|
userId,
|
||||||
|
action: 'EVALUATION_SUMMARY',
|
||||||
|
entityType: 'Project',
|
||||||
|
entityId: projectId,
|
||||||
|
model,
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
itemsProcessed: 0,
|
||||||
|
status: 'ERROR',
|
||||||
|
errorMessage: classified.message,
|
||||||
|
})
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: classified.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Compute scoring patterns (server-side, no AI)
|
||||||
|
const scoringPatterns = computeScoringPatterns(typedEvaluations, criteria)
|
||||||
|
|
||||||
|
// 5. Merge and upsert
|
||||||
|
const summaryJson = {
|
||||||
|
...aiResponse,
|
||||||
|
scoringPatterns,
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryJsonValue = summaryJson as unknown as Prisma.InputJsonValue
|
||||||
|
|
||||||
|
const summary = await prisma.evaluationSummary.upsert({
|
||||||
|
where: {
|
||||||
|
projectId_roundId: { projectId, roundId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
projectId,
|
||||||
|
roundId,
|
||||||
|
summaryJson: summaryJsonValue,
|
||||||
|
generatedById: userId,
|
||||||
|
model,
|
||||||
|
tokensUsed,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
summaryJson: summaryJsonValue,
|
||||||
|
generatedAt: new Date(),
|
||||||
|
generatedById: userId,
|
||||||
|
model,
|
||||||
|
tokensUsed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 6. Log AI usage
|
||||||
|
await logAIUsage({
|
||||||
|
userId,
|
||||||
|
action: 'EVALUATION_SUMMARY',
|
||||||
|
entityType: 'Project',
|
||||||
|
entityId: projectId,
|
||||||
|
model,
|
||||||
|
promptTokens: 0, // Detailed breakdown not always available
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: tokensUsed,
|
||||||
|
itemsProcessed: evaluations.length,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: summary.id,
|
||||||
|
projectId: summary.projectId,
|
||||||
|
roundId: summary.roundId,
|
||||||
|
summaryJson: summaryJson as AIResponsePayload & { scoringPatterns: ScoringPatterns },
|
||||||
|
generatedAt: summary.generatedAt,
|
||||||
|
model: summary.model,
|
||||||
|
tokensUsed: summary.tokensUsed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { sendStyledNotificationEmail } from '@/lib/email'
|
||||||
|
|
||||||
|
const REMINDER_TYPES = [
|
||||||
|
{ type: '3_DAYS', thresholdMs: 3 * 24 * 60 * 60 * 1000 },
|
||||||
|
{ type: '24H', thresholdMs: 24 * 60 * 60 * 1000 },
|
||||||
|
{ type: '1H', thresholdMs: 60 * 60 * 1000 },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type ReminderType = (typeof REMINDER_TYPES)[number]['type']
|
||||||
|
|
||||||
|
interface ReminderResult {
|
||||||
|
sent: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find active rounds with approaching voting deadlines and send reminders
|
||||||
|
* to jurors who have incomplete assignments.
|
||||||
|
*/
|
||||||
|
export async function processEvaluationReminders(roundId?: string): Promise<ReminderResult> {
|
||||||
|
const now = new Date()
|
||||||
|
let totalSent = 0
|
||||||
|
let totalErrors = 0
|
||||||
|
|
||||||
|
// Find active rounds with voting end dates in the future
|
||||||
|
const rounds = await prisma.round.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
votingEndAt: { gt: now },
|
||||||
|
votingStartAt: { lte: now },
|
||||||
|
...(roundId && { id: roundId }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
votingEndAt: true,
|
||||||
|
program: { select: { name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const round of rounds) {
|
||||||
|
if (!round.votingEndAt) continue
|
||||||
|
|
||||||
|
const msUntilDeadline = round.votingEndAt.getTime() - now.getTime()
|
||||||
|
|
||||||
|
// Determine which reminder types should fire for this round
|
||||||
|
const applicableTypes = REMINDER_TYPES.filter(
|
||||||
|
({ thresholdMs }) => msUntilDeadline <= thresholdMs
|
||||||
|
)
|
||||||
|
|
||||||
|
if (applicableTypes.length === 0) continue
|
||||||
|
|
||||||
|
for (const { type } of applicableTypes) {
|
||||||
|
const result = await sendRemindersForRound(round, type, now)
|
||||||
|
totalSent += result.sent
|
||||||
|
totalErrors += result.errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent: totalSent, errors: totalErrors }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRemindersForRound(
|
||||||
|
round: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
votingEndAt: Date | null
|
||||||
|
program: { name: string }
|
||||||
|
},
|
||||||
|
type: ReminderType,
|
||||||
|
now: Date
|
||||||
|
): Promise<ReminderResult> {
|
||||||
|
let sent = 0
|
||||||
|
let errors = 0
|
||||||
|
|
||||||
|
if (!round.votingEndAt) return { sent, errors }
|
||||||
|
|
||||||
|
// Find jurors with incomplete assignments for this round
|
||||||
|
const incompleteAssignments = await prisma.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: round.id,
|
||||||
|
isCompleted: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get unique user IDs with incomplete work
|
||||||
|
const userIds = [...new Set(incompleteAssignments.map((a) => a.userId))]
|
||||||
|
|
||||||
|
if (userIds.length === 0) return { sent, errors }
|
||||||
|
|
||||||
|
// Check which users already received this reminder type for this round
|
||||||
|
const existingReminders = await prisma.reminderLog.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: round.id,
|
||||||
|
type,
|
||||||
|
userId: { in: userIds },
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const alreadySent = new Set(existingReminders.map((r) => r.userId))
|
||||||
|
const usersToNotify = userIds.filter((id) => !alreadySent.has(id))
|
||||||
|
|
||||||
|
if (usersToNotify.length === 0) return { sent, errors }
|
||||||
|
|
||||||
|
// Get user details and their pending counts
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: usersToNotify } },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||||
|
const deadlineStr = round.votingEndAt.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZoneName: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map to get pending count per user
|
||||||
|
const pendingCounts = new Map<string, number>()
|
||||||
|
for (const a of incompleteAssignments) {
|
||||||
|
pendingCounts.set(a.userId, (pendingCounts.get(a.userId) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select email template type based on reminder type
|
||||||
|
const emailTemplateType = type === '1H' ? 'REMINDER_1H' : 'REMINDER_24H'
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const pendingCount = pendingCounts.get(user.id) || 0
|
||||||
|
if (pendingCount === 0) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendStyledNotificationEmail(
|
||||||
|
user.email,
|
||||||
|
user.name || '',
|
||||||
|
emailTemplateType,
|
||||||
|
{
|
||||||
|
name: user.name || undefined,
|
||||||
|
title: `Evaluation Reminder - ${round.name}`,
|
||||||
|
message: `You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${round.name}.`,
|
||||||
|
linkUrl: `${baseUrl}/jury/assignments?round=${round.id}`,
|
||||||
|
metadata: {
|
||||||
|
pendingCount,
|
||||||
|
roundName: round.name,
|
||||||
|
deadline: deadlineStr,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Log the sent reminder
|
||||||
|
await prisma.reminderLog.create({
|
||||||
|
data: {
|
||||||
|
roundId: round.id,
|
||||||
|
userId: user.id,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
sent++
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to send ${type} reminder to ${user.email} for round ${round.name}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent, errors }
|
||||||
|
}
|
||||||
|
|
@ -6,13 +6,18 @@
|
||||||
* - Bio/description match (text similarity)
|
* - Bio/description match (text similarity)
|
||||||
* - Workload balance
|
* - Workload balance
|
||||||
* - Country match (mentors only)
|
* - Country match (mentors only)
|
||||||
|
* - Geographic diversity penalty (prevents clustering by country)
|
||||||
|
* - Previous round familiarity bonus (continuity across rounds)
|
||||||
|
* - COI penalty (conflict of interest hard-block)
|
||||||
*
|
*
|
||||||
* Score Breakdown (100 points max):
|
* Score Breakdown:
|
||||||
* - Tag overlap: 0-40 points (weighted by confidence)
|
* - Tag overlap: 0-40 points (weighted by confidence)
|
||||||
* - Bio match: 0-15 points (if bio exists)
|
* - Bio match: 0-15 points (if bio exists)
|
||||||
* - Workload balance: 0-25 points
|
* - Workload balance: 0-25 points
|
||||||
* - Country match: 0-15 points (mentors only)
|
* - Country match: 0-15 points (mentors only)
|
||||||
* - Reserved: 0-5 points (future AI boost)
|
* - Geo diversity: -15 per excess same-country assignment (threshold: 2)
|
||||||
|
* - Previous round familiarity: +10 if reviewed in earlier round
|
||||||
|
* - COI: juror skipped entirely if conflict declared
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
@ -24,6 +29,9 @@ export interface ScoreBreakdown {
|
||||||
bioMatch: number
|
bioMatch: number
|
||||||
workloadBalance: number
|
workloadBalance: number
|
||||||
countryMatch: number
|
countryMatch: number
|
||||||
|
geoDiversityPenalty: number
|
||||||
|
previousRoundFamiliarity: number
|
||||||
|
coiPenalty: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignmentScore {
|
export interface AssignmentScore {
|
||||||
|
|
@ -52,6 +60,12 @@ const MAX_WORKLOAD_SCORE = 25
|
||||||
const MAX_COUNTRY_SCORE = 15
|
const MAX_COUNTRY_SCORE = 15
|
||||||
const POINTS_PER_TAG_MATCH = 8
|
const POINTS_PER_TAG_MATCH = 8
|
||||||
|
|
||||||
|
// New scoring factors
|
||||||
|
const GEO_DIVERSITY_THRESHOLD = 2
|
||||||
|
const GEO_DIVERSITY_PENALTY_PER_EXCESS = -15
|
||||||
|
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
|
||||||
|
// COI jurors are skipped entirely rather than penalized (effectively -Infinity)
|
||||||
|
|
||||||
// Common words to exclude from bio matching
|
// Common words to exclude from bio matching
|
||||||
const STOP_WORDS = new Set([
|
const STOP_WORDS = new Set([
|
||||||
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with',
|
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with',
|
||||||
|
|
@ -284,10 +298,68 @@ export async function getSmartSuggestions(options: {
|
||||||
existingAssignments.map((a) => `${a.userId}:${a.projectId}`)
|
existingAssignments.map((a) => `${a.userId}:${a.projectId}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Calculate target assignments per user
|
// ── Batch-query data for new scoring factors ──────────────────────────────
|
||||||
|
|
||||||
|
// 1. Geographic diversity: per-juror country distribution for existing assignments
|
||||||
|
const assignmentsWithCountry = await prisma.assignment.findMany({
|
||||||
|
where: { roundId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
project: { select: { country: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build map: userId -> { country -> count }
|
||||||
|
const userCountryDistribution = new Map<string, Map<string, number>>()
|
||||||
|
for (const a of assignmentsWithCountry) {
|
||||||
|
const country = a.project.country?.toLowerCase().trim()
|
||||||
|
if (!country) continue
|
||||||
|
let countryMap = userCountryDistribution.get(a.userId)
|
||||||
|
if (!countryMap) {
|
||||||
|
countryMap = new Map()
|
||||||
|
userCountryDistribution.set(a.userId, countryMap)
|
||||||
|
}
|
||||||
|
countryMap.set(country, (countryMap.get(country) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Previous round familiarity: find assignments in earlier rounds of the same program
|
||||||
|
const currentRound = await prisma.round.findUnique({
|
||||||
|
where: { id: roundId },
|
||||||
|
select: { programId: true, sortOrder: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const previousRoundAssignmentPairs = new Set<string>()
|
||||||
|
if (currentRound) {
|
||||||
|
const previousAssignments = await prisma.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
round: {
|
||||||
|
programId: currentRound.programId,
|
||||||
|
sortOrder: { lt: currentRound.sortOrder },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
for (const pa of previousAssignments) {
|
||||||
|
previousRoundAssignmentPairs.add(`${pa.userId}:${pa.projectId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. COI declarations: all active conflicts for this round
|
||||||
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||||
|
where: {
|
||||||
|
roundId,
|
||||||
|
hasConflict: true,
|
||||||
|
},
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
const coiPairs = new Set(
|
||||||
|
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Calculate target assignments per user ─────────────────────────────────
|
||||||
const targetPerUser = Math.ceil(projects.length / users.length)
|
const targetPerUser = Math.ceil(projects.length / users.length)
|
||||||
|
|
||||||
// Calculate scores for all user-project pairs
|
// ── Calculate scores for all user-project pairs ───────────────────────────
|
||||||
const suggestions: AssignmentScore[] = []
|
const suggestions: AssignmentScore[] = []
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
|
|
@ -304,6 +376,11 @@ export async function getSmartSuggestions(options: {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COI check - skip juror entirely for this project if COI declared
|
||||||
|
if (coiPairs.has(pairKey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Get project tags data
|
// Get project tags data
|
||||||
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
|
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
|
||||||
tagId: pt.tagId,
|
tagId: pt.tagId,
|
||||||
|
|
@ -311,13 +388,12 @@ export async function getSmartSuggestions(options: {
|
||||||
confidence: pt.confidence,
|
confidence: pt.confidence,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Calculate scores
|
// Calculate existing scores
|
||||||
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
|
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
|
||||||
user.expertiseTags,
|
user.expertiseTags,
|
||||||
projectTags
|
projectTags
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bio match (only if user has a bio)
|
|
||||||
const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
|
const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
|
||||||
user.bio,
|
user.bio,
|
||||||
project.description
|
project.description
|
||||||
|
|
@ -329,13 +405,39 @@ export async function getSmartSuggestions(options: {
|
||||||
user.maxAssignments
|
user.maxAssignments
|
||||||
)
|
)
|
||||||
|
|
||||||
// Country match only for mentors
|
|
||||||
const countryScore =
|
const countryScore =
|
||||||
type === 'mentor'
|
type === 'mentor'
|
||||||
? calculateCountryMatchScore(user.country, project.country)
|
? calculateCountryMatchScore(user.country, project.country)
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
const totalScore = tagScore + bioScore + workloadScore + countryScore
|
// ── New scoring factors ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Geographic diversity penalty
|
||||||
|
let geoDiversityPenalty = 0
|
||||||
|
const projectCountry = project.country?.toLowerCase().trim()
|
||||||
|
if (projectCountry) {
|
||||||
|
const countryMap = userCountryDistribution.get(user.id)
|
||||||
|
const sameCountryCount = countryMap?.get(projectCountry) || 0
|
||||||
|
if (sameCountryCount >= GEO_DIVERSITY_THRESHOLD) {
|
||||||
|
geoDiversityPenalty =
|
||||||
|
GEO_DIVERSITY_PENALTY_PER_EXCESS *
|
||||||
|
(sameCountryCount - GEO_DIVERSITY_THRESHOLD + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previous round familiarity bonus
|
||||||
|
let previousRoundFamiliarity = 0
|
||||||
|
if (previousRoundAssignmentPairs.has(pairKey)) {
|
||||||
|
previousRoundFamiliarity = PREVIOUS_ROUND_FAMILIARITY_BONUS
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalScore =
|
||||||
|
tagScore +
|
||||||
|
bioScore +
|
||||||
|
workloadScore +
|
||||||
|
countryScore +
|
||||||
|
geoDiversityPenalty +
|
||||||
|
previousRoundFamiliarity
|
||||||
|
|
||||||
// Build reasoning
|
// Build reasoning
|
||||||
const reasoning: string[] = []
|
const reasoning: string[] = []
|
||||||
|
|
@ -353,6 +455,12 @@ export async function getSmartSuggestions(options: {
|
||||||
if (countryScore > 0) {
|
if (countryScore > 0) {
|
||||||
reasoning.push('Same country')
|
reasoning.push('Same country')
|
||||||
}
|
}
|
||||||
|
if (geoDiversityPenalty < 0) {
|
||||||
|
reasoning.push(`Geo diversity penalty (${geoDiversityPenalty})`)
|
||||||
|
}
|
||||||
|
if (previousRoundFamiliarity > 0) {
|
||||||
|
reasoning.push('Reviewed in previous round (+10)')
|
||||||
|
}
|
||||||
|
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|
@ -366,6 +474,9 @@ export async function getSmartSuggestions(options: {
|
||||||
bioMatch: bioScore,
|
bioMatch: bioScore,
|
||||||
workloadBalance: workloadScore,
|
workloadBalance: workloadScore,
|
||||||
countryMatch: countryScore,
|
countryMatch: countryScore,
|
||||||
|
geoDiversityPenalty,
|
||||||
|
previousRoundFamiliarity,
|
||||||
|
coiPenalty: 0, // COI jurors are skipped entirely
|
||||||
},
|
},
|
||||||
reasoning,
|
reasoning,
|
||||||
matchingTags,
|
matchingTags,
|
||||||
|
|
@ -488,6 +599,9 @@ export async function getMentorSuggestionsForProject(
|
||||||
bioMatch: bioScore,
|
bioMatch: bioScore,
|
||||||
workloadBalance: workloadScore,
|
workloadBalance: workloadScore,
|
||||||
countryMatch: countryScore,
|
countryMatch: countryScore,
|
||||||
|
geoDiversityPenalty: 0,
|
||||||
|
previousRoundFamiliarity: 0,
|
||||||
|
coiPenalty: 0,
|
||||||
},
|
},
|
||||||
reasoning,
|
reasoning,
|
||||||
matchingTags,
|
matchingTags,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export type AIAction =
|
||||||
| 'AWARD_ELIGIBILITY'
|
| 'AWARD_ELIGIBILITY'
|
||||||
| 'MENTOR_MATCHING'
|
| 'MENTOR_MATCHING'
|
||||||
| 'PROJECT_TAGGING'
|
| 'PROJECT_TAGGING'
|
||||||
|
| 'EVALUATION_SUMMARY'
|
||||||
|
|
||||||
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue