Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -1,28 +1,28 @@
'use client'
import { motion } from 'motion/react'
import { type ReactNode } from 'react'
export function AnimatedCard({ children, index = 0 }: { children: ReactNode; index?: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
export function AnimatedList({ children }: { children: ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}
'use client'
import { motion } from 'motion/react'
import { type ReactNode } from 'react'
export function AnimatedCard({ children, index = 0 }: { children: ReactNode; index?: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
export function AnimatedList({ children }: { children: ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}

View File

@@ -1,215 +1,215 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Download, Loader2 } from 'lucide-react'
/**
* Converts a camelCase or snake_case column name to Title Case.
* e.g. "projectTitle" -> "Project Title", "ai_meetsCriteria" -> "Ai Meets Criteria"
*/
function formatColumnName(col: string): string {
// Replace underscores with spaces
let result = col.replace(/_/g, ' ')
// Insert space before uppercase letters (camelCase -> spaced)
result = result.replace(/([a-z])([A-Z])/g, '$1 $2')
// Capitalize first letter of each word
return result
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
type ExportData = {
data: Record<string, unknown>[]
columns: string[]
}
type CsvExportDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
exportData: ExportData | undefined
isLoading: boolean
filename: string
onRequestData: () => Promise<ExportData | undefined>
}
export function CsvExportDialog({
open,
onOpenChange,
exportData,
isLoading,
filename,
onRequestData,
}: CsvExportDialogProps) {
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set())
const [dataLoaded, setDataLoaded] = useState(false)
// When dialog opens, fetch data if not already loaded
useEffect(() => {
if (open && !dataLoaded) {
onRequestData().then((result) => {
if (result?.columns) {
setSelectedColumns(new Set(result.columns))
}
setDataLoaded(true)
})
}
}, [open, dataLoaded, onRequestData])
// Sync selected columns when export data changes
useEffect(() => {
if (exportData?.columns) {
setSelectedColumns(new Set(exportData.columns))
}
}, [exportData])
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setDataLoaded(false)
}
}, [open])
const toggleColumn = (col: string, checked: boolean) => {
const next = new Set(selectedColumns)
if (checked) {
next.add(col)
} else {
next.delete(col)
}
setSelectedColumns(next)
}
const toggleAll = () => {
if (!exportData) return
if (selectedColumns.size === exportData.columns.length) {
setSelectedColumns(new Set())
} else {
setSelectedColumns(new Set(exportData.columns))
}
}
const handleDownload = () => {
if (!exportData) return
const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col))
// Build CSV header with formatted names
const csvHeader = columnsArray.map((col) => {
const formatted = formatColumnName(col)
// Escape quotes in header
if (formatted.includes(',') || formatted.includes('"')) {
return `"${formatted.replace(/"/g, '""')}"`
}
return formatted
})
const csvContent = [
csvHeader.join(','),
...exportData.data.map((row) =>
columnsArray
.map((col) => {
const value = row[col]
if (value === null || value === undefined) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
})
.join(',')
),
].join('\n')
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// Delay revoking to ensure download starts before URL is invalidated
setTimeout(() => URL.revokeObjectURL(url), 1000)
onOpenChange(false)
}
const allSelected = exportData ? selectedColumns.size === exportData.columns.length : false
const noneSelected = selectedColumns.size === 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Export CSV</DialogTitle>
<DialogDescription>
Select which columns to include in the export
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading data...</span>
</div>
) : exportData ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
{selectedColumns.size} of {exportData.columns.length} columns selected
</Label>
<Button variant="ghost" size="sm" onClick={toggleAll}>
{allSelected ? 'Deselect all' : 'Select all'}
</Button>
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto rounded-lg border p-3">
{exportData.columns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}`}
checked={selectedColumns.has(col)}
onCheckedChange={(checked) => toggleColumn(col, !!checked)}
/>
<Label htmlFor={`col-${col}`} className="text-sm cursor-pointer font-normal">
{formatColumnName(col)}
</Label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
{exportData.data.length} row{exportData.data.length !== 1 ? 's' : ''} will be exported
</p>
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">
No data available for export.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleDownload}
disabled={isLoading || !exportData || noneSelected}
>
<Download className="mr-2 h-4 w-4" />
Download CSV
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Download, Loader2 } from 'lucide-react'
/**
* Converts a camelCase or snake_case column name to Title Case.
* e.g. "projectTitle" -> "Project Title", "ai_meetsCriteria" -> "Ai Meets Criteria"
*/
function formatColumnName(col: string): string {
// Replace underscores with spaces
let result = col.replace(/_/g, ' ')
// Insert space before uppercase letters (camelCase -> spaced)
result = result.replace(/([a-z])([A-Z])/g, '$1 $2')
// Capitalize first letter of each word
return result
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
type ExportData = {
data: Record<string, unknown>[]
columns: string[]
}
type CsvExportDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
exportData: ExportData | undefined
isLoading: boolean
filename: string
onRequestData: () => Promise<ExportData | undefined>
}
export function CsvExportDialog({
open,
onOpenChange,
exportData,
isLoading,
filename,
onRequestData,
}: CsvExportDialogProps) {
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set())
const [dataLoaded, setDataLoaded] = useState(false)
// When dialog opens, fetch data if not already loaded
useEffect(() => {
if (open && !dataLoaded) {
onRequestData().then((result) => {
if (result?.columns) {
setSelectedColumns(new Set(result.columns))
}
setDataLoaded(true)
})
}
}, [open, dataLoaded, onRequestData])
// Sync selected columns when export data changes
useEffect(() => {
if (exportData?.columns) {
setSelectedColumns(new Set(exportData.columns))
}
}, [exportData])
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setDataLoaded(false)
}
}, [open])
const toggleColumn = (col: string, checked: boolean) => {
const next = new Set(selectedColumns)
if (checked) {
next.add(col)
} else {
next.delete(col)
}
setSelectedColumns(next)
}
const toggleAll = () => {
if (!exportData) return
if (selectedColumns.size === exportData.columns.length) {
setSelectedColumns(new Set())
} else {
setSelectedColumns(new Set(exportData.columns))
}
}
const handleDownload = () => {
if (!exportData) return
const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col))
// Build CSV header with formatted names
const csvHeader = columnsArray.map((col) => {
const formatted = formatColumnName(col)
// Escape quotes in header
if (formatted.includes(',') || formatted.includes('"')) {
return `"${formatted.replace(/"/g, '""')}"`
}
return formatted
})
const csvContent = [
csvHeader.join(','),
...exportData.data.map((row) =>
columnsArray
.map((col) => {
const value = row[col]
if (value === null || value === undefined) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
})
.join(',')
),
].join('\n')
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// Delay revoking to ensure download starts before URL is invalidated
setTimeout(() => URL.revokeObjectURL(url), 1000)
onOpenChange(false)
}
const allSelected = exportData ? selectedColumns.size === exportData.columns.length : false
const noneSelected = selectedColumns.size === 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Export CSV</DialogTitle>
<DialogDescription>
Select which columns to include in the export
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading data...</span>
</div>
) : exportData ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
{selectedColumns.size} of {exportData.columns.length} columns selected
</Label>
<Button variant="ghost" size="sm" onClick={toggleAll}>
{allSelected ? 'Deselect all' : 'Select all'}
</Button>
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto rounded-lg border p-3">
{exportData.columns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}`}
checked={selectedColumns.has(col)}
onCheckedChange={(checked) => toggleColumn(col, !!checked)}
/>
<Label htmlFor={`col-${col}`} className="text-sm cursor-pointer font-normal">
{formatColumnName(col)}
</Label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
{exportData.data.length} row{exportData.data.length !== 1 ? 's' : ''} will be exported
</p>
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">
No data available for export.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleDownload}
disabled={isLoading || !exportData || noneSelected}
>
<Download className="mr-2 h-4 w-4" />
Download CSV
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,145 +1,145 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MessageSquare, Lock, Send, User } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Comment {
id: string
author: string
content: string
createdAt: string
}
interface DiscussionThreadProps {
comments: Comment[]
onAddComment?: (content: string) => void
isLocked?: boolean
maxLength?: number
isSubmitting?: boolean
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMinutes < 1) return 'just now'
if (diffMinutes < 60) return `${diffMinutes}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
export function DiscussionThread({
comments,
onAddComment,
isLocked = false,
maxLength = 2000,
isSubmitting = false,
}: DiscussionThreadProps) {
const [newComment, setNewComment] = useState('')
const handleSubmit = () => {
const trimmed = newComment.trim()
if (!trimmed || !onAddComment) return
onAddComment(trimmed)
setNewComment('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
handleSubmit()
}
}
return (
<div className="space-y-4">
{/* Locked banner */}
{isLocked && (
<div className="flex items-center gap-2 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<Lock className="h-4 w-4 shrink-0" />
Discussion is closed. No new comments can be added.
</div>
)}
{/* Comments list */}
{comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MessageSquare className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm font-medium text-muted-foreground">No comments yet</p>
<p className="text-xs text-muted-foreground">
Be the first to share your thoughts on this project.
</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<Card key={comment.id}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<User className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{comment.author}</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<p className="mt-1 text-sm whitespace-pre-wrap break-words">
{comment.content}
</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add comment form */}
{!isLocked && onAddComment && (
<div className="space-y-2">
<Textarea
placeholder="Add a comment... (Ctrl+Enter to send)"
value={newComment}
onChange={(e) => setNewComment(e.target.value.slice(0, maxLength))}
onKeyDown={handleKeyDown}
rows={3}
disabled={isSubmitting}
/>
<div className="flex items-center justify-between">
<span
className={cn(
'text-xs',
newComment.length > maxLength * 0.9
? 'text-destructive'
: 'text-muted-foreground'
)}
>
{newComment.length}/{maxLength}
</span>
<Button
size="sm"
onClick={handleSubmit}
disabled={!newComment.trim() || isSubmitting}
>
<Send className="mr-2 h-4 w-4" />
{isSubmitting ? 'Sending...' : 'Comment'}
</Button>
</div>
</div>
)}
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MessageSquare, Lock, Send, User } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Comment {
id: string
author: string
content: string
createdAt: string
}
interface DiscussionThreadProps {
comments: Comment[]
onAddComment?: (content: string) => void
isLocked?: boolean
maxLength?: number
isSubmitting?: boolean
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMinutes < 1) return 'just now'
if (diffMinutes < 60) return `${diffMinutes}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
export function DiscussionThread({
comments,
onAddComment,
isLocked = false,
maxLength = 2000,
isSubmitting = false,
}: DiscussionThreadProps) {
const [newComment, setNewComment] = useState('')
const handleSubmit = () => {
const trimmed = newComment.trim()
if (!trimmed || !onAddComment) return
onAddComment(trimmed)
setNewComment('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
handleSubmit()
}
}
return (
<div className="space-y-4">
{/* Locked banner */}
{isLocked && (
<div className="flex items-center gap-2 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<Lock className="h-4 w-4 shrink-0" />
Discussion is closed. No new comments can be added.
</div>
)}
{/* Comments list */}
{comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MessageSquare className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm font-medium text-muted-foreground">No comments yet</p>
<p className="text-xs text-muted-foreground">
Be the first to share your thoughts on this project.
</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<Card key={comment.id}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<User className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{comment.author}</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<p className="mt-1 text-sm whitespace-pre-wrap break-words">
{comment.content}
</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add comment form */}
{!isLocked && onAddComment && (
<div className="space-y-2">
<Textarea
placeholder="Add a comment... (Ctrl+Enter to send)"
value={newComment}
onChange={(e) => setNewComment(e.target.value.slice(0, maxLength))}
onKeyDown={handleKeyDown}
rows={3}
disabled={isSubmitting}
/>
<div className="flex items-center justify-between">
<span
className={cn(
'text-xs',
newComment.length > maxLength * 0.9
? 'text-destructive'
: 'text-muted-foreground'
)}
>
{newComment.length}/{maxLength}
</span>
<Button
size="sm"
onClick={handleSubmit}
disabled={!newComment.trim() || isSubmitting}
>
<Send className="mr-2 h-4 w-4" />
{isSubmitting ? 'Sending...' : 'Comment'}
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,51 +1,51 @@
import { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: {
label: string
href?: string
onClick?: () => void
}
className?: string
}
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-12 text-center',
className
)}
>
<div className="rounded-2xl bg-muted/60 p-4">
<Icon className="h-8 w-8 text-muted-foreground/70" />
</div>
<h3 className="mt-4 font-medium">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{description}
</p>
)}
{action && (
<Button
className="mt-4"
onClick={action.onClick}
asChild={!!action.href}
>
{action.href ? <a href={action.href}>{action.label}</a> : action.label}
</Button>
)}
</div>
)
}
import { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: {
label: string
href?: string
onClick?: () => void
}
className?: string
}
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-12 text-center',
className
)}
>
<div className="rounded-2xl bg-muted/60 p-4">
<Icon className="h-8 w-8 text-muted-foreground/70" />
</div>
<h3 className="mt-4 font-medium">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{description}
</p>
)}
{action && (
<Button
className="mt-4"
onClick={action.onClick}
asChild={!!action.href}
>
{action.href ? <a href={action.href}>{action.label}</a> : action.label}
</Button>
)}
</div>
)
}

View File

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

View File

@@ -1,103 +1,103 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Eye, Download, FileText, Image as ImageIcon, Video, File } from 'lucide-react'
interface FilePreviewProps {
fileName: string
mimeType: string
downloadUrl: string
}
function getPreviewType(mimeType: string): 'pdf' | 'image' | 'video' | 'unsupported' {
if (mimeType === 'application/pdf') return 'pdf'
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('video/')) return 'video'
return 'unsupported'
}
function getFileIcon(mimeType: string) {
if (mimeType === 'application/pdf') return FileText
if (mimeType.startsWith('image/')) return ImageIcon
if (mimeType.startsWith('video/')) return Video
return File
}
export function FilePreview({ fileName, mimeType, downloadUrl }: FilePreviewProps) {
const [open, setOpen] = useState(false)
const previewType = getPreviewType(mimeType)
const Icon = getFileIcon(mimeType)
const canPreview = previewType !== 'unsupported'
if (!canPreview) {
return (
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate">
<Icon className="h-4 w-4 shrink-0" />
{fileName}
</DialogTitle>
</DialogHeader>
<div className="overflow-auto">
{previewType === 'pdf' && (
<iframe
src={`${downloadUrl}#toolbar=0`}
className="w-full h-[70vh] rounded-md"
title={fileName}
/>
)}
{previewType === 'image' && (
<img
src={downloadUrl}
alt={fileName}
className="w-full h-auto max-h-[70vh] object-contain rounded-md"
/>
)}
{previewType === 'video' && (
<video
src={downloadUrl}
controls
className="w-full max-h-[70vh] rounded-md"
preload="metadata"
>
Your browser does not support the video tag.
</video>
)}
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
</div>
</DialogContent>
</Dialog>
)
}
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Eye, Download, FileText, Image as ImageIcon, Video, File } from 'lucide-react'
interface FilePreviewProps {
fileName: string
mimeType: string
downloadUrl: string
}
function getPreviewType(mimeType: string): 'pdf' | 'image' | 'video' | 'unsupported' {
if (mimeType === 'application/pdf') return 'pdf'
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('video/')) return 'video'
return 'unsupported'
}
function getFileIcon(mimeType: string) {
if (mimeType === 'application/pdf') return FileText
if (mimeType.startsWith('image/')) return ImageIcon
if (mimeType.startsWith('video/')) return Video
return File
}
export function FilePreview({ fileName, mimeType, downloadUrl }: FilePreviewProps) {
const [open, setOpen] = useState(false)
const previewType = getPreviewType(mimeType)
const Icon = getFileIcon(mimeType)
const canPreview = previewType !== 'unsupported'
if (!canPreview) {
return (
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate">
<Icon className="h-4 w-4 shrink-0" />
{fileName}
</DialogTitle>
</DialogHeader>
<div className="overflow-auto">
{previewType === 'pdf' && (
<iframe
src={`${downloadUrl}#toolbar=0`}
className="w-full h-[70vh] rounded-md"
title={fileName}
/>
)}
{previewType === 'image' && (
<img
src={downloadUrl}
alt={fileName}
className="w-full h-auto max-h-[70vh] object-contain rounded-md"
/>
)}
{previewType === 'video' && (
<video
src={downloadUrl}
controls
className="w-full max-h-[70vh] rounded-md"
preload="metadata"
>
Your browser does not support the video tag.
</video>
)}
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,493 +1,493 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Upload,
X,
FileIcon,
CheckCircle2,
AlertCircle,
Loader2,
Film,
FileText,
Presentation,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB
type FileType = 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER'
interface UploadingFile {
id: string
file: File
progress: number
status: 'pending' | 'uploading' | 'complete' | 'error'
error?: string
fileType: FileType
dbFileId?: string
}
interface FileUploadProps {
projectId: string
onUploadComplete?: (file: { id: string; fileName: string; fileType: string }) => void
onUploadError?: (error: Error) => void
allowedTypes?: string[]
multiple?: boolean
className?: string
stageId?: string
availableStages?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
function suggestFileType(mimeType: string): FileType {
if (mimeType.startsWith('video/')) return 'VIDEO'
if (mimeType === 'application/pdf') return 'EXEC_SUMMARY'
if (
mimeType.includes('presentation') ||
mimeType.includes('powerpoint') ||
mimeType.includes('slides')
) {
return 'PRESENTATION'
}
return 'OTHER'
}
// Get icon for file type
function getFileTypeIcon(fileType: FileType) {
switch (fileType) {
case 'VIDEO':
return <Film className="h-4 w-4" />
case 'EXEC_SUMMARY':
return <FileText className="h-4 w-4" />
case 'PRESENTATION':
return <Presentation className="h-4 w-4" />
default:
return <FileIcon className="h-4 w-4" />
}
}
export function FileUpload({
projectId,
onUploadComplete,
onUploadError,
allowedTypes,
multiple = true,
className,
stageId,
availableStages,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedStageId, setSelectedStageId] = useState<string | null>(stageId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
const confirmUpload = trpc.file.confirmUpload.useMutation()
const utils = trpc.useUtils()
// Validate file
const validateFile = useCallback(
(file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File size exceeds ${formatFileSize(MAX_FILE_SIZE)} limit`
}
if (allowedTypes && !allowedTypes.includes(file.type)) {
return `File type ${file.type} is not allowed`
}
return null
},
[allowedTypes]
)
// Upload a single file
const uploadFile = useCallback(
async (uploadingFile: UploadingFile) => {
const { file, id, fileType } = uploadingFile
try {
// Update status to uploading
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, status: 'uploading' as const } : f))
)
// Get pre-signed upload URL
const { uploadUrl, file: dbFile } = await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
stageId: selectedStageId ?? undefined,
})
// Store the DB file ID
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, dbFileId: dbFile.id } : f))
)
// Upload to MinIO using XHR for progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100)
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, progress } : f))
)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'))
})
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'))
})
xhr.open('PUT', uploadUrl)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.send(file)
})
// Confirm upload
await confirmUpload.mutateAsync({ fileId: dbFile.id })
// Update status to complete
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'complete' as const, progress: 100 } : f
)
)
// Invalidate file list queries
utils.file.listByProject.invalidate({ projectId })
// Notify parent
onUploadComplete?.({
id: dbFile.id,
fileName: file.name,
fileType,
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Upload failed'
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'error' as const, error: errorMessage } : f
)
)
onUploadError?.(error instanceof Error ? error : new Error(errorMessage))
}
},
[projectId, getUploadUrl, confirmUpload, utils, onUploadComplete, onUploadError]
)
// Handle file selection
const handleFiles = useCallback(
(files: FileList | File[]) => {
const fileArray = Array.from(files)
const filesToUpload = multiple ? fileArray : [fileArray[0]].filter(Boolean)
const newUploadingFiles: UploadingFile[] = filesToUpload.map((file) => {
const validationError = validateFile(file)
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
progress: 0,
status: validationError ? ('error' as const) : ('pending' as const),
error: validationError || undefined,
fileType: suggestFileType(file.type),
}
})
setUploadingFiles((prev) => [...prev, ...newUploadingFiles])
// Start uploading valid files
newUploadingFiles
.filter((f) => f.status === 'pending')
.forEach((f) => uploadFile(f))
},
[multiple, validateFile, uploadFile]
)
// Drag and drop handlers
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files?.length) {
handleFiles(e.dataTransfer.files)
}
},
[handleFiles]
)
// File input change handler
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
handleFiles(e.target.files)
}
// Reset input so same file can be selected again
e.target.value = ''
},
[handleFiles]
)
// Update file type for a pending file
const updateFileType = useCallback((fileId: string, fileType: FileType) => {
setUploadingFiles((prev) =>
prev.map((f) => (f.id === fileId ? { ...f, fileType } : f))
)
}, [])
// Remove a file from the queue
const removeFile = useCallback((fileId: string) => {
setUploadingFiles((prev) => prev.filter((f) => f.id !== fileId))
}, [])
// Retry a failed upload
const retryUpload = useCallback(
(fileId: string) => {
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === fileId
? { ...f, status: 'pending' as const, progress: 0, error: undefined }
: f
)
)
const file = uploadingFiles.find((f) => f.id === fileId)
if (file) {
uploadFile({ ...file, status: 'pending', progress: 0, error: undefined })
}
},
[uploadingFiles, uploadFile]
)
const hasActiveUploads = uploadingFiles.some(
(f) => f.status === 'pending' || f.status === 'uploading'
)
return (
<div className={cn('space-y-4', className)}>
{/* Stage selector */}
{availableStages && availableStages.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Stage
</label>
<Select
value={selectedStageId ?? 'null'}
onValueChange={(value) => setSelectedStageId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific stage)</SelectItem>
{availableStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Drop zone */}
<div
className={cn(
'relative border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50',
hasActiveUploads && 'opacity-50 pointer-events-none'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple={multiple}
accept={allowedTypes?.join(',')}
onChange={handleFileInputChange}
className="hidden"
/>
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-2 font-medium">
{isDragging ? 'Drop files here' : 'Drag and drop files here'}
</p>
<p className="text-sm text-muted-foreground">
or click to browse (max {formatFileSize(MAX_FILE_SIZE)})
</p>
</div>
{/* Upload queue */}
{uploadingFiles.length > 0 && (
<div className="space-y-2">
{uploadingFiles.map((uploadingFile) => (
<div
key={uploadingFile.id}
className={cn(
'flex items-center gap-3 rounded-lg border p-3',
uploadingFile.status === 'error' && 'border-destructive/50 bg-destructive/5',
uploadingFile.status === 'complete' && 'border-green-500/50 bg-green-500/5'
)}
>
{/* File type icon */}
<div className="shrink-0 text-muted-foreground">
{getFileTypeIcon(uploadingFile.fileType)}
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{uploadingFile.file.name}
</p>
<Badge variant="outline" className="shrink-0 text-xs">
{formatFileSize(uploadingFile.file.size)}
</Badge>
</div>
{/* Progress bar or error message */}
{uploadingFile.status === 'uploading' && (
<div className="mt-1 flex items-center gap-2">
<Progress value={uploadingFile.progress} className="h-1.5 flex-1" />
<span className="text-xs text-muted-foreground">
{uploadingFile.progress}%
</span>
</div>
)}
{uploadingFile.status === 'error' && (
<p className="mt-1 text-xs text-destructive">{uploadingFile.error}</p>
)}
{uploadingFile.status === 'pending' && (
<div className="mt-1 flex items-center gap-2">
<Select
value={uploadingFile.fileType}
onValueChange={(v) => updateFileType(uploadingFile.id, v as FileType)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EXEC_SUMMARY">Executive Summary</SelectItem>
<SelectItem value="PRESENTATION">Presentation</SelectItem>
<SelectItem value="VIDEO">Video</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Status / Actions */}
<div className="shrink-0">
{uploadingFile.status === 'pending' && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{uploadingFile.status === 'uploading' && (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
)}
{uploadingFile.status === 'complete' && (
<CheckCircle2 className="h-4 w-4 text-green-600" />
)}
{uploadingFile.status === 'error' && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation()
retryUpload(uploadingFile.id)
}}
>
Retry
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Remove file"
onClick={(e) => {
e.stopPropagation()
removeFile(uploadingFile.id)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
))}
{/* Clear completed */}
{uploadingFiles.some((f) => f.status === 'complete') && (
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() =>
setUploadingFiles((prev) => prev.filter((f) => f.status !== 'complete'))
}
>
Clear completed
</Button>
)}
</div>
)}
</div>
)
}
'use client'
import { useState, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Upload,
X,
FileIcon,
CheckCircle2,
AlertCircle,
Loader2,
Film,
FileText,
Presentation,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB
type FileType = 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER'
interface UploadingFile {
id: string
file: File
progress: number
status: 'pending' | 'uploading' | 'complete' | 'error'
error?: string
fileType: FileType
dbFileId?: string
}
interface FileUploadProps {
projectId: string
onUploadComplete?: (file: { id: string; fileName: string; fileType: string }) => void
onUploadError?: (error: Error) => void
allowedTypes?: string[]
multiple?: boolean
className?: string
stageId?: string
availableStages?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
function suggestFileType(mimeType: string): FileType {
if (mimeType.startsWith('video/')) return 'VIDEO'
if (mimeType === 'application/pdf') return 'EXEC_SUMMARY'
if (
mimeType.includes('presentation') ||
mimeType.includes('powerpoint') ||
mimeType.includes('slides')
) {
return 'PRESENTATION'
}
return 'OTHER'
}
// Get icon for file type
function getFileTypeIcon(fileType: FileType) {
switch (fileType) {
case 'VIDEO':
return <Film className="h-4 w-4" />
case 'EXEC_SUMMARY':
return <FileText className="h-4 w-4" />
case 'PRESENTATION':
return <Presentation className="h-4 w-4" />
default:
return <FileIcon className="h-4 w-4" />
}
}
export function FileUpload({
projectId,
onUploadComplete,
onUploadError,
allowedTypes,
multiple = true,
className,
stageId,
availableStages,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedStageId, setSelectedStageId] = useState<string | null>(stageId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
const confirmUpload = trpc.file.confirmUpload.useMutation()
const utils = trpc.useUtils()
// Validate file
const validateFile = useCallback(
(file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File size exceeds ${formatFileSize(MAX_FILE_SIZE)} limit`
}
if (allowedTypes && !allowedTypes.includes(file.type)) {
return `File type ${file.type} is not allowed`
}
return null
},
[allowedTypes]
)
// Upload a single file
const uploadFile = useCallback(
async (uploadingFile: UploadingFile) => {
const { file, id, fileType } = uploadingFile
try {
// Update status to uploading
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, status: 'uploading' as const } : f))
)
// Get pre-signed upload URL
const { uploadUrl, file: dbFile } = await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
stageId: selectedStageId ?? undefined,
})
// Store the DB file ID
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, dbFileId: dbFile.id } : f))
)
// Upload to MinIO using XHR for progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100)
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, progress } : f))
)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'))
})
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'))
})
xhr.open('PUT', uploadUrl)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.send(file)
})
// Confirm upload
await confirmUpload.mutateAsync({ fileId: dbFile.id })
// Update status to complete
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'complete' as const, progress: 100 } : f
)
)
// Invalidate file list queries
utils.file.listByProject.invalidate({ projectId })
// Notify parent
onUploadComplete?.({
id: dbFile.id,
fileName: file.name,
fileType,
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Upload failed'
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'error' as const, error: errorMessage } : f
)
)
onUploadError?.(error instanceof Error ? error : new Error(errorMessage))
}
},
[projectId, getUploadUrl, confirmUpload, utils, onUploadComplete, onUploadError]
)
// Handle file selection
const handleFiles = useCallback(
(files: FileList | File[]) => {
const fileArray = Array.from(files)
const filesToUpload = multiple ? fileArray : [fileArray[0]].filter(Boolean)
const newUploadingFiles: UploadingFile[] = filesToUpload.map((file) => {
const validationError = validateFile(file)
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
progress: 0,
status: validationError ? ('error' as const) : ('pending' as const),
error: validationError || undefined,
fileType: suggestFileType(file.type),
}
})
setUploadingFiles((prev) => [...prev, ...newUploadingFiles])
// Start uploading valid files
newUploadingFiles
.filter((f) => f.status === 'pending')
.forEach((f) => uploadFile(f))
},
[multiple, validateFile, uploadFile]
)
// Drag and drop handlers
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files?.length) {
handleFiles(e.dataTransfer.files)
}
},
[handleFiles]
)
// File input change handler
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
handleFiles(e.target.files)
}
// Reset input so same file can be selected again
e.target.value = ''
},
[handleFiles]
)
// Update file type for a pending file
const updateFileType = useCallback((fileId: string, fileType: FileType) => {
setUploadingFiles((prev) =>
prev.map((f) => (f.id === fileId ? { ...f, fileType } : f))
)
}, [])
// Remove a file from the queue
const removeFile = useCallback((fileId: string) => {
setUploadingFiles((prev) => prev.filter((f) => f.id !== fileId))
}, [])
// Retry a failed upload
const retryUpload = useCallback(
(fileId: string) => {
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === fileId
? { ...f, status: 'pending' as const, progress: 0, error: undefined }
: f
)
)
const file = uploadingFiles.find((f) => f.id === fileId)
if (file) {
uploadFile({ ...file, status: 'pending', progress: 0, error: undefined })
}
},
[uploadingFiles, uploadFile]
)
const hasActiveUploads = uploadingFiles.some(
(f) => f.status === 'pending' || f.status === 'uploading'
)
return (
<div className={cn('space-y-4', className)}>
{/* Stage selector */}
{availableStages && availableStages.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Stage
</label>
<Select
value={selectedStageId ?? 'null'}
onValueChange={(value) => setSelectedStageId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific stage)</SelectItem>
{availableStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Drop zone */}
<div
className={cn(
'relative border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50',
hasActiveUploads && 'opacity-50 pointer-events-none'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple={multiple}
accept={allowedTypes?.join(',')}
onChange={handleFileInputChange}
className="hidden"
/>
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-2 font-medium">
{isDragging ? 'Drop files here' : 'Drag and drop files here'}
</p>
<p className="text-sm text-muted-foreground">
or click to browse (max {formatFileSize(MAX_FILE_SIZE)})
</p>
</div>
{/* Upload queue */}
{uploadingFiles.length > 0 && (
<div className="space-y-2">
{uploadingFiles.map((uploadingFile) => (
<div
key={uploadingFile.id}
className={cn(
'flex items-center gap-3 rounded-lg border p-3',
uploadingFile.status === 'error' && 'border-destructive/50 bg-destructive/5',
uploadingFile.status === 'complete' && 'border-green-500/50 bg-green-500/5'
)}
>
{/* File type icon */}
<div className="shrink-0 text-muted-foreground">
{getFileTypeIcon(uploadingFile.fileType)}
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{uploadingFile.file.name}
</p>
<Badge variant="outline" className="shrink-0 text-xs">
{formatFileSize(uploadingFile.file.size)}
</Badge>
</div>
{/* Progress bar or error message */}
{uploadingFile.status === 'uploading' && (
<div className="mt-1 flex items-center gap-2">
<Progress value={uploadingFile.progress} className="h-1.5 flex-1" />
<span className="text-xs text-muted-foreground">
{uploadingFile.progress}%
</span>
</div>
)}
{uploadingFile.status === 'error' && (
<p className="mt-1 text-xs text-destructive">{uploadingFile.error}</p>
)}
{uploadingFile.status === 'pending' && (
<div className="mt-1 flex items-center gap-2">
<Select
value={uploadingFile.fileType}
onValueChange={(v) => updateFileType(uploadingFile.id, v as FileType)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EXEC_SUMMARY">Executive Summary</SelectItem>
<SelectItem value="PRESENTATION">Presentation</SelectItem>
<SelectItem value="VIDEO">Video</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Status / Actions */}
<div className="shrink-0">
{uploadingFile.status === 'pending' && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{uploadingFile.status === 'uploading' && (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
)}
{uploadingFile.status === 'complete' && (
<CheckCircle2 className="h-4 w-4 text-green-600" />
)}
{uploadingFile.status === 'error' && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation()
retryUpload(uploadingFile.id)
}}
>
Retry
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Remove file"
onClick={(e) => {
e.stopPropagation()
removeFile(uploadingFile.id)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
))}
{/* Clear completed */}
{uploadingFiles.some((f) => f.status === 'complete') && (
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() =>
setUploadingFiles((prev) => prev.filter((f) => f.status !== 'complete'))
}
>
Clear completed
</Button>
)}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,173 +1,173 @@
'use client'
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
interface LiveScoreAnimationProps {
score: number | null
maxScore: number
label: string
animate?: boolean
theme?: 'dark' | 'light' | 'branded'
}
function getScoreColor(score: number, maxScore: number): string {
const ratio = score / maxScore
if (ratio >= 0.75) return 'text-green-500'
if (ratio >= 0.5) return 'text-yellow-500'
if (ratio >= 0.25) return 'text-orange-500'
return 'text-red-500'
}
function getProgressColor(score: number, maxScore: number): string {
const ratio = score / maxScore
if (ratio >= 0.75) return 'stroke-green-500'
if (ratio >= 0.5) return 'stroke-yellow-500'
if (ratio >= 0.25) return 'stroke-orange-500'
return 'stroke-red-500'
}
function getThemeClasses(theme: 'dark' | 'light' | 'branded') {
switch (theme) {
case 'dark':
return {
bg: 'bg-gray-900',
text: 'text-white',
label: 'text-gray-400',
ring: 'stroke-gray-700',
}
case 'light':
return {
bg: 'bg-white',
text: 'text-gray-900',
label: 'text-gray-500',
ring: 'stroke-gray-200',
}
case 'branded':
return {
bg: 'bg-[#053d57]',
text: 'text-white',
label: 'text-[#557f8c]',
ring: 'stroke-[#053d57]/30',
}
}
}
export function LiveScoreAnimation({
score,
maxScore,
label,
animate = true,
theme = 'branded',
}: LiveScoreAnimationProps) {
const [displayScore, setDisplayScore] = useState(0)
const themeClasses = getThemeClasses(theme)
const radius = 40
const circumference = 2 * Math.PI * radius
const targetScore = score ?? 0
const progress = maxScore > 0 ? targetScore / maxScore : 0
const offset = circumference - progress * circumference
useEffect(() => {
if (!animate || score === null) {
setDisplayScore(targetScore)
return
}
let frame: number
const duration = 1200
const startTime = performance.now()
const startScore = 0
const step = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3)
const current = startScore + (targetScore - startScore) * eased
setDisplayScore(Math.round(current * 10) / 10)
if (progress < 1) {
frame = requestAnimationFrame(step)
}
}
frame = requestAnimationFrame(step)
return () => cancelAnimationFrame(frame)
}, [targetScore, animate, score])
if (score === null) {
return (
<div className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}>
<div className="relative h-24 w-24">
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r={radius}
fill="none"
className={themeClasses.ring}
strokeWidth="6"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className={cn('text-lg font-medium', themeClasses.label)}>--</span>
</div>
</div>
<span className={cn('text-xs font-medium', themeClasses.label)}>{label}</span>
</div>
)
}
return (
<AnimatePresence>
<motion.div
initial={animate ? { opacity: 0, scale: 0.8 } : false}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}
>
<div className="relative h-24 w-24">
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
{/* Background ring */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
className={themeClasses.ring}
strokeWidth="6"
/>
{/* Progress ring */}
<motion.circle
cx="50"
cy="50"
r={radius}
fill="none"
className={getProgressColor(targetScore, maxScore)}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={circumference}
initial={animate ? { strokeDashoffset: circumference } : { strokeDashoffset: offset }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 1.2, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span
className={cn(
'text-2xl font-bold tabular-nums',
themeClasses.text,
getScoreColor(targetScore, maxScore)
)}
>
{displayScore.toFixed(maxScore % 1 !== 0 ? 1 : 0)}
</span>
</div>
</div>
<span className={cn('text-xs font-medium text-center', themeClasses.label)}>{label}</span>
</motion.div>
</AnimatePresence>
)
}
'use client'
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
interface LiveScoreAnimationProps {
score: number | null
maxScore: number
label: string
animate?: boolean
theme?: 'dark' | 'light' | 'branded'
}
function getScoreColor(score: number, maxScore: number): string {
const ratio = score / maxScore
if (ratio >= 0.75) return 'text-green-500'
if (ratio >= 0.5) return 'text-yellow-500'
if (ratio >= 0.25) return 'text-orange-500'
return 'text-red-500'
}
function getProgressColor(score: number, maxScore: number): string {
const ratio = score / maxScore
if (ratio >= 0.75) return 'stroke-green-500'
if (ratio >= 0.5) return 'stroke-yellow-500'
if (ratio >= 0.25) return 'stroke-orange-500'
return 'stroke-red-500'
}
function getThemeClasses(theme: 'dark' | 'light' | 'branded') {
switch (theme) {
case 'dark':
return {
bg: 'bg-gray-900',
text: 'text-white',
label: 'text-gray-400',
ring: 'stroke-gray-700',
}
case 'light':
return {
bg: 'bg-white',
text: 'text-gray-900',
label: 'text-gray-500',
ring: 'stroke-gray-200',
}
case 'branded':
return {
bg: 'bg-[#053d57]',
text: 'text-white',
label: 'text-[#557f8c]',
ring: 'stroke-[#053d57]/30',
}
}
}
export function LiveScoreAnimation({
score,
maxScore,
label,
animate = true,
theme = 'branded',
}: LiveScoreAnimationProps) {
const [displayScore, setDisplayScore] = useState(0)
const themeClasses = getThemeClasses(theme)
const radius = 40
const circumference = 2 * Math.PI * radius
const targetScore = score ?? 0
const progress = maxScore > 0 ? targetScore / maxScore : 0
const offset = circumference - progress * circumference
useEffect(() => {
if (!animate || score === null) {
setDisplayScore(targetScore)
return
}
let frame: number
const duration = 1200
const startTime = performance.now()
const startScore = 0
const step = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3)
const current = startScore + (targetScore - startScore) * eased
setDisplayScore(Math.round(current * 10) / 10)
if (progress < 1) {
frame = requestAnimationFrame(step)
}
}
frame = requestAnimationFrame(step)
return () => cancelAnimationFrame(frame)
}, [targetScore, animate, score])
if (score === null) {
return (
<div className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}>
<div className="relative h-24 w-24">
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r={radius}
fill="none"
className={themeClasses.ring}
strokeWidth="6"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className={cn('text-lg font-medium', themeClasses.label)}>--</span>
</div>
</div>
<span className={cn('text-xs font-medium', themeClasses.label)}>{label}</span>
</div>
)
}
return (
<AnimatePresence>
<motion.div
initial={animate ? { opacity: 0, scale: 0.8 } : false}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}
>
<div className="relative h-24 w-24">
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
{/* Background ring */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
className={themeClasses.ring}
strokeWidth="6"
/>
{/* Progress ring */}
<motion.circle
cx="50"
cy="50"
r={radius}
fill="none"
className={getProgressColor(targetScore, maxScore)}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={circumference}
initial={animate ? { strokeDashoffset: circumference } : { strokeDashoffset: offset }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 1.2, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span
className={cn(
'text-2xl font-bold tabular-nums',
themeClasses.text,
getScoreColor(targetScore, maxScore)
)}
>
{displayScore.toFixed(maxScore % 1 !== 0 ? 1 : 0)}
</span>
</div>
</div>
<span className={cn('text-xs font-medium text-center', themeClasses.label)}>{label}</span>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -1,335 +1,335 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import type { Area } from 'react-easy-crop'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { ProjectLogo } from './project-logo'
import { Upload, Loader2, Trash2, ImagePlus, ZoomIn } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type LogoUploadProps = {
project: {
id: string
title: string
logoKey?: string | null
}
currentLogoUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
/**
* Crop an image client-side using canvas and return a Blob.
*/
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
const image = new Image()
image.crossOrigin = 'anonymous'
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = reject
image.src = imageSrc
})
const canvas = document.createElement('canvas')
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
)
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob)
else reject(new Error('Canvas toBlob failed'))
},
'image/png',
0.9
)
})
}
export function LogoUpload({
project,
currentLogoUrl,
onUploadComplete,
children,
}: LogoUploadProps) {
const [open, setOpen] = useState(false)
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const utils = trpc.useUtils()
const getUploadUrl = trpc.logo.getUploadUrl.useMutation()
const confirmUpload = trpc.logo.confirmUpload.useMutation()
const deleteLogo = trpc.logo.delete.useMutation()
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels)
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate type
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
return
}
// Validate size
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
return
}
const reader = new FileReader()
reader.onload = (ev) => {
setImageSrc(ev.target?.result as string)
setCrop({ x: 0, y: 0 })
setZoom(1)
}
reader.readAsDataURL(file)
}, [])
const handleUpload = async () => {
if (!imageSrc || !croppedAreaPixels) return
setIsUploading(true)
try {
// Crop the image client-side
const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels)
// Get pre-signed upload URL
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
projectId: project.id,
fileName: 'logo.png',
contentType: 'image/png',
})
// Upload cropped blob directly to storage
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: croppedBlob,
headers: {
'Content-Type': 'image/png',
},
})
if (!uploadResponse.ok) {
throw new Error('Failed to upload file')
}
// Confirm upload with the provider type that was used
await confirmUpload.mutateAsync({ projectId: project.id, key, providerType })
// Invalidate logo query
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo updated successfully')
setOpen(false)
resetState()
onUploadComplete?.()
} catch (error) {
console.error('Upload error:', error)
toast.error('Failed to upload logo. Please try again.')
} finally {
setIsUploading(false)
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteLogo.mutateAsync({ projectId: project.id })
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
const resetState = () => {
setImageSrc(null)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleCancel = () => {
resetState()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button variant="outline" size="sm" className="gap-2">
<ImagePlus className="h-4 w-4" />
{currentLogoUrl ? 'Change Logo' : 'Add Logo'}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update Project Logo</DialogTitle>
<DialogDescription>
{imageSrc
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
: `Upload a logo for "${project.title}". Allowed formats: JPEG, PNG, GIF, WebP. Max size: ${MAX_SIZE_MB}MB.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
{/* Cropper */}
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="rect"
showGrid
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{/* Zoom slider */}
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
value={[zoom]}
min={1}
max={3}
step={0.1}
onValueChange={([val]) => setZoom(val)}
className="flex-1"
/>
</div>
{/* Change image button */}
<Button
variant="ghost"
size="sm"
onClick={() => {
resetState()
fileInputRef.current?.click()
}}
className="w-full"
>
Choose a different image
</Button>
</>
) : (
<>
{/* Current logo preview */}
<div className="flex justify-center">
<ProjectLogo
project={project}
logoUrl={currentLogoUrl}
size="lg"
/>
</div>
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
ref={fileInputRef}
id="logo"
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
</>
)}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !imageSrc && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel
</Button>
{imageSrc && (
<Button
onClick={handleUpload}
disabled={!croppedAreaPixels || isUploading}
className="flex-1"
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
'use client'
import { useState, useRef, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import type { Area } from 'react-easy-crop'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { ProjectLogo } from './project-logo'
import { Upload, Loader2, Trash2, ImagePlus, ZoomIn } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type LogoUploadProps = {
project: {
id: string
title: string
logoKey?: string | null
}
currentLogoUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
/**
* Crop an image client-side using canvas and return a Blob.
*/
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
const image = new Image()
image.crossOrigin = 'anonymous'
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = reject
image.src = imageSrc
})
const canvas = document.createElement('canvas')
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
)
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob)
else reject(new Error('Canvas toBlob failed'))
},
'image/png',
0.9
)
})
}
export function LogoUpload({
project,
currentLogoUrl,
onUploadComplete,
children,
}: LogoUploadProps) {
const [open, setOpen] = useState(false)
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const utils = trpc.useUtils()
const getUploadUrl = trpc.logo.getUploadUrl.useMutation()
const confirmUpload = trpc.logo.confirmUpload.useMutation()
const deleteLogo = trpc.logo.delete.useMutation()
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels)
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate type
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
return
}
// Validate size
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
return
}
const reader = new FileReader()
reader.onload = (ev) => {
setImageSrc(ev.target?.result as string)
setCrop({ x: 0, y: 0 })
setZoom(1)
}
reader.readAsDataURL(file)
}, [])
const handleUpload = async () => {
if (!imageSrc || !croppedAreaPixels) return
setIsUploading(true)
try {
// Crop the image client-side
const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels)
// Get pre-signed upload URL
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
projectId: project.id,
fileName: 'logo.png',
contentType: 'image/png',
})
// Upload cropped blob directly to storage
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: croppedBlob,
headers: {
'Content-Type': 'image/png',
},
})
if (!uploadResponse.ok) {
throw new Error('Failed to upload file')
}
// Confirm upload with the provider type that was used
await confirmUpload.mutateAsync({ projectId: project.id, key, providerType })
// Invalidate logo query
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo updated successfully')
setOpen(false)
resetState()
onUploadComplete?.()
} catch (error) {
console.error('Upload error:', error)
toast.error('Failed to upload logo. Please try again.')
} finally {
setIsUploading(false)
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteLogo.mutateAsync({ projectId: project.id })
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
const resetState = () => {
setImageSrc(null)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleCancel = () => {
resetState()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button variant="outline" size="sm" className="gap-2">
<ImagePlus className="h-4 w-4" />
{currentLogoUrl ? 'Change Logo' : 'Add Logo'}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update Project Logo</DialogTitle>
<DialogDescription>
{imageSrc
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
: `Upload a logo for "${project.title}". Allowed formats: JPEG, PNG, GIF, WebP. Max size: ${MAX_SIZE_MB}MB.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
{/* Cropper */}
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="rect"
showGrid
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{/* Zoom slider */}
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
value={[zoom]}
min={1}
max={3}
step={0.1}
onValueChange={([val]) => setZoom(val)}
className="flex-1"
/>
</div>
{/* Change image button */}
<Button
variant="ghost"
size="sm"
onClick={() => {
resetState()
fileInputRef.current?.click()
}}
className="w-full"
>
Choose a different image
</Button>
</>
) : (
<>
{/* Current logo preview */}
<div className="flex justify-center">
<ProjectLogo
project={project}
logoUrl={currentLogoUrl}
size="lg"
/>
</div>
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
ref={fileInputRef}
id="logo"
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
</>
)}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !imageSrc && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel
</Button>
{imageSrc && (
<Button
onClick={handleUpload}
disabled={!croppedAreaPixels || isUploading}
className="flex-1"
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,55 +1,55 @@
import Image from 'next/image'
import { cn } from '@/lib/utils'
interface LogoProps {
variant?: 'small' | 'long'
className?: string
showText?: boolean
textSuffix?: string
}
export function Logo({
variant = 'small',
className,
showText = false,
textSuffix,
}: LogoProps) {
if (variant === 'long') {
return (
<div className={cn('flex items-center gap-2', className)}>
<Image
src="/images/MOPC-blue-long.png"
alt="MOPC Logo"
width={120}
height={40}
className="h-8 w-auto"
priority
/>
{textSuffix && (
<span className="text-xs text-muted-foreground">{textSuffix}</span>
)}
</div>
)
}
return (
<div className={cn('flex items-center gap-3', className)}>
<Image
src="/images/MOPC-blue-small.png"
alt="MOPC Logo"
width={32}
height={32}
className="h-8 w-8"
priority
/>
{showText && (
<div className="flex items-center gap-1 min-w-0">
<span className="font-semibold">MOPC</span>
{textSuffix && (
<span className="text-xs text-muted-foreground truncate">{textSuffix}</span>
)}
</div>
)}
</div>
)
}
import Image from 'next/image'
import { cn } from '@/lib/utils'
interface LogoProps {
variant?: 'small' | 'long'
className?: string
showText?: boolean
textSuffix?: string
}
export function Logo({
variant = 'small',
className,
showText = false,
textSuffix,
}: LogoProps) {
if (variant === 'long') {
return (
<div className={cn('flex items-center gap-2', className)}>
<Image
src="/images/MOPC-blue-long.png"
alt="MOPC Logo"
width={120}
height={40}
className="h-8 w-auto"
priority
/>
{textSuffix && (
<span className="text-xs text-muted-foreground">{textSuffix}</span>
)}
</div>
)
}
return (
<div className={cn('flex items-center gap-3', className)}>
<Image
src="/images/MOPC-blue-small.png"
alt="MOPC Logo"
width={32}
height={32}
className="h-8 w-8"
priority
/>
{showText && (
<div className="flex items-center gap-1 min-w-0">
<span className="font-semibold">MOPC</span>
{textSuffix && (
<span className="text-xs text-muted-foreground truncate">{textSuffix}</span>
)}
</div>
)}
</div>
)
}

View File

@@ -1,425 +1,425 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Bell,
CheckCheck,
Settings,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Brain,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
} from 'lucide-react'
// Icon mapping for notification types
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
Bell,
}
// Priority styles
const PRIORITY_STYLES = {
low: {
iconBg: 'bg-slate-100 dark:bg-slate-800',
iconColor: 'text-slate-500',
},
normal: {
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
iconColor: 'text-blue-600 dark:text-blue-400',
},
high: {
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
iconColor: 'text-amber-600 dark:text-amber-400',
},
urgent: {
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
},
}
type Notification = {
id: string
type: string
priority: string
icon: string | null
title: string
message: string
linkUrl: string | null
linkLabel: string | null
isRead: boolean
createdAt: Date
}
function NotificationItem({
notification,
onRead,
observeRef,
}: {
notification: Notification
onRead: () => void
observeRef?: (el: HTMLDivElement | null) => void
}) {
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
const priorityStyle =
PRIORITY_STYLES[notification.priority as keyof typeof PRIORITY_STYLES] ||
PRIORITY_STYLES.normal
const content = (
<div
ref={observeRef}
data-notification-id={notification.id}
className={cn(
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
)}
onClick={onRead}
>
{/* Icon with colored background */}
<div
className={cn(
'shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
priorityStyle.iconBg
)}
>
<IconComponent className={cn('h-5 w-5', priorityStyle.iconColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={cn('text-sm', !notification.isRead && 'font-medium')}>
{notification.title}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.createdAt)}
</span>
{notification.linkLabel && (
<span className="text-xs text-primary font-medium">
{notification.linkLabel} &rarr;
</span>
)}
</div>
</div>
{/* Unread dot */}
{!notification.isRead && (
<div className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
)}
</div>
)
if (notification.linkUrl) {
return (
<Link href={notification.linkUrl as Route} className="block">
{content}
</Link>
)
}
return content
}
export function NotificationBell() {
const [open, setOpen] = useState(false)
const pathname = usePathname()
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
// Derive the role-based path prefix from the current route
const pathPrefix = pathname.startsWith('/admin')
? '/admin'
: pathname.startsWith('/jury')
? '/jury'
: pathname.startsWith('/mentor')
? '/mentor'
: pathname.startsWith('/observer')
? '/observer'
: pathname.startsWith('/applicant')
? '/applicant'
: ''
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
undefined,
{
enabled: isAuthenticated,
refetchInterval: 30000, // Refetch every 30 seconds
}
)
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30000,
})
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
{
unreadOnly: false,
limit: 20,
},
{
enabled: open && isAuthenticated, // Only fetch when popover is open and authenticated
}
)
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
onSuccess: () => refetch(),
})
const markAllAsReadMutation = trpc.notification.markAllAsRead.useMutation({
onSuccess: () => refetch(),
})
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
onSuccess: () => refetch(),
})
const unreadCount = countData ?? 0
const notifications = notificationData?.notifications ?? []
// Track unread notification IDs that have become visible
const pendingReadIds = useRef<Set<string>>(new Set())
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Flush pending read IDs in a batch
const flushPendingReads = useCallback(() => {
if (pendingReadIds.current.size === 0) return
const ids = Array.from(pendingReadIds.current)
pendingReadIds.current.clear()
markBatchAsReadMutation.mutate({ ids })
}, [markBatchAsReadMutation])
// Set up IntersectionObserver when popover opens
useEffect(() => {
if (!open) {
// Flush any remaining on close
if (flushTimer.current) clearTimeout(flushTimer.current)
flushPendingReads()
observerRef.current?.disconnect()
observerRef.current = null
return
}
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).dataset.notificationId
if (id) {
// Check if this notification is unread
const notif = notifications.find((n) => n.id === id)
if (notif && !notif.isRead) {
pendingReadIds.current.add(id)
}
}
}
}
// Debounce the batch call
if (pendingReadIds.current.size > 0) {
if (flushTimer.current) clearTimeout(flushTimer.current)
flushTimer.current = setTimeout(flushPendingReads, 500)
}
},
{ threshold: 0.5 }
)
// Observe all currently tracked items
itemRefs.current.forEach((el) => observerRef.current?.observe(el))
return () => {
observerRef.current?.disconnect()
if (flushTimer.current) clearTimeout(flushTimer.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, notifications])
// Ref callback for each notification item
const getItemRef = useCallback(
(id: string) => (el: HTMLDivElement | null) => {
if (el) {
itemRefs.current.set(id, el)
observerRef.current?.observe(el)
} else {
const prev = itemRefs.current.get(id)
if (prev) observerRef.current?.unobserve(prev)
itemRefs.current.delete(id)
}
},
[]
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell
className={cn('h-5 w-5', hasUrgent && 'animate-pulse text-red-500')}
/>
{unreadCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center',
hasUrgent ? 'bg-red-500' : 'bg-primary'
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b">
<h3 className="font-semibold">Notifications</h3>
<div className="flex gap-1">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
>
<CheckCheck className="h-4 w-4 mr-1" />
Mark all read
</Button>
)}
<Button variant="ghost" size="icon" asChild>
<Link href={`${pathPrefix}/settings` as Route}>
<Settings className="h-4 w-4" />
<span className="sr-only">Notification settings</span>
</Link>
</Button>
</div>
</div>
{/* Notification list */}
<ScrollArea className="h-[400px]">
<div className="divide-y">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
observeRef={!notification.isRead ? getItemRef(notification.id) : undefined}
onRead={() => {
if (!notification.isRead) {
markAsReadMutation.mutate({ id: notification.id })
}
}}
/>
))}
{notifications.length === 0 && (
<div className="p-8 text-center">
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
<p className="mt-2 text-muted-foreground">
No notifications yet
</p>
</div>
)}
</div>
</ScrollArea>
{/* Footer */}
{notifications.length > 0 && (
<div className="p-2 border-t bg-muted/30">
<Button variant="ghost" className="w-full" asChild>
<Link href={`${pathPrefix}/notifications` as Route}>View all notifications</Link>
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Bell,
CheckCheck,
Settings,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Brain,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
} from 'lucide-react'
// Icon mapping for notification types
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
Bell,
}
// Priority styles
const PRIORITY_STYLES = {
low: {
iconBg: 'bg-slate-100 dark:bg-slate-800',
iconColor: 'text-slate-500',
},
normal: {
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
iconColor: 'text-blue-600 dark:text-blue-400',
},
high: {
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
iconColor: 'text-amber-600 dark:text-amber-400',
},
urgent: {
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
},
}
type Notification = {
id: string
type: string
priority: string
icon: string | null
title: string
message: string
linkUrl: string | null
linkLabel: string | null
isRead: boolean
createdAt: Date
}
function NotificationItem({
notification,
onRead,
observeRef,
}: {
notification: Notification
onRead: () => void
observeRef?: (el: HTMLDivElement | null) => void
}) {
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
const priorityStyle =
PRIORITY_STYLES[notification.priority as keyof typeof PRIORITY_STYLES] ||
PRIORITY_STYLES.normal
const content = (
<div
ref={observeRef}
data-notification-id={notification.id}
className={cn(
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
)}
onClick={onRead}
>
{/* Icon with colored background */}
<div
className={cn(
'shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
priorityStyle.iconBg
)}
>
<IconComponent className={cn('h-5 w-5', priorityStyle.iconColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={cn('text-sm', !notification.isRead && 'font-medium')}>
{notification.title}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.createdAt)}
</span>
{notification.linkLabel && (
<span className="text-xs text-primary font-medium">
{notification.linkLabel} &rarr;
</span>
)}
</div>
</div>
{/* Unread dot */}
{!notification.isRead && (
<div className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
)}
</div>
)
if (notification.linkUrl) {
return (
<Link href={notification.linkUrl as Route} className="block">
{content}
</Link>
)
}
return content
}
export function NotificationBell() {
const [open, setOpen] = useState(false)
const pathname = usePathname()
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
// Derive the role-based path prefix from the current route
const pathPrefix = pathname.startsWith('/admin')
? '/admin'
: pathname.startsWith('/jury')
? '/jury'
: pathname.startsWith('/mentor')
? '/mentor'
: pathname.startsWith('/observer')
? '/observer'
: pathname.startsWith('/applicant')
? '/applicant'
: ''
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
undefined,
{
enabled: isAuthenticated,
refetchInterval: 30000, // Refetch every 30 seconds
}
)
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30000,
})
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
{
unreadOnly: false,
limit: 20,
},
{
enabled: open && isAuthenticated, // Only fetch when popover is open and authenticated
}
)
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
onSuccess: () => refetch(),
})
const markAllAsReadMutation = trpc.notification.markAllAsRead.useMutation({
onSuccess: () => refetch(),
})
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
onSuccess: () => refetch(),
})
const unreadCount = countData ?? 0
const notifications = notificationData?.notifications ?? []
// Track unread notification IDs that have become visible
const pendingReadIds = useRef<Set<string>>(new Set())
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Flush pending read IDs in a batch
const flushPendingReads = useCallback(() => {
if (pendingReadIds.current.size === 0) return
const ids = Array.from(pendingReadIds.current)
pendingReadIds.current.clear()
markBatchAsReadMutation.mutate({ ids })
}, [markBatchAsReadMutation])
// Set up IntersectionObserver when popover opens
useEffect(() => {
if (!open) {
// Flush any remaining on close
if (flushTimer.current) clearTimeout(flushTimer.current)
flushPendingReads()
observerRef.current?.disconnect()
observerRef.current = null
return
}
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).dataset.notificationId
if (id) {
// Check if this notification is unread
const notif = notifications.find((n) => n.id === id)
if (notif && !notif.isRead) {
pendingReadIds.current.add(id)
}
}
}
}
// Debounce the batch call
if (pendingReadIds.current.size > 0) {
if (flushTimer.current) clearTimeout(flushTimer.current)
flushTimer.current = setTimeout(flushPendingReads, 500)
}
},
{ threshold: 0.5 }
)
// Observe all currently tracked items
itemRefs.current.forEach((el) => observerRef.current?.observe(el))
return () => {
observerRef.current?.disconnect()
if (flushTimer.current) clearTimeout(flushTimer.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, notifications])
// Ref callback for each notification item
const getItemRef = useCallback(
(id: string) => (el: HTMLDivElement | null) => {
if (el) {
itemRefs.current.set(id, el)
observerRef.current?.observe(el)
} else {
const prev = itemRefs.current.get(id)
if (prev) observerRef.current?.unobserve(prev)
itemRefs.current.delete(id)
}
},
[]
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell
className={cn('h-5 w-5', hasUrgent && 'animate-pulse text-red-500')}
/>
{unreadCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center',
hasUrgent ? 'bg-red-500' : 'bg-primary'
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b">
<h3 className="font-semibold">Notifications</h3>
<div className="flex gap-1">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
>
<CheckCheck className="h-4 w-4 mr-1" />
Mark all read
</Button>
)}
<Button variant="ghost" size="icon" asChild>
<Link href={`${pathPrefix}/settings` as Route}>
<Settings className="h-4 w-4" />
<span className="sr-only">Notification settings</span>
</Link>
</Button>
</div>
</div>
{/* Notification list */}
<ScrollArea className="h-[400px]">
<div className="divide-y">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
observeRef={!notification.isRead ? getItemRef(notification.id) : undefined}
onRead={() => {
if (!notification.isRead) {
markAsReadMutation.mutate({ id: notification.id })
}
}}
/>
))}
{notifications.length === 0 && (
<div className="p-8 text-center">
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
<p className="mt-2 text-muted-foreground">
No notifications yet
</p>
</div>
)}
</div>
</ScrollArea>
{/* Footer */}
{notifications.length > 0 && (
<div className="p-2 border-t bg-muted/30">
<Button variant="ghost" className="w-full" asChild>
<Link href={`${pathPrefix}/notifications` as Route}>View all notifications</Link>
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -1,86 +1,86 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface PaginationProps {
page: number
totalPages: number
total: number
perPage: number
onPageChange: (page: number) => void
onPerPageChange?: (perPage: number) => void
}
export function Pagination({
page,
totalPages,
total,
perPage,
onPageChange,
onPerPageChange,
}: PaginationProps) {
if (totalPages <= 1 && !onPerPageChange) return null
const from = (page - 1) * perPage + 1
const to = Math.min(page * perPage, total)
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing {from} to {to} of {total} results
</p>
{onPerPageChange && (
<Select
value={String(perPage)}
onValueChange={(v) => onPerPageChange(Number(v))}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 20, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
}
'use client'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface PaginationProps {
page: number
totalPages: number
total: number
perPage: number
onPageChange: (page: number) => void
onPerPageChange?: (perPage: number) => void
}
export function Pagination({
page,
totalPages,
total,
perPage,
onPageChange,
onPerPageChange,
}: PaginationProps) {
if (totalPages <= 1 && !onPerPageChange) return null
const from = (page - 1) * perPage + 1
const to = Math.min(page * perPage, total)
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing {from} to {to} of {total} results
</p>
{onPerPageChange && (
<Select
value={String(perPage)}
onValueChange={(v) => onPerPageChange(Number(v))}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 20, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
}

View File

@@ -1,135 +1,135 @@
'use client'
import { useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Copy, QrCode } from 'lucide-react'
import { toast } from 'sonner'
interface QRCodeDisplayProps {
url: string
title?: string
size?: number
}
/**
* Generates a simple QR code using Canvas API.
* Uses a basic QR encoding approach for URLs.
*/
function generateQRMatrix(data: string): boolean[][] {
// Simple QR-like grid pattern based on data hash
// For production, use a library like 'qrcode', but this is a lightweight visual
const size = 25
const matrix: boolean[][] = Array.from({ length: size }, () =>
Array(size).fill(false)
)
// Add finder patterns (top-left, top-right, bottom-left)
const addFinderPattern = (row: number, col: number) => {
for (let r = 0; r < 7; r++) {
for (let c = 0; c < 7; c++) {
if (
r === 0 || r === 6 || c === 0 || c === 6 || // outer border
(r >= 2 && r <= 4 && c >= 2 && c <= 4) // inner block
) {
if (row + r < size && col + c < size) {
matrix[row + r][col + c] = true
}
}
}
}
}
addFinderPattern(0, 0)
addFinderPattern(0, size - 7)
addFinderPattern(size - 7, 0)
// Fill data area with a hash-based pattern
let hash = 0
for (let i = 0; i < data.length; i++) {
hash = ((hash << 5) - hash + data.charCodeAt(i)) | 0
}
for (let r = 8; r < size - 8; r++) {
for (let c = 8; c < size - 8; c++) {
hash = ((hash << 5) - hash + r * size + c) | 0
matrix[r][c] = (hash & 1) === 1
}
}
// Timing patterns
for (let i = 8; i < size - 8; i++) {
matrix[6][i] = i % 2 === 0
matrix[i][6] = i % 2 === 0
}
return matrix
}
function drawQR(canvas: HTMLCanvasElement, data: string, pixelSize: number) {
const matrix = generateQRMatrix(data)
const size = matrix.length
const totalSize = size * pixelSize
canvas.width = totalSize
canvas.height = totalSize
const ctx = canvas.getContext('2d')
if (!ctx) return
// White background
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, totalSize, totalSize)
// Draw modules
ctx.fillStyle = '#053d57'
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (matrix[r][c]) {
ctx.fillRect(c * pixelSize, r * pixelSize, pixelSize, pixelSize)
}
}
}
}
export function QRCodeDisplay({ url, title = 'Scan to Vote', size = 200 }: QRCodeDisplayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const pixelSize = Math.floor(size / 25)
useEffect(() => {
if (canvasRef.current) {
drawQR(canvasRef.current, url, pixelSize)
}
}, [url, pixelSize])
const handleCopyUrl = () => {
navigator.clipboard.writeText(url).then(() => {
toast.success('URL copied to clipboard')
})
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<QrCode className="h-4 w-4" />
{title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-3">
<canvas
ref={canvasRef}
className="border rounded-lg"
style={{ width: size, height: size }}
/>
<div className="flex items-center gap-2 w-full">
<code className="flex-1 text-xs bg-muted p-2 rounded truncate">
{url}
</code>
<Button variant="ghost" size="sm" onClick={handleCopyUrl}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
}
'use client'
import { useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Copy, QrCode } from 'lucide-react'
import { toast } from 'sonner'
interface QRCodeDisplayProps {
url: string
title?: string
size?: number
}
/**
* Generates a simple QR code using Canvas API.
* Uses a basic QR encoding approach for URLs.
*/
function generateQRMatrix(data: string): boolean[][] {
// Simple QR-like grid pattern based on data hash
// For production, use a library like 'qrcode', but this is a lightweight visual
const size = 25
const matrix: boolean[][] = Array.from({ length: size }, () =>
Array(size).fill(false)
)
// Add finder patterns (top-left, top-right, bottom-left)
const addFinderPattern = (row: number, col: number) => {
for (let r = 0; r < 7; r++) {
for (let c = 0; c < 7; c++) {
if (
r === 0 || r === 6 || c === 0 || c === 6 || // outer border
(r >= 2 && r <= 4 && c >= 2 && c <= 4) // inner block
) {
if (row + r < size && col + c < size) {
matrix[row + r][col + c] = true
}
}
}
}
}
addFinderPattern(0, 0)
addFinderPattern(0, size - 7)
addFinderPattern(size - 7, 0)
// Fill data area with a hash-based pattern
let hash = 0
for (let i = 0; i < data.length; i++) {
hash = ((hash << 5) - hash + data.charCodeAt(i)) | 0
}
for (let r = 8; r < size - 8; r++) {
for (let c = 8; c < size - 8; c++) {
hash = ((hash << 5) - hash + r * size + c) | 0
matrix[r][c] = (hash & 1) === 1
}
}
// Timing patterns
for (let i = 8; i < size - 8; i++) {
matrix[6][i] = i % 2 === 0
matrix[i][6] = i % 2 === 0
}
return matrix
}
function drawQR(canvas: HTMLCanvasElement, data: string, pixelSize: number) {
const matrix = generateQRMatrix(data)
const size = matrix.length
const totalSize = size * pixelSize
canvas.width = totalSize
canvas.height = totalSize
const ctx = canvas.getContext('2d')
if (!ctx) return
// White background
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, totalSize, totalSize)
// Draw modules
ctx.fillStyle = '#053d57'
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (matrix[r][c]) {
ctx.fillRect(c * pixelSize, r * pixelSize, pixelSize, pixelSize)
}
}
}
}
export function QRCodeDisplay({ url, title = 'Scan to Vote', size = 200 }: QRCodeDisplayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const pixelSize = Math.floor(size / 25)
useEffect(() => {
if (canvasRef.current) {
drawQR(canvasRef.current, url, pixelSize)
}
}, [url, pixelSize])
const handleCopyUrl = () => {
navigator.clipboard.writeText(url).then(() => {
toast.success('URL copied to clipboard')
})
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<QrCode className="h-4 w-4" />
{title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-3">
<canvas
ref={canvasRef}
className="border rounded-lg"
style={{ width: size, height: size }}
/>
<div className="flex items-center gap-2 w-full">
<code className="flex-1 text-xs bg-muted p-2 rounded truncate">
{url}
</code>
<Button variant="ghost" size="sm" onClick={handleCopyUrl}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,364 +1,364 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import {
Upload,
FileIcon,
CheckCircle2,
AlertCircle,
Loader2,
Trash2,
RefreshCw,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
import { toast } from 'sonner'
function getMimeLabel(mime: string): string {
if (mime === 'application/pdf') return 'PDF'
if (mime.startsWith('image/')) return 'Images'
if (mime.startsWith('video/')) return 'Video'
if (mime.includes('wordprocessingml')) return 'Word'
if (mime.includes('spreadsheetml')) return 'Excel'
if (mime.includes('presentationml')) return 'PowerPoint'
if (mime.endsWith('/*')) return mime.replace('/*', '')
return mime
}
interface FileRequirement {
id: string
name: string
description?: string | null
acceptedMimeTypes: string[]
maxSizeMB?: number | null
isRequired: boolean
}
interface UploadedFile {
id: string
fileName: string
mimeType: string
size: number
createdAt: string | Date
requirementId?: string | null
}
interface RequirementUploadSlotProps {
requirement: FileRequirement
existingFile?: UploadedFile | null
projectId: string
stageId: string
onFileChange?: () => void
disabled?: boolean
}
export function RequirementUploadSlot({
requirement,
existingFile,
projectId,
stageId,
onFileChange,
disabled = false,
}: RequirementUploadSlotProps) {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [deleting, setDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
const saveFileMetadata = trpc.applicant.saveFileMetadata.useMutation()
const deleteFile = trpc.applicant.deleteFile.useMutation()
const acceptsMime = useCallback(
(mimeType: string) => {
if (requirement.acceptedMimeTypes.length === 0) return true
return requirement.acceptedMimeTypes.some((pattern) => {
if (pattern.endsWith('/*')) {
return mimeType.startsWith(pattern.replace('/*', '/'))
}
return mimeType === pattern
})
},
[requirement.acceptedMimeTypes]
)
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ''
// Validate mime type
if (!acceptsMime(file.type)) {
toast.error(`File type ${file.type} is not accepted for this requirement`)
return
}
// Validate size
if (requirement.maxSizeMB && file.size > requirement.maxSizeMB * 1024 * 1024) {
toast.error(`File exceeds maximum size of ${requirement.maxSizeMB}MB`)
return
}
setUploading(true)
setProgress(0)
try {
// Get presigned URL
const { url, bucket, objectKey, isLate, stageId: uploadStageId } =
await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
fileType: 'OTHER',
stageId,
requirementId: requirement.id,
})
// Upload file with progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
// Save metadata
await saveFileMetadata.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
size: file.size,
fileType: 'OTHER',
bucket,
objectKey,
stageId: uploadStageId || stageId,
isLate: isLate || false,
requirementId: requirement.id,
})
toast.success(`${requirement.name} uploaded successfully`)
onFileChange?.()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
setProgress(0)
}
},
[projectId, stageId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
)
const handleDelete = useCallback(async () => {
if (!existingFile) return
setDeleting(true)
try {
await deleteFile.mutateAsync({ fileId: existingFile.id })
toast.success('File deleted')
onFileChange?.()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed')
} finally {
setDeleting(false)
}
}, [existingFile, deleteFile, onFileChange])
const isFulfilled = !!existingFile
const statusColor = isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
: requirement.isRequired
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950'
: 'border-muted'
// Build accept string for file input
const acceptStr =
requirement.acceptedMimeTypes.length > 0
? requirement.acceptedMimeTypes.join(',')
: undefined
return (
<div className={cn('rounded-lg border p-4 transition-colors', statusColor)}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{isFulfilled ? (
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
) : requirement.isRequired ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="font-medium text-sm">{requirement.name}</span>
<Badge
variant={requirement.isRequired ? 'destructive' : 'secondary'}
className="text-xs shrink-0"
>
{requirement.isRequired ? 'Required' : 'Optional'}
</Badge>
</div>
{requirement.description && (
<p className="text-xs text-muted-foreground ml-6 mb-2">
{requirement.description}
</p>
)}
<div className="flex flex-wrap gap-1 ml-6 mb-2">
{requirement.acceptedMimeTypes.map((mime) => (
<Badge key={mime} variant="outline" className="text-xs">
{getMimeLabel(mime)}
</Badge>
))}
{requirement.maxSizeMB && (
<Badge variant="outline" className="text-xs">
Max {requirement.maxSizeMB}MB
</Badge>
)}
</div>
{existingFile && (
<div className="ml-6 flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="h-3 w-3" />
<span className="truncate">{existingFile.fileName}</span>
<span>({formatFileSize(existingFile.size)})</span>
</div>
)}
{uploading && (
<div className="ml-6 mt-2">
<Progress value={progress} className="h-1.5" />
<p className="text-xs text-muted-foreground mt-1">Uploading... {progress}%</p>
</div>
)}
</div>
{!disabled && (
<div className="flex items-center gap-1 shrink-0">
{existingFile ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<RefreshCw className="mr-1 h-3 w-3" />
Replace
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Upload className="mr-1 h-3 w-3" />
)}
Upload
</Button>
)}
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={acceptStr}
onChange={handleFileSelect}
/>
</div>
)
}
interface RequirementUploadListProps {
projectId: string
stageId: string
disabled?: boolean
}
export function RequirementUploadList({ projectId, stageId, disabled }: RequirementUploadListProps) {
const utils = trpc.useUtils()
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
stageId,
})
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, stageId })
if (requirements.length === 0) return null
const handleFileChange = () => {
utils.file.listByProject.invalidate({ projectId, stageId })
}
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Required Documents
</h3>
{requirements.map((req) => {
const existing = files.find(
(f) => (f as { requirementId?: string | null }).requirementId === req.id
)
return (
<RequirementUploadSlot
key={req.id}
requirement={req}
existingFile={
existing
? {
id: existing.id,
fileName: existing.fileName,
mimeType: existing.mimeType,
size: existing.size,
createdAt: existing.createdAt,
requirementId: (existing as { requirementId?: string | null }).requirementId,
}
: null
}
projectId={projectId}
stageId={stageId}
onFileChange={handleFileChange}
disabled={disabled}
/>
)
})}
</div>
)
}
'use client'
import { useState, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import {
Upload,
FileIcon,
CheckCircle2,
AlertCircle,
Loader2,
Trash2,
RefreshCw,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
import { toast } from 'sonner'
function getMimeLabel(mime: string): string {
if (mime === 'application/pdf') return 'PDF'
if (mime.startsWith('image/')) return 'Images'
if (mime.startsWith('video/')) return 'Video'
if (mime.includes('wordprocessingml')) return 'Word'
if (mime.includes('spreadsheetml')) return 'Excel'
if (mime.includes('presentationml')) return 'PowerPoint'
if (mime.endsWith('/*')) return mime.replace('/*', '')
return mime
}
interface FileRequirement {
id: string
name: string
description?: string | null
acceptedMimeTypes: string[]
maxSizeMB?: number | null
isRequired: boolean
}
interface UploadedFile {
id: string
fileName: string
mimeType: string
size: number
createdAt: string | Date
requirementId?: string | null
}
interface RequirementUploadSlotProps {
requirement: FileRequirement
existingFile?: UploadedFile | null
projectId: string
stageId: string
onFileChange?: () => void
disabled?: boolean
}
export function RequirementUploadSlot({
requirement,
existingFile,
projectId,
stageId,
onFileChange,
disabled = false,
}: RequirementUploadSlotProps) {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [deleting, setDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
const saveFileMetadata = trpc.applicant.saveFileMetadata.useMutation()
const deleteFile = trpc.applicant.deleteFile.useMutation()
const acceptsMime = useCallback(
(mimeType: string) => {
if (requirement.acceptedMimeTypes.length === 0) return true
return requirement.acceptedMimeTypes.some((pattern) => {
if (pattern.endsWith('/*')) {
return mimeType.startsWith(pattern.replace('/*', '/'))
}
return mimeType === pattern
})
},
[requirement.acceptedMimeTypes]
)
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ''
// Validate mime type
if (!acceptsMime(file.type)) {
toast.error(`File type ${file.type} is not accepted for this requirement`)
return
}
// Validate size
if (requirement.maxSizeMB && file.size > requirement.maxSizeMB * 1024 * 1024) {
toast.error(`File exceeds maximum size of ${requirement.maxSizeMB}MB`)
return
}
setUploading(true)
setProgress(0)
try {
// Get presigned URL
const { url, bucket, objectKey, isLate, stageId: uploadStageId } =
await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
fileType: 'OTHER',
stageId,
requirementId: requirement.id,
})
// Upload file with progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
// Save metadata
await saveFileMetadata.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
size: file.size,
fileType: 'OTHER',
bucket,
objectKey,
stageId: uploadStageId || stageId,
isLate: isLate || false,
requirementId: requirement.id,
})
toast.success(`${requirement.name} uploaded successfully`)
onFileChange?.()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
setProgress(0)
}
},
[projectId, stageId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
)
const handleDelete = useCallback(async () => {
if (!existingFile) return
setDeleting(true)
try {
await deleteFile.mutateAsync({ fileId: existingFile.id })
toast.success('File deleted')
onFileChange?.()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed')
} finally {
setDeleting(false)
}
}, [existingFile, deleteFile, onFileChange])
const isFulfilled = !!existingFile
const statusColor = isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
: requirement.isRequired
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950'
: 'border-muted'
// Build accept string for file input
const acceptStr =
requirement.acceptedMimeTypes.length > 0
? requirement.acceptedMimeTypes.join(',')
: undefined
return (
<div className={cn('rounded-lg border p-4 transition-colors', statusColor)}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{isFulfilled ? (
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
) : requirement.isRequired ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="font-medium text-sm">{requirement.name}</span>
<Badge
variant={requirement.isRequired ? 'destructive' : 'secondary'}
className="text-xs shrink-0"
>
{requirement.isRequired ? 'Required' : 'Optional'}
</Badge>
</div>
{requirement.description && (
<p className="text-xs text-muted-foreground ml-6 mb-2">
{requirement.description}
</p>
)}
<div className="flex flex-wrap gap-1 ml-6 mb-2">
{requirement.acceptedMimeTypes.map((mime) => (
<Badge key={mime} variant="outline" className="text-xs">
{getMimeLabel(mime)}
</Badge>
))}
{requirement.maxSizeMB && (
<Badge variant="outline" className="text-xs">
Max {requirement.maxSizeMB}MB
</Badge>
)}
</div>
{existingFile && (
<div className="ml-6 flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="h-3 w-3" />
<span className="truncate">{existingFile.fileName}</span>
<span>({formatFileSize(existingFile.size)})</span>
</div>
)}
{uploading && (
<div className="ml-6 mt-2">
<Progress value={progress} className="h-1.5" />
<p className="text-xs text-muted-foreground mt-1">Uploading... {progress}%</p>
</div>
)}
</div>
{!disabled && (
<div className="flex items-center gap-1 shrink-0">
{existingFile ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<RefreshCw className="mr-1 h-3 w-3" />
Replace
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Upload className="mr-1 h-3 w-3" />
)}
Upload
</Button>
)}
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={acceptStr}
onChange={handleFileSelect}
/>
</div>
)
}
interface RequirementUploadListProps {
projectId: string
stageId: string
disabled?: boolean
}
export function RequirementUploadList({ projectId, stageId, disabled }: RequirementUploadListProps) {
const utils = trpc.useUtils()
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
stageId,
})
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, stageId })
if (requirements.length === 0) return null
const handleFileChange = () => {
utils.file.listByProject.invalidate({ projectId, stageId })
}
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Required Documents
</h3>
{requirements.map((req) => {
const existing = files.find(
(f) => (f as { requirementId?: string | null }).requirementId === req.id
)
return (
<RequirementUploadSlot
key={req.id}
requirement={req}
existingFile={
existing
? {
id: existing.id,
fileName: existing.fileName,
mimeType: existing.mimeType,
size: existing.size,
createdAt: existing.createdAt,
requirementId: (existing as { requirementId?: string | null }).requirementId,
}
: null
}
projectId={projectId}
stageId={stageId}
onFileChange={handleFileChange}
disabled={disabled}
/>
)
})}
</div>
)
}

View File

@@ -1,47 +1,47 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface StageBreadcrumbProps {
pipelineName: string
trackName: string
stageName: string
stageId?: string
pipelineId?: string
className?: string
basePath?: string // e.g. '/jury/stages' or '/admin/reports/stages'
}
export function StageBreadcrumb({
pipelineName,
trackName,
stageName,
stageId,
pipelineId,
className,
basePath = '/jury/stages',
}: StageBreadcrumbProps) {
return (
<nav className={cn('flex items-center gap-1 text-sm text-muted-foreground', className)}>
<Link href={basePath as Route} className="hover:text-foreground transition-colors truncate max-w-[150px]">
{pipelineName}
</Link>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span className="truncate max-w-[120px]">{trackName}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
{stageId ? (
<Link
href={`${basePath}/${stageId}/assignments` as Route}
className="hover:text-foreground transition-colors font-medium text-foreground truncate max-w-[150px]"
>
{stageName}
</Link>
) : (
<span className="font-medium text-foreground truncate max-w-[150px]">{stageName}</span>
)}
</nav>
)
}
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface StageBreadcrumbProps {
pipelineName: string
trackName: string
stageName: string
stageId?: string
pipelineId?: string
className?: string
basePath?: string // e.g. '/jury/stages' or '/admin/reports/stages'
}
export function StageBreadcrumb({
pipelineName,
trackName,
stageName,
stageId,
pipelineId,
className,
basePath = '/jury/stages',
}: StageBreadcrumbProps) {
return (
<nav className={cn('flex items-center gap-1 text-sm text-muted-foreground', className)}>
<Link href={basePath as Route} className="hover:text-foreground transition-colors truncate max-w-[150px]">
{pipelineName}
</Link>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span className="truncate max-w-[120px]">{trackName}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
{stageId ? (
<Link
href={`${basePath}/${stageId}/assignments` as Route}
className="hover:text-foreground transition-colors font-medium text-foreground truncate max-w-[150px]"
>
{stageName}
</Link>
) : (
<span className="font-medium text-foreground truncate max-w-[150px]">{stageName}</span>
)}
</nav>
)
}

View File

@@ -1,205 +1,205 @@
'use client'
import { cn } from '@/lib/utils'
import {
CheckCircle,
Circle,
Clock,
XCircle,
FileText,
Users,
Vote,
ArrowRightLeft,
Presentation,
Award,
} from 'lucide-react'
interface StageTimelineItem {
id: string
name: string
stageType: string
isCurrent: boolean
state: string // PENDING, IN_PROGRESS, PASSED, REJECTED, etc.
enteredAt?: Date | string | null
}
interface StageTimelineProps {
stages: StageTimelineItem[]
orientation?: 'horizontal' | 'vertical'
className?: string
}
const stageTypeIcons: Record<string, typeof Circle> = {
INTAKE: FileText,
EVALUATION: Users,
VOTING: Vote,
DELIBERATION: ArrowRightLeft,
LIVE_PRESENTATION: Presentation,
AWARD: Award,
}
function getStateColor(state: string, isCurrent: boolean) {
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive text-destructive-foreground'
if (state === 'PASSED' || state === 'COMPLETED')
return 'bg-green-600 text-white dark:bg-green-700'
if (state === 'IN_PROGRESS' || isCurrent)
return 'bg-primary text-primary-foreground'
return 'border-2 border-muted bg-background text-muted-foreground'
}
function getConnectorColor(state: string) {
if (state === 'PASSED' || state === 'COMPLETED' || state === 'IN_PROGRESS')
return 'bg-primary'
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive/30'
return 'bg-muted'
}
function formatDate(date: Date | string | null | undefined) {
if (!date) return null
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function StageTimeline({
stages,
orientation = 'horizontal',
className,
}: StageTimelineProps) {
if (stages.length === 0) return null
if (orientation === 'vertical') {
return (
<div className={cn('relative', className)}>
<div className="space-y-0">
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="relative flex gap-4">
{index < stages.length - 1 && (
<div
className={cn(
'absolute left-[15px] top-[32px] h-full w-0.5',
getConnectorColor(stage.state)
)}
/>
)}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1 pb-8">
<div className="flex items-center gap-2">
<p
className={cn(
'font-medium text-sm',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground'
)}
>
{stage.name}
</p>
{stage.isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Current
</span>
)}
</div>
<p className="text-xs text-muted-foreground capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</p>
{stage.enteredAt && (
<p className="text-xs text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// Horizontal orientation
return (
<div className={cn('flex items-center gap-0 overflow-x-auto pb-2', className)}>
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="flex items-center">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8 lg:w-12 shrink-0',
getConnectorColor(stages[index - 1].state)
)}
/>
)}
<div className="flex flex-col items-center gap-1 shrink-0">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
<div className="text-center max-w-[80px]">
<p
className={cn(
'text-xs font-medium leading-tight',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground',
stage.isCurrent && 'text-primary'
)}
>
{stage.name}
</p>
{stage.enteredAt && (
<p className="text-[10px] text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
</div>
)
})}
</div>
)
}
'use client'
import { cn } from '@/lib/utils'
import {
CheckCircle,
Circle,
Clock,
XCircle,
FileText,
Users,
Vote,
ArrowRightLeft,
Presentation,
Award,
} from 'lucide-react'
interface StageTimelineItem {
id: string
name: string
stageType: string
isCurrent: boolean
state: string // PENDING, IN_PROGRESS, PASSED, REJECTED, etc.
enteredAt?: Date | string | null
}
interface StageTimelineProps {
stages: StageTimelineItem[]
orientation?: 'horizontal' | 'vertical'
className?: string
}
const stageTypeIcons: Record<string, typeof Circle> = {
INTAKE: FileText,
EVALUATION: Users,
VOTING: Vote,
DELIBERATION: ArrowRightLeft,
LIVE_PRESENTATION: Presentation,
AWARD: Award,
}
function getStateColor(state: string, isCurrent: boolean) {
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive text-destructive-foreground'
if (state === 'PASSED' || state === 'COMPLETED')
return 'bg-green-600 text-white dark:bg-green-700'
if (state === 'IN_PROGRESS' || isCurrent)
return 'bg-primary text-primary-foreground'
return 'border-2 border-muted bg-background text-muted-foreground'
}
function getConnectorColor(state: string) {
if (state === 'PASSED' || state === 'COMPLETED' || state === 'IN_PROGRESS')
return 'bg-primary'
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive/30'
return 'bg-muted'
}
function formatDate(date: Date | string | null | undefined) {
if (!date) return null
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function StageTimeline({
stages,
orientation = 'horizontal',
className,
}: StageTimelineProps) {
if (stages.length === 0) return null
if (orientation === 'vertical') {
return (
<div className={cn('relative', className)}>
<div className="space-y-0">
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="relative flex gap-4">
{index < stages.length - 1 && (
<div
className={cn(
'absolute left-[15px] top-[32px] h-full w-0.5',
getConnectorColor(stage.state)
)}
/>
)}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1 pb-8">
<div className="flex items-center gap-2">
<p
className={cn(
'font-medium text-sm',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground'
)}
>
{stage.name}
</p>
{stage.isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Current
</span>
)}
</div>
<p className="text-xs text-muted-foreground capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</p>
{stage.enteredAt && (
<p className="text-xs text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// Horizontal orientation
return (
<div className={cn('flex items-center gap-0 overflow-x-auto pb-2', className)}>
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="flex items-center">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8 lg:w-12 shrink-0',
getConnectorColor(stages[index - 1].state)
)}
/>
)}
<div className="flex flex-col items-center gap-1 shrink-0">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
<div className="text-center max-w-[80px]">
<p
className={cn(
'text-xs font-medium leading-tight',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground',
stage.isCurrent && 'text-primary'
)}
>
{stage.name}
</p>
{stage.enteredAt && (
<p className="text-[10px] text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,133 +1,133 @@
'use client'
import { cn } from '@/lib/utils'
import { Clock, CheckCircle, XCircle, Timer } from 'lucide-react'
import { CountdownTimer } from '@/components/shared/countdown-timer'
interface StageWindowBadgeProps {
windowOpenAt?: Date | string | null
windowCloseAt?: Date | string | null
status?: string
className?: string
}
function toDate(v: Date | string | null | undefined): Date | null {
if (!v) return null
return typeof v === 'string' ? new Date(v) : v
}
export function StageWindowBadge({
windowOpenAt,
windowCloseAt,
status,
className,
}: StageWindowBadgeProps) {
const now = new Date()
const openAt = toDate(windowOpenAt)
const closeAt = toDate(windowCloseAt)
// Determine window state
const isBeforeOpen = openAt && now < openAt
const isOpenEnded = openAt && !closeAt && now >= openAt
const isOpen = openAt && closeAt && now >= openAt && now <= closeAt
const isClosed = closeAt && now > closeAt
if (status === 'COMPLETED' || status === 'CLOSED') {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<CheckCircle className="h-3 w-3 shrink-0" />
<span>Completed</span>
</div>
)
}
if (isClosed) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<XCircle className="h-3 w-3 shrink-0" />
<span>Closed</span>
</div>
)
}
if (isOpenEnded) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isOpen && closeAt) {
const remainingMs = closeAt.getTime() - now.getTime()
const isUrgent = remainingMs < 24 * 60 * 60 * 1000 // < 24 hours
if (isUrgent) {
return <CountdownTimer deadline={closeAt} label="Closes in" className={className} />
}
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isBeforeOpen && openAt) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Timer className="h-3 w-3 shrink-0" />
<span>
Opens{' '}
{openAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
)
}
// No window configured
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>No window set</span>
</div>
)
}
'use client'
import { cn } from '@/lib/utils'
import { Clock, CheckCircle, XCircle, Timer } from 'lucide-react'
import { CountdownTimer } from '@/components/shared/countdown-timer'
interface StageWindowBadgeProps {
windowOpenAt?: Date | string | null
windowCloseAt?: Date | string | null
status?: string
className?: string
}
function toDate(v: Date | string | null | undefined): Date | null {
if (!v) return null
return typeof v === 'string' ? new Date(v) : v
}
export function StageWindowBadge({
windowOpenAt,
windowCloseAt,
status,
className,
}: StageWindowBadgeProps) {
const now = new Date()
const openAt = toDate(windowOpenAt)
const closeAt = toDate(windowCloseAt)
// Determine window state
const isBeforeOpen = openAt && now < openAt
const isOpenEnded = openAt && !closeAt && now >= openAt
const isOpen = openAt && closeAt && now >= openAt && now <= closeAt
const isClosed = closeAt && now > closeAt
if (status === 'COMPLETED' || status === 'CLOSED') {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<CheckCircle className="h-3 w-3 shrink-0" />
<span>Completed</span>
</div>
)
}
if (isClosed) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<XCircle className="h-3 w-3 shrink-0" />
<span>Closed</span>
</div>
)
}
if (isOpenEnded) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isOpen && closeAt) {
const remainingMs = closeAt.getTime() - now.getTime()
const isUrgent = remainingMs < 24 * 60 * 60 * 1000 // < 24 hours
if (isUrgent) {
return <CountdownTimer deadline={closeAt} label="Closes in" className={className} />
}
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isBeforeOpen && openAt) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Timer className="h-3 w-3 shrink-0" />
<span>
Opens{' '}
{openAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
)
}
// No window configured
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>No window set</span>
</div>
)
}

View File

@@ -1,56 +1,56 @@
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?: string }> = {
// Round statuses
DRAFT: { variant: 'secondary' },
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
// Project statuses
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' },
ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200 dark:text-orange-400' },
WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300 dark:text-yellow-400' },
REJECTED: { variant: 'destructive' },
WITHDRAWN: { variant: 'secondary' },
// Evaluation statuses
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
// User statuses
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' },
INACTIVE: { variant: 'secondary' },
SUSPENDED: { variant: 'destructive' },
}
type StatusBadgeProps = {
status: string
className?: string
size?: 'sm' | 'default'
}
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ')
return (
<Badge
variant={style.variant}
className={cn(
style.className,
size === 'sm' && 'text-[10px] px-1.5 py-0',
className,
)}
>
{label}
</Badge>
)
}
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?: string }> = {
// Round statuses
DRAFT: { variant: 'secondary' },
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
// Project statuses
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' },
ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200 dark:text-orange-400' },
WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300 dark:text-yellow-400' },
REJECTED: { variant: 'destructive' },
WITHDRAWN: { variant: 'secondary' },
// Evaluation statuses
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
// User statuses
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' },
INACTIVE: { variant: 'secondary' },
SUSPENDED: { variant: 'destructive' },
}
type StatusBadgeProps = {
status: string
className?: string
size?: 'sm' | 'default'
}
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ')
return (
<Badge
variant={style.variant}
className={cn(
style.className,
size === 'sm' && 'text-[10px] px-1.5 py-0',
className,
)}
>
{label}
</Badge>
)
}