feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
- processRoundClose EVALUATION uses ranking scores + advanceMode config (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED - Advancement emails generate invite tokens for passwordless users with "Create Your Account" CTA; rejection emails have no link - Finalization UI shows account stats (invite vs dashboard link counts) - Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson) - New award pool notification system: getAwardSelectionNotificationTemplate email, notifyEligibleProjects mutation with invite token generation, "Notify Pool" button on award detail page with custom message dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
82
src/components/admin/round/bulk-invite-button.tsx
Normal file
82
src/components/admin/round/bulk-invite-button.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { UserPlus, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
interface BulkInviteButtonProps {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function BulkInviteButton({ roundId }: BulkInviteButtonProps) {
|
||||
const preview = trpc.round.getBulkInvitePreview.useQuery({ roundId })
|
||||
const inviteMutation = trpc.round.bulkInviteTeamMembers.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
`Invited ${data.invited} team member${data.invited !== 1 ? 's' : ''}${data.skipped ? ` (${data.skipped} already active/invited)` : ''}`
|
||||
)
|
||||
void preview.refetch()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const uninvited = preview.data?.uninvitedCount ?? 0
|
||||
if (uninvited === 0 && !preview.isLoading) return null
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-blue-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
|
||||
<UserPlus className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Invite Team Members</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{preview.isLoading
|
||||
? 'Checking...'
|
||||
: `${uninvited} team member${uninvited !== 1 ? 's' : ''} need invitations`}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Invite team members?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will send invitation emails to {uninvited} team member
|
||||
{uninvited !== 1 ? 's' : ''} who haven't been invited yet.
|
||||
{preview.data?.alreadyInvitedCount
|
||||
? ` (${preview.data.alreadyInvitedCount} already invited)`
|
||||
: ''}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => inviteMutation.mutate({ roundId })}
|
||||
disabled={inviteMutation.isPending}
|
||||
>
|
||||
{inviteMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
`Send ${uninvited} Invitation${uninvited !== 1 ? 's' : ''}`
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
130
src/components/admin/round/email-preview-dialog.tsx
Normal file
130
src/components/admin/round/email-preview-dialog.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Mail, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface EmailPreviewDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
recipientCount: number
|
||||
previewHtml: string | undefined
|
||||
isPreviewLoading: boolean
|
||||
onSend: (customMessage?: string) => void
|
||||
isSending: boolean
|
||||
showCustomMessage?: boolean
|
||||
onRefreshPreview?: (customMessage?: string) => void
|
||||
}
|
||||
|
||||
export function EmailPreviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
recipientCount,
|
||||
previewHtml,
|
||||
isPreviewLoading,
|
||||
onSend,
|
||||
isSending,
|
||||
showCustomMessage = true,
|
||||
onRefreshPreview,
|
||||
}: EmailPreviewDialogProps) {
|
||||
const [customMessage, setCustomMessage] = useState('')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden grid grid-cols-1 md:grid-cols-2 gap-4 min-h-0">
|
||||
{/* Left: Custom message */}
|
||||
{showCustomMessage && (
|
||||
<div className="space-y-3">
|
||||
<Label>Custom Message (optional)</Label>
|
||||
<Textarea
|
||||
placeholder="Add a personal message to include in the email..."
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
className="min-h-[200px] resize-none"
|
||||
/>
|
||||
{onRefreshPreview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRefreshPreview(customMessage || undefined)}
|
||||
disabled={isPreviewLoading}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Refresh Preview
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: Email preview */}
|
||||
<div className="border rounded-lg overflow-hidden bg-gray-50">
|
||||
<div className="px-3 py-1.5 bg-gray-100 border-b text-xs text-muted-foreground font-medium">
|
||||
Email Preview
|
||||
</div>
|
||||
{isPreviewLoading ? (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : previewHtml ? (
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
sandbox="allow-same-origin"
|
||||
className="w-full h-[400px] border-0"
|
||||
title="Email Preview"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[300px] text-sm text-muted-foreground">
|
||||
No preview available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onSend(customMessage || undefined)}
|
||||
disabled={isSending || recipientCount === 0}
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send to {recipientCount} recipient{recipientCount !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
665
src/components/admin/round/finalization-tab.tsx
Normal file
665
src/components/admin/round/finalization-tab.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Send,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { projectStateConfig } from '@/lib/round-config'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FinalizationTabProps {
|
||||
roundId: string
|
||||
roundStatus: string
|
||||
}
|
||||
|
||||
type ProposedOutcome = 'PASSED' | 'REJECTED'
|
||||
|
||||
const stateColors: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
|
||||
)
|
||||
|
||||
const stateLabelColors: Record<string, string> = {
|
||||
PENDING: 'bg-gray-100 text-gray-700',
|
||||
IN_PROGRESS: 'bg-blue-100 text-blue-700',
|
||||
COMPLETED: 'bg-indigo-100 text-indigo-700',
|
||||
PASSED: 'bg-green-100 text-green-700',
|
||||
REJECTED: 'bg-red-100 text-red-700',
|
||||
WITHDRAWN: 'bg-yellow-100 text-yellow-700',
|
||||
}
|
||||
|
||||
// ── Main Component ─────────────────────────────────────────────────────────
|
||||
|
||||
export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: summary, isLoading } = trpc.roundEngine.getFinalizationSummary.useQuery(
|
||||
{ roundId },
|
||||
)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterOutcome, setFilterOutcome] = useState<'all' | 'PASSED' | 'REJECTED' | 'none'>('all')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
|
||||
const [advancementMessage, setAdvancementMessage] = useState('')
|
||||
const [rejectionMessage, setRejectionMessage] = useState('')
|
||||
|
||||
// Mutations
|
||||
const updateOutcome = trpc.roundEngine.updateProposedOutcome.useMutation({
|
||||
onSuccess: () => utils.roundEngine.getFinalizationSummary.invalidate({ roundId }),
|
||||
})
|
||||
|
||||
const batchUpdate = trpc.roundEngine.batchUpdateProposedOutcomes.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
||||
setSelectedIds(new Set())
|
||||
toast.success('Proposed outcomes updated')
|
||||
},
|
||||
})
|
||||
|
||||
const confirmMutation = trpc.roundEngine.confirmFinalization.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
||||
toast.success(
|
||||
`Finalized: ${data.advanced} advanced, ${data.rejected} rejected, ${data.emailsSent} emails sent`,
|
||||
)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const endGraceMutation = trpc.roundEngine.endGracePeriod.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
||||
toast.success('Grace period ended, projects processed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const processProjectsMutation = trpc.roundEngine.processRoundProjects.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
||||
toast.success(`Processed ${data.processed} projects — review proposed outcomes below`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Filtered projects
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!summary) return []
|
||||
return summary.projects.filter((p) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
p.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.teamName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.country?.toLowerCase().includes(search.toLowerCase())
|
||||
|
||||
const matchesFilter =
|
||||
filterOutcome === 'all' ||
|
||||
(filterOutcome === 'none' && !p.proposedOutcome) ||
|
||||
p.proposedOutcome === filterOutcome
|
||||
|
||||
return matchesSearch && matchesFilter
|
||||
})
|
||||
}, [summary, search, filterOutcome])
|
||||
|
||||
// Counts
|
||||
const passedCount = summary?.projects.filter((p) => p.proposedOutcome === 'PASSED').length ?? 0
|
||||
const rejectedCount = summary?.projects.filter((p) => p.proposedOutcome === 'REJECTED').length ?? 0
|
||||
const undecidedCount = summary?.projects.filter((p) => !p.proposedOutcome).length ?? 0
|
||||
|
||||
// Select all toggle
|
||||
const allSelected = filteredProjects.length > 0 && filteredProjects.every((p) => selectedIds.has(p.id))
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredProjects.map((p) => p.id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk set outcome
|
||||
const handleBulkSetOutcome = (outcome: ProposedOutcome) => {
|
||||
const outcomes: Record<string, ProposedOutcome> = {}
|
||||
for (const id of selectedIds) {
|
||||
outcomes[id] = outcome
|
||||
}
|
||||
batchUpdate.mutate({ roundId, outcomes })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Grace Period Banner */}
|
||||
{summary.isGracePeriodActive && (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800 dark:text-amber-200">Grace Period Active</p>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
Applicants can still submit until{' '}
|
||||
{summary.gracePeriodEndsAt
|
||||
? new Date(summary.gracePeriodEndsAt).toLocaleString()
|
||||
: 'the grace period ends'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-amber-300 hover:bg-amber-100"
|
||||
onClick={() => endGraceMutation.mutate({ roundId })}
|
||||
disabled={endGraceMutation.isPending}
|
||||
>
|
||||
{endGraceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
End Grace Period
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Finalized Banner */}
|
||||
{summary.isFinalized && (
|
||||
<Card className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium text-green-800 dark:text-green-200">Round Finalized</p>
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
Finalized on{' '}
|
||||
{summary.finalizedAt
|
||||
? new Date(summary.finalizedAt).toLocaleString()
|
||||
: 'unknown date'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Needs Processing Banner */}
|
||||
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
|
||||
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20">
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-800 dark:text-blue-200">Projects Need Processing</p>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
|
||||
Click "Process" to auto-assign outcomes based on round type and project activity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-300 hover:bg-blue-100"
|
||||
onClick={() => processProjectsMutation.mutate({ roundId })}
|
||||
disabled={processProjectsMutation.isPending}
|
||||
>
|
||||
{processProjectsMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Process Projects
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summary Stats Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{([
|
||||
['Pending', summary.stats.pending, 'bg-gray-100 text-gray-700'],
|
||||
['In Progress', summary.stats.inProgress, 'bg-blue-100 text-blue-700'],
|
||||
['Completed', summary.stats.completed, 'bg-indigo-100 text-indigo-700'],
|
||||
['Passed', summary.stats.passed, 'bg-green-100 text-green-700'],
|
||||
['Rejected', summary.stats.rejected, 'bg-red-100 text-red-700'],
|
||||
['Withdrawn', summary.stats.withdrawn, 'bg-yellow-100 text-yellow-700'],
|
||||
] as const).map(([label, count, cls]) => (
|
||||
<div key={label} className="rounded-lg border p-3 text-center">
|
||||
<div className="text-2xl font-bold">{count}</div>
|
||||
<div className={cn('text-xs font-medium mt-1 inline-flex px-2 py-0.5 rounded-full', cls)}>{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Category Target Progress */}
|
||||
{(summary.categoryTargets.startupTarget != null || summary.categoryTargets.conceptTarget != null) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Advancement Targets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{summary.categoryTargets.startupTarget != null && (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Startup</span>
|
||||
<span className="font-medium">
|
||||
{summary.categoryTargets.startupProposed} / {summary.categoryTargets.startupTarget}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
summary.categoryTargets.startupProposed > summary.categoryTargets.startupTarget
|
||||
? 'bg-amber-500'
|
||||
: 'bg-green-500',
|
||||
)}
|
||||
style={{
|
||||
width: `${Math.min(100, (summary.categoryTargets.startupProposed / summary.categoryTargets.startupTarget) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{summary.categoryTargets.conceptTarget != null && (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Business Concept</span>
|
||||
<span className="font-medium">
|
||||
{summary.categoryTargets.conceptProposed} / {summary.categoryTargets.conceptTarget}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
summary.categoryTargets.conceptProposed > summary.categoryTargets.conceptTarget
|
||||
? 'bg-amber-500'
|
||||
: 'bg-green-500',
|
||||
)}
|
||||
style={{
|
||||
width: `${Math.min(100, (summary.categoryTargets.conceptProposed / summary.categoryTargets.conceptTarget) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Proposed Outcomes Table */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">Proposed Outcomes</CardTitle>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||
{passedCount} advancing
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
|
||||
{rejectedCount} rejected
|
||||
</Badge>
|
||||
{undecidedCount > 0 && (
|
||||
<Badge variant="outline">{undecidedCount} undecided</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Search + Filter */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filterOutcome} onValueChange={(v) => setFilterOutcome(v as typeof filterOutcome)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by outcome" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All projects</SelectItem>
|
||||
<SelectItem value="PASSED">Proposed: Pass</SelectItem>
|
||||
<SelectItem value="REJECTED">Proposed: Reject</SelectItem>
|
||||
<SelectItem value="none">Undecided</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedIds.size > 0 && !summary.isFinalized && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
|
||||
<span className="text-sm font-medium">{selectedIds.size} selected</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkSetOutcome('PASSED')}
|
||||
disabled={batchUpdate.isPending}
|
||||
>
|
||||
Set as Pass
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkSetOutcome('REJECTED')}
|
||||
disabled={batchUpdate.isPending}
|
||||
className="text-destructive"
|
||||
>
|
||||
Set as Reject
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{!summary.isFinalized && (
|
||||
<th className="w-10 px-3 py-2.5">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left px-3 py-2.5 font-medium">Project</th>
|
||||
<th className="text-left px-3 py-2.5 font-medium hidden sm:table-cell">Category</th>
|
||||
<th className="text-left px-3 py-2.5 font-medium hidden md:table-cell">Country</th>
|
||||
<th className="text-center px-3 py-2.5 font-medium">Current State</th>
|
||||
{summary.roundType === 'EVALUATION' && (
|
||||
<th className="text-center px-3 py-2.5 font-medium hidden lg:table-cell">Score / Rank</th>
|
||||
)}
|
||||
<th className="text-center px-3 py-2.5 font-medium w-[160px]">Proposed Outcome</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredProjects.map((project) => (
|
||||
<tr key={project.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
{!summary.isFinalized && (
|
||||
<td className="px-3 py-2.5">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = new Set(selectedIds)
|
||||
if (checked) next.add(project.id)
|
||||
else next.delete(project.id)
|
||||
setSelectedIds(next)
|
||||
}}
|
||||
aria-label={`Select ${project.title}`}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="font-medium truncate max-w-[200px]">{project.title}</div>
|
||||
{project.teamName && (
|
||||
<div className="text-xs text-muted-foreground truncate">{project.teamName}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 hidden sm:table-cell text-muted-foreground">
|
||||
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
|
||||
{project.country ?? '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}
|
||||
>
|
||||
{project.currentState.replace('_', ' ')}
|
||||
</Badge>
|
||||
</td>
|
||||
{summary.roundType === 'EVALUATION' && (
|
||||
<td className="px-3 py-2.5 text-center hidden lg:table-cell text-muted-foreground">
|
||||
{project.evaluationScore != null
|
||||
? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})`
|
||||
: '-'}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
{summary.isFinalized ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
project.proposedOutcome === 'PASSED' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700',
|
||||
)}
|
||||
>
|
||||
{project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Select
|
||||
value={project.proposedOutcome ?? 'undecided'}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'undecided') return
|
||||
updateOutcome.mutate({
|
||||
roundId,
|
||||
projectId: project.id,
|
||||
proposedOutcome: v as 'PASSED' | 'REJECTED',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'h-8 w-[130px] text-xs mx-auto',
|
||||
project.proposedOutcome === 'PASSED' && 'border-green-300 bg-green-50 text-green-700',
|
||||
project.proposedOutcome === 'REJECTED' && 'border-red-300 bg-red-50 text-red-700',
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="undecided" disabled>Undecided</SelectItem>
|
||||
<SelectItem value="PASSED">Pass</SelectItem>
|
||||
<SelectItem value="REJECTED">Reject</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredProjects.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={summary.isFinalized ? 6 : 7}
|
||||
className="px-3 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
No projects match your search/filter
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Customization + Confirm */}
|
||||
{!summary.isFinalized && !summary.isGracePeriodActive && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<button
|
||||
className="flex items-center gap-2 text-left w-full"
|
||||
onClick={() => setEmailSectionOpen(!emailSectionOpen)}
|
||||
>
|
||||
{emailSectionOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<Mail className="h-4 w-4" />
|
||||
<CardTitle className="text-base">Email Customization</CardTitle>
|
||||
<span className="text-xs text-muted-foreground ml-2">(optional)</span>
|
||||
</button>
|
||||
</CardHeader>
|
||||
{emailSectionOpen && (
|
||||
<CardContent className="space-y-4">
|
||||
{/* Account stats */}
|
||||
{(summary.accountStats.needsInvite > 0 || summary.accountStats.hasAccount > 0) && (
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/50 border text-sm">
|
||||
<Mail className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{summary.accountStats.needsInvite > 0 && (
|
||||
<span>
|
||||
<strong>{summary.accountStats.needsInvite}</strong> project{summary.accountStats.needsInvite !== 1 ? 's' : ''} will receive a{' '}
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 text-xs">Create Account</Badge>{' '}
|
||||
invite link
|
||||
</span>
|
||||
)}
|
||||
{summary.accountStats.hasAccount > 0 && (
|
||||
<span>
|
||||
<strong>{summary.accountStats.hasAccount}</strong> project{summary.accountStats.hasAccount !== 1 ? 's' : ''} will receive a{' '}
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs">View Dashboard</Badge>{' '}
|
||||
link
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Advancement Message</label>
|
||||
<Textarea
|
||||
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
|
||||
value={advancementMessage}
|
||||
onChange={(e) => setAdvancementMessage(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Rejection Message</label>
|
||||
<Textarea
|
||||
placeholder="Custom message for projects that are not advancing (added to the standard email template)..."
|
||||
value={rejectionMessage}
|
||||
onChange={(e) => setRejectionMessage(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{summary.nextRound ? (
|
||||
<span>
|
||||
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
|
||||
<strong>{summary.nextRound.name}</strong>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will be marked as passed
|
||||
</span>
|
||||
)}
|
||||
{' | '}
|
||||
<strong>{rejectedCount}</strong> rejected
|
||||
{undecidedCount > 0 && (
|
||||
<span className="text-amber-600"> | {undecidedCount} undecided (will not be processed)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
disabled={passedCount + rejectedCount === 0 || confirmMutation.isPending}
|
||||
>
|
||||
{confirmMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
Finalize Round
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Finalization</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>This will:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
|
||||
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
|
||||
{summary.nextRound && (
|
||||
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
|
||||
)}
|
||||
<li>Send email notifications to all affected teams</li>
|
||||
</ul>
|
||||
{undecidedCount > 0 && (
|
||||
<p className="text-amber-600 flex items-center gap-1.5 mt-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{undecidedCount} project{undecidedCount !== 1 ? 's' : ''} with no proposed outcome will not be affected.
|
||||
</p>
|
||||
)}
|
||||
<p className="font-medium mt-3">This action cannot be undone.</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
confirmMutation.mutate({
|
||||
roundId,
|
||||
targetRoundId: summary.nextRound?.id,
|
||||
advancementMessage: advancementMessage || undefined,
|
||||
rejectionMessage: rejectionMessage || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Yes, Finalize Round
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
src/components/admin/round/notify-advanced-button.tsx
Normal file
62
src/components/admin/round/notify-advanced-button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Trophy } from 'lucide-react'
|
||||
import { EmailPreviewDialog } from './email-preview-dialog'
|
||||
|
||||
interface NotifyAdvancedButtonProps {
|
||||
roundId: string
|
||||
targetRoundId?: string
|
||||
}
|
||||
|
||||
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customMessage, setCustomMessage] = useState<string | undefined>()
|
||||
|
||||
const preview = trpc.round.previewAdvancementEmail.useQuery(
|
||||
{ roundId, targetRoundId, customMessage },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
const sendMutation = trpc.round.sendAdvancementNotifications.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
`Sent ${data.sent} notification${data.sent !== 1 ? 's' : ''}${data.failed ? ` (${data.failed} failed)` : ''}`
|
||||
)
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
|
||||
>
|
||||
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Notify Advanced Teams</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Send advancement emails to passed projects
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<EmailPreviewDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Notify Advanced Teams"
|
||||
description="Send advancement notification emails to project team members"
|
||||
recipientCount={preview.data?.recipientCount ?? 0}
|
||||
previewHtml={preview.data?.html}
|
||||
isPreviewLoading={preview.isLoading}
|
||||
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })}
|
||||
isSending={sendMutation.isPending}
|
||||
onRefreshPreview={(msg) => setCustomMessage(msg)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
61
src/components/admin/round/notify-rejected-button.tsx
Normal file
61
src/components/admin/round/notify-rejected-button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { XCircle } from 'lucide-react'
|
||||
import { EmailPreviewDialog } from './email-preview-dialog'
|
||||
|
||||
interface NotifyRejectedButtonProps {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customMessage, setCustomMessage] = useState<string | undefined>()
|
||||
|
||||
const preview = trpc.round.previewRejectionEmail.useQuery(
|
||||
{ roundId, customMessage },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
const sendMutation = trpc.round.sendRejectionNotifications.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
`Sent ${data.sent} notification${data.sent !== 1 ? 's' : ''}${data.failed ? ` (${data.failed} failed)` : ''}`
|
||||
)
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
|
||||
>
|
||||
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Notify Non-Advanced</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Send rejection emails to non-advanced projects
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<EmailPreviewDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Notify Non-Advanced Teams"
|
||||
description="Send rejection notification emails to project team members"
|
||||
recipientCount={preview.data?.recipientCount ?? 0}
|
||||
previewHtml={preview.data?.html}
|
||||
isPreviewLoading={preview.isLoading}
|
||||
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg })}
|
||||
isSending={sendMutation.isPending}
|
||||
onRefreshPreview={(msg) => setCustomMessage(msg)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -76,13 +76,23 @@ const stateConfig: Record<ProjectState, { label: string; color: string; icon: Re
|
||||
WITHDRAWN: { label: 'Withdrawn', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: LogOut },
|
||||
}
|
||||
|
||||
type CompetitionRound = {
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
_count: { projectRoundStates: number }
|
||||
}
|
||||
|
||||
type ProjectStatesTableProps = {
|
||||
competitionId: string
|
||||
roundId: string
|
||||
roundStatus?: string
|
||||
competitionRounds?: CompetitionRound[]
|
||||
currentSortOrder?: number
|
||||
onAssignProjects?: (projectIds: string[]) => void
|
||||
}
|
||||
|
||||
export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }: ProjectStatesTableProps) {
|
||||
export function ProjectStatesTable({ competitionId, roundId, roundStatus, competitionRounds, currentSortOrder, onAssignProjects }: ProjectStatesTableProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [stateFilter, setStateFilter] = useState<string>('ALL')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -226,32 +236,65 @@ export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }:
|
||||
)
|
||||
}
|
||||
|
||||
const hasEarlierRounds = competitionRounds && currentSortOrder != null &&
|
||||
competitionRounds.some((r) => r.sortOrder < currentSortOrder && r._count.projectRoundStates > 0)
|
||||
|
||||
if (!projectStates || projectStates.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="rounded-full bg-muted p-4 mb-4">
|
||||
<Layers className="h-8 w-8 text-muted-foreground" />
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="rounded-full bg-muted p-4 mb-4">
|
||||
<Layers className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">No Projects in This Round</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
|
||||
Assign projects from the Project Pool or import from an earlier round to get started.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Link href={poolLink}>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Go to Project Pool
|
||||
</Button>
|
||||
</Link>
|
||||
{hasEarlierRounds && (
|
||||
<Button size="sm" onClick={() => { setAddProjectOpen(true) }}>
|
||||
<ArrowRight className="h-4 w-4 mr-1.5" />
|
||||
Import from Earlier Round
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium">No Projects in This Round</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
|
||||
Assign projects from the Project Pool to this round to get started.
|
||||
</p>
|
||||
<Link href={poolLink}>
|
||||
<Button size="sm" className="mt-4">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Go to Project Pool
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AddProjectDialog
|
||||
open={addProjectOpen}
|
||||
onOpenChange={setAddProjectOpen}
|
||||
roundId={roundId}
|
||||
competitionId={competitionId}
|
||||
competitionRounds={competitionRounds}
|
||||
currentSortOrder={currentSortOrder}
|
||||
defaultTab={hasEarlierRounds ? 'round' : 'create'}
|
||||
onAssigned={() => {
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Finalization hint for closed rounds */}
|
||||
{(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 px-4 py-3 text-sm">
|
||||
<span className="text-blue-700 dark:text-blue-300">
|
||||
This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Top bar: search + filters + add buttons */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
@@ -496,12 +539,14 @@ export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }:
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Add Project Dialog (Create New + From Pool) */}
|
||||
{/* Add Project Dialog (Create New + From Pool + From Round) */}
|
||||
<AddProjectDialog
|
||||
open={addProjectOpen}
|
||||
onOpenChange={setAddProjectOpen}
|
||||
roundId={roundId}
|
||||
competitionId={competitionId}
|
||||
competitionRounds={competitionRounds}
|
||||
currentSortOrder={currentSortOrder}
|
||||
onAssigned={() => {
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
}}
|
||||
@@ -744,15 +789,21 @@ function AddProjectDialog({
|
||||
onOpenChange,
|
||||
roundId,
|
||||
competitionId,
|
||||
competitionRounds,
|
||||
currentSortOrder,
|
||||
defaultTab,
|
||||
onAssigned,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
roundId: string
|
||||
competitionId: string
|
||||
competitionRounds?: CompetitionRound[]
|
||||
currentSortOrder?: number
|
||||
defaultTab?: 'create' | 'pool' | 'round'
|
||||
onAssigned: () => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
|
||||
const [activeTab, setActiveTab] = useState<'create' | 'pool' | 'round'>(defaultTab ?? 'create')
|
||||
|
||||
// ── Create New tab state ──
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -765,6 +816,12 @@ function AddProjectDialog({
|
||||
const [poolSearch, setPoolSearch] = useState('')
|
||||
const [selectedPoolIds, setSelectedPoolIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// ── From Round tab state ──
|
||||
const [sourceRoundId, setSourceRoundId] = useState('')
|
||||
const [roundStateFilter, setRoundStateFilter] = useState<string[]>([])
|
||||
const [roundSearch, setRoundSearch] = useState('')
|
||||
const [selectedRoundIds, setSelectedRoundIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Get the competition to find programId (for pool search)
|
||||
@@ -774,6 +831,34 @@ function AddProjectDialog({
|
||||
)
|
||||
const programId = (competition as any)?.programId || ''
|
||||
|
||||
// Earlier rounds available for import
|
||||
const earlierRounds = useMemo(() => {
|
||||
if (!competitionRounds || currentSortOrder == null) return []
|
||||
return competitionRounds
|
||||
.filter((r) => r.sortOrder < currentSortOrder && r._count.projectRoundStates > 0)
|
||||
}, [competitionRounds, currentSortOrder])
|
||||
|
||||
// From Round query
|
||||
const { data: roundProjects, isLoading: roundLoading } = trpc.projectPool.getProjectsInRound.useQuery(
|
||||
{
|
||||
roundId: sourceRoundId,
|
||||
states: roundStateFilter.length > 0 ? roundStateFilter : undefined,
|
||||
search: roundSearch.trim() || undefined,
|
||||
},
|
||||
{ enabled: open && activeTab === 'round' && !!sourceRoundId },
|
||||
)
|
||||
|
||||
// Import mutation
|
||||
const importMutation = trpc.projectPool.importFromRound.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.imported} project(s) imported${data.skipped > 0 ? `, ${data.skipped} already in round` : ''}`)
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
onAssigned()
|
||||
resetAndClose()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Pool query
|
||||
const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
|
||||
{
|
||||
@@ -815,6 +900,10 @@ function AddProjectDialog({
|
||||
setCategory('')
|
||||
setPoolSearch('')
|
||||
setSelectedPoolIds(new Set())
|
||||
setSourceRoundId('')
|
||||
setRoundStateFilter([])
|
||||
setRoundSearch('')
|
||||
setSelectedRoundIds(new Set())
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
@@ -838,6 +927,24 @@ function AddProjectDialog({
|
||||
})
|
||||
}
|
||||
|
||||
const handleImportFromRound = () => {
|
||||
if (selectedRoundIds.size === 0 || !sourceRoundId) return
|
||||
importMutation.mutate({
|
||||
sourceRoundId,
|
||||
targetRoundId: roundId,
|
||||
projectIds: Array.from(selectedRoundIds),
|
||||
})
|
||||
}
|
||||
|
||||
const toggleRoundProject = (id: string) => {
|
||||
setSelectedRoundIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const togglePoolProject = (id: string) => {
|
||||
setSelectedPoolIds(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -847,7 +954,7 @@ function AddProjectDialog({
|
||||
})
|
||||
}
|
||||
|
||||
const isMutating = createMutation.isPending || assignMutation.isPending
|
||||
const isMutating = createMutation.isPending || assignMutation.isPending || importMutation.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
@@ -862,10 +969,13 @@ function AddProjectDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool' | 'round')}>
|
||||
<TabsList className={`grid w-full ${earlierRounds.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}>
|
||||
<TabsTrigger value="create">Create New</TabsTrigger>
|
||||
<TabsTrigger value="pool">From Pool</TabsTrigger>
|
||||
{earlierRounds.length > 0 && (
|
||||
<TabsTrigger value="round">From Round</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* ── Create New Tab ── */}
|
||||
@@ -1012,6 +1122,158 @@ function AddProjectDialog({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── From Round Tab ── */}
|
||||
{earlierRounds.length > 0 && (
|
||||
<TabsContent value="round" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Source Round</Label>
|
||||
<Select value={sourceRoundId} onValueChange={(v) => {
|
||||
setSourceRoundId(v)
|
||||
setSelectedRoundIds(new Set())
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{earlierRounds.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name} ({r._count.projectRoundStates} projects)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{sourceRoundId && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by title or team..."
|
||||
value={roundSearch}
|
||||
onChange={(e) => setRoundSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{['PASSED', 'COMPLETED', 'PENDING', 'IN_PROGRESS', 'REJECTED'].map((state) => {
|
||||
const isActive = roundStateFilter.includes(state)
|
||||
return (
|
||||
<button
|
||||
key={state}
|
||||
onClick={() => {
|
||||
setRoundStateFilter(prev =>
|
||||
isActive ? prev.filter(s => s !== state) : [...prev, state]
|
||||
)
|
||||
setSelectedRoundIds(new Set())
|
||||
}}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
|
||||
isActive
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{state.charAt(0) + state.slice(1).toLowerCase().replace('_', ' ')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[280px] rounded-md border">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{roundLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!roundLoading && roundProjects?.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{roundSearch.trim() ? `No projects found matching "${roundSearch}"` : 'No projects in this round'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{roundProjects && roundProjects.length > 0 && (
|
||||
<label
|
||||
className="flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer hover:bg-muted/50 border-b mb-1"
|
||||
>
|
||||
<Checkbox
|
||||
checked={roundProjects.length > 0 && roundProjects.every(p => selectedRoundIds.has(p.id))}
|
||||
onCheckedChange={() => {
|
||||
const allIds = roundProjects.map(p => p.id)
|
||||
const allSelected = allIds.every(id => selectedRoundIds.has(id))
|
||||
if (allSelected) {
|
||||
setSelectedRoundIds(new Set())
|
||||
} else {
|
||||
setSelectedRoundIds(new Set(allIds))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Select all ({roundProjects.length})
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{roundProjects?.map((project) => {
|
||||
const isSelected = selectedRoundIds.has(project.id)
|
||||
return (
|
||||
<label
|
||||
key={project.id}
|
||||
className={`flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-accent' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleRoundProject(project.id)}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
{project.country && <> · {project.country}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{project.state.charAt(0) + project.state.slice(1).toLowerCase().replace('_', ' ')}
|
||||
</Badge>
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleImportFromRound}
|
||||
disabled={selectedRoundIds.size === 0 || isMutating}
|
||||
>
|
||||
{importMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
{selectedRoundIds.size <= 1
|
||||
? 'Import to Round'
|
||||
: `Import ${selectedRoundIds.size} Projects`
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
||||
import { CheckCircle2, Circle, Clock, XCircle, Trophy, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
|
||||
ROUND_DRAFT: { label: 'Upcoming', variant: 'secondary' },
|
||||
@@ -166,7 +167,7 @@ export function ApplicantCompetitionTimeline() {
|
||||
|
||||
/**
|
||||
* Compact sidebar variant for the dashboard.
|
||||
* Shows dots + labels, no date details.
|
||||
* Animated timeline with connector indicators between dots.
|
||||
*/
|
||||
export function CompetitionTimelineSidebar() {
|
||||
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
|
||||
@@ -185,54 +186,123 @@ export function CompetitionTimelineSidebar() {
|
||||
return <p className="text-sm text-muted-foreground">No rounds available</p>
|
||||
}
|
||||
|
||||
// Find the index where elimination happened (first REJECTED entry)
|
||||
const eliminationIndex = data.entries.findIndex((e) => e.projectState === 'REJECTED')
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{data.entries.map((entry, index) => {
|
||||
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||
const isRejected = entry.projectState === 'REJECTED'
|
||||
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||
const isLast = index === data.entries.length - 1
|
||||
<div className="relative">
|
||||
<div className="space-y-0">
|
||||
{data.entries.map((entry, index) => {
|
||||
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||
const isRejected = entry.projectState === 'REJECTED'
|
||||
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||
const isPassed = entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED'
|
||||
const isLast = index === data.entries.length - 1
|
||||
// Is this entry after the elimination point?
|
||||
const isAfterElimination = eliminationIndex >= 0 && index > eliminationIndex
|
||||
|
||||
let dotColor = 'border-2 border-muted bg-background'
|
||||
if (isRejected) dotColor = 'bg-destructive'
|
||||
else if (isGrandFinale && isCompleted) dotColor = 'bg-yellow-500'
|
||||
else if (isCompleted) dotColor = 'bg-primary'
|
||||
else if (isActive) dotColor = 'bg-primary ring-2 ring-primary/30'
|
||||
// Is this the current round the project is in (regardless of round status)?
|
||||
const isCurrent = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED'
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="relative flex gap-3">
|
||||
{/* Connecting line */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[7px] top-[20px] h-full w-0.5 bg-muted" />
|
||||
)}
|
||||
// Determine connector segment color (no icons, just colored lines)
|
||||
let connectorColor = 'bg-border'
|
||||
if ((isPassed || isCompleted) && !isAfterElimination) connectorColor = 'bg-emerald-400'
|
||||
else if (isRejected) connectorColor = 'bg-destructive/30'
|
||||
|
||||
{/* Dot */}
|
||||
<div className={`relative z-10 mt-1.5 h-4 w-4 rounded-full shrink-0 ${dotColor}`} />
|
||||
// Dot inner content
|
||||
let dotInner: React.ReactNode = null
|
||||
let dotClasses = 'border-2 border-muted-foreground/20 bg-background'
|
||||
|
||||
{/* Label */}
|
||||
<div className="flex-1 pb-4">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isRejected
|
||||
? 'text-destructive'
|
||||
: isCompleted || isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{entry.label}
|
||||
</p>
|
||||
{isRejected && (
|
||||
<p className="text-xs text-destructive">Not Selected</p>
|
||||
)}
|
||||
{isActive && (
|
||||
<p className="text-xs text-primary">In Progress</p>
|
||||
if (isAfterElimination) {
|
||||
dotClasses = 'border-2 border-muted/60 bg-muted/30'
|
||||
} else if (isRejected) {
|
||||
dotClasses = 'bg-destructive border-2 border-destructive'
|
||||
dotInner = <XCircle className="h-3.5 w-3.5 text-white" />
|
||||
} else if (isGrandFinale && (isCompleted || isPassed)) {
|
||||
dotClasses = 'bg-yellow-500 border-2 border-yellow-500'
|
||||
dotInner = <Trophy className="h-3.5 w-3.5 text-white" />
|
||||
} else if (isCompleted || isPassed) {
|
||||
dotClasses = 'bg-emerald-500 border-2 border-emerald-500'
|
||||
dotInner = <Check className="h-3.5 w-3.5 text-white" />
|
||||
} else if (isCurrent) {
|
||||
dotClasses = 'bg-amber-400 border-2 border-amber-400'
|
||||
dotInner = <span className="h-2.5 w-2.5 rounded-full bg-white animate-ping" style={{ animationDuration: '2s' }} />
|
||||
}
|
||||
|
||||
// Status sub-label
|
||||
let statusLabel: string | null = null
|
||||
let statusColor = 'text-muted-foreground'
|
||||
if (isRejected) {
|
||||
statusLabel = 'Eliminated'
|
||||
statusColor = 'text-destructive'
|
||||
} else if (isAfterElimination) {
|
||||
statusLabel = null
|
||||
} else if (isPassed) {
|
||||
statusLabel = 'Advanced'
|
||||
statusColor = 'text-emerald-600'
|
||||
} else if (isCurrent) {
|
||||
statusLabel = 'You are here'
|
||||
statusColor = 'text-amber-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="animate-in fade-in slide-in-from-left-2 fill-mode-both"
|
||||
style={{ animationDelay: `${index * 100}ms`, animationDuration: '400ms' }}
|
||||
>
|
||||
{/* Row: dot + label */}
|
||||
<div className={cn(
|
||||
'group relative flex items-center gap-3.5 rounded-lg px-1.5 py-1.5 -mx-1.5 transition-colors duration-200 hover:bg-muted/40',
|
||||
isCurrent && 'bg-amber-50/60 hover:bg-amber-50/80',
|
||||
)}>
|
||||
{/* Dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 h-7 w-7 rounded-full shrink-0 flex items-center justify-center shadow-sm transition-all duration-300',
|
||||
isCurrent && 'ring-4 ring-amber-400/25',
|
||||
isPassed && !isRejected && !isAfterElimination && 'ring-[3px] ring-emerald-500/15',
|
||||
dotClasses,
|
||||
)}
|
||||
>
|
||||
{dotInner}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn('text-sm leading-5 font-medium transition-colors duration-200', {
|
||||
'text-destructive line-through decoration-destructive/40': isRejected,
|
||||
'text-muted-foreground/40': isAfterElimination,
|
||||
'text-foreground font-semibold': isCurrent,
|
||||
'text-foreground': !isCurrent && !isRejected && !isAfterElimination && (isCompleted || isActive || isPassed),
|
||||
'text-muted-foreground': !isRejected && !isAfterElimination && !isCompleted && !isActive && !isPassed,
|
||||
})}
|
||||
>
|
||||
{entry.label}
|
||||
</p>
|
||||
{statusLabel && (
|
||||
<p className={cn('text-xs mt-0.5 font-semibold tracking-wide uppercase', statusColor)}>{statusLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connector line between dots */}
|
||||
{!isLast && (
|
||||
<div className="flex items-center ml-[13px] h-5">
|
||||
<div
|
||||
className={cn(
|
||||
'w-[2px] h-full rounded-full transition-all duration-500',
|
||||
connectorColor,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
133
src/components/applicant/mentoring-request-card.tsx
Normal file
133
src/components/applicant/mentoring-request-card.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { MessageSquare, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MentoringRequestCardProps {
|
||||
projectId: string
|
||||
roundId: string
|
||||
roundName: string
|
||||
}
|
||||
|
||||
export function MentoringRequestCard({ projectId, roundId, roundName }: MentoringRequestCardProps) {
|
||||
const [timeLeft, setTimeLeft] = useState('')
|
||||
|
||||
const { data: status, isLoading } = trpc.applicant.getMentoringRequestStatus.useQuery(
|
||||
{ projectId, roundId },
|
||||
{ refetchInterval: 60_000 },
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const requestMutation = trpc.applicant.requestMentoring.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.requesting ? 'Mentoring requested' : 'Mentoring request cancelled')
|
||||
utils.applicant.getMentoringRequestStatus.invalidate({ projectId, roundId })
|
||||
utils.applicant.getMyDashboard.invalidate()
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (!status?.deadline) return
|
||||
const update = () => {
|
||||
const now = new Date()
|
||||
const deadline = new Date(status.deadline!)
|
||||
const diff = deadline.getTime() - now.getTime()
|
||||
if (diff <= 0) {
|
||||
setTimeLeft('Expired')
|
||||
return
|
||||
}
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||
if (days > 0) setTimeLeft(`${days}d ${hours}h remaining`)
|
||||
else if (hours > 0) setTimeLeft(`${hours}h ${minutes}m remaining`)
|
||||
else setTimeLeft(`${minutes}m remaining`)
|
||||
}
|
||||
update()
|
||||
const interval = setInterval(update, 60_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [status?.deadline])
|
||||
|
||||
if (isLoading || !status?.available) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Mentoring — {roundName}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status.requested ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Mentoring requested</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.requestedAt
|
||||
? `Requested on ${new Date(status.requestedAt).toLocaleDateString()}`
|
||||
: 'Awaiting mentor assignment'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<XCircle className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Not requesting mentoring</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You will advance automatically without a mentoring period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deadline info */}
|
||||
{status.deadline && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Request window:</span>
|
||||
{status.canStillRequest ? (
|
||||
<Badge variant="outline" className="text-amber-600 border-amber-300">
|
||||
{timeLeft}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Closed</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{status.canStillRequest && (
|
||||
<Button
|
||||
variant={status.requested ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => requestMutation.mutate({ projectId, roundId, requesting: !status.requested })}
|
||||
disabled={requestMutation.isPending}
|
||||
>
|
||||
{requestMutation.isPending
|
||||
? 'Updating...'
|
||||
: status.requested
|
||||
? 'Cancel Mentoring Request'
|
||||
: 'Request Mentoring'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!status.canStillRequest && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
The mentoring request window has closed.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
70
src/components/applicant/withdraw-button.tsx
Normal file
70
src/components/applicant/withdraw-button.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface WithdrawButtonProps {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export function WithdrawButton({ projectId }: WithdrawButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const withdraw = trpc.applicant.withdrawFromCompetition.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Withdrawn from ${data.roundName}`)
|
||||
utils.applicant.getMyDashboard.invalidate()
|
||||
utils.applicant.getMyCompetitionTimeline.invalidate()
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
|
||||
<LogOut className="h-4 w-4 mr-1.5" />
|
||||
Withdraw
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Withdraw from Competition?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to withdraw your project from the current round?
|
||||
This action is immediate and cannot be undone by you.
|
||||
An administrator would need to re-include your project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => withdraw.mutate({ projectId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={withdraw.isPending}
|
||||
>
|
||||
{withdraw.isPending ? 'Withdrawing...' : 'Yes, Withdraw'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Home, Users, FileText, MessageSquare, Trophy, Star, BookOpen } from 'lucide-react'
|
||||
import { Home, FolderOpen, FileText, MessageSquare, Trophy, Star, BookOpen } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
||||
{ name: 'Team', href: '/applicant/team', icon: Users },
|
||||
{ name: 'Project', href: '/applicant/team', icon: FolderOpen },
|
||||
{ name: 'Competition', href: '/applicant/competition', icon: Trophy },
|
||||
{ name: 'Documents', href: '/applicant/documents', icon: FileText },
|
||||
...(flags?.hasEvaluationRounds
|
||||
|
||||
@@ -144,9 +144,9 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem disabled>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
<User className="mr-2 h-4 w-4 shrink-0" />
|
||||
{user.email}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -40,7 +40,7 @@ const OFFICE_MIME_TYPES = [
|
||||
|
||||
const OFFICE_EXTENSIONS = ['.pptx', '.ppt', '.docx', '.doc']
|
||||
|
||||
function isOfficeFile(mimeType: string, fileName: string): boolean {
|
||||
export function isOfficeFile(mimeType: string, fileName: string): boolean {
|
||||
if (OFFICE_MIME_TYPES.includes(mimeType)) return true
|
||||
const ext = fileName.toLowerCase().slice(fileName.lastIndexOf('.'))
|
||||
return OFFICE_EXTENSIONS.includes(ext)
|
||||
@@ -633,7 +633,7 @@ function FileDownloadButton({ file, className, label }: { file: ProjectFile; cla
|
||||
)
|
||||
}
|
||||
|
||||
function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
|
||||
export function FilePreview({ file, url }: { file: { mimeType: string; fileName: string }; url: string }) {
|
||||
if (file.mimeType.startsWith('video/')) {
|
||||
return (
|
||||
<video
|
||||
|
||||
@@ -252,6 +252,14 @@ export function NotificationBell() {
|
||||
}
|
||||
)
|
||||
|
||||
// Mark all notifications as read when popover opens
|
||||
useEffect(() => {
|
||||
if (open && isAuthenticated && unreadCount > 0) {
|
||||
markAllAsReadMutation.mutate()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ 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 { Upload, Loader2, ZoomIn, ImageIcon } from 'lucide-react'
|
||||
import { Upload, Loader2, Trash2, ZoomIn, ImageIcon } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -24,6 +24,7 @@ type ProjectLogoUploadProps = {
|
||||
projectId: string
|
||||
currentLogoUrl?: string | null
|
||||
onUploadComplete?: () => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const MAX_SIZE_MB = 5
|
||||
@@ -72,6 +73,7 @@ export function ProjectLogoUpload({
|
||||
projectId,
|
||||
currentLogoUrl,
|
||||
onUploadComplete,
|
||||
children,
|
||||
}: ProjectLogoUploadProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
||||
@@ -79,10 +81,12 @@ export function ProjectLogoUpload({
|
||||
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 getUploadUrl = trpc.applicant.getProjectLogoUploadUrl.useMutation()
|
||||
const confirmUpload = trpc.applicant.confirmProjectLogo.useMutation()
|
||||
const deleteLogo = trpc.applicant.deleteProjectLogo.useMutation()
|
||||
|
||||
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedPixels)
|
||||
@@ -148,6 +152,21 @@ export function ProjectLogoUpload({
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteLogo.mutateAsync({ projectId })
|
||||
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 })
|
||||
@@ -164,43 +183,48 @@ export function ProjectLogoUpload({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
|
||||
>
|
||||
{currentLogoUrl ? (
|
||||
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
{children || (
|
||||
<button
|
||||
type="button"
|
||||
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
|
||||
>
|
||||
{currentLogoUrl ? (
|
||||
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Project Logo</DialogTitle>
|
||||
<DialogDescription>
|
||||
{imageSrc
|
||||
? 'Drag to reposition and use the slider to zoom.'
|
||||
: 'Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP.'}
|
||||
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
|
||||
: `Upload a logo for your project. 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}
|
||||
showGrid={false}
|
||||
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
|
||||
@@ -213,6 +237,7 @@ export function ProjectLogoUpload({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Change image button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -227,6 +252,7 @@ export function ProjectLogoUpload({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Current logo preview */}
|
||||
{currentLogoUrl && (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
@@ -237,6 +263,7 @@ export function ProjectLogoUpload({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logo">Select image</Label>
|
||||
<Input
|
||||
@@ -253,6 +280,22 @@ export function ProjectLogoUpload({
|
||||
</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
|
||||
|
||||
@@ -13,17 +13,27 @@ import {
|
||||
Loader2,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
Download,
|
||||
FileText,
|
||||
Languages,
|
||||
Play,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatFileSize } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
|
||||
|
||||
function getMimeLabel(mime: string): string {
|
||||
if (mime === 'application/pdf') return 'PDF'
|
||||
if (mime.startsWith('image/')) return 'Images'
|
||||
if (mime === 'video/mp4') return 'MP4'
|
||||
if (mime === 'video/quicktime') return 'MOV'
|
||||
if (mime === 'video/webm') return 'WebM'
|
||||
if (mime.startsWith('video/')) return 'Video'
|
||||
if (mime.includes('wordprocessingml')) return 'Word'
|
||||
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'Word'
|
||||
if (mime.includes('spreadsheetml')) return 'Excel'
|
||||
if (mime.includes('presentationml')) return 'PowerPoint'
|
||||
if (mime.includes('presentationml') || mime === 'application/vnd.ms-powerpoint') return 'PowerPoint'
|
||||
if (mime.endsWith('/*')) return mime.replace('/*', '')
|
||||
return mime
|
||||
}
|
||||
@@ -44,6 +54,11 @@ interface UploadedFile {
|
||||
size: number
|
||||
createdAt: string | Date
|
||||
requirementId?: string | null
|
||||
bucket?: string
|
||||
objectKey?: string
|
||||
pageCount?: number | null
|
||||
detectedLang?: string | null
|
||||
analyzedAt?: string | Date | null
|
||||
}
|
||||
|
||||
interface RequirementUploadSlotProps {
|
||||
@@ -55,6 +70,36 @@ interface RequirementUploadSlotProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: string }) {
|
||||
const { data } = trpc.file.getDownloadUrl.useQuery(
|
||||
{ bucket, objectKey, forDownload: false },
|
||||
{ staleTime: 10 * 60 * 1000 }
|
||||
)
|
||||
const href = typeof data === 'string' ? data : data?.url
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
|
||||
<a href={href || '#'} target="_blank" rel="noopener noreferrer">
|
||||
<Eye className="h-3 w-3" /> View
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadFileButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
|
||||
const { data } = trpc.file.getDownloadUrl.useQuery(
|
||||
{ bucket, objectKey, forDownload: true, fileName },
|
||||
{ staleTime: 10 * 60 * 1000 }
|
||||
)
|
||||
const href = typeof data === 'string' ? data : data?.url
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
|
||||
<a href={href || '#'} download={fileName}>
|
||||
<Download className="h-3 w-3" /> Download
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function RequirementUploadSlot({
|
||||
requirement,
|
||||
existingFile,
|
||||
@@ -66,6 +111,7 @@ export function RequirementUploadSlot({
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
|
||||
@@ -181,6 +227,20 @@ export function RequirementUploadSlot({
|
||||
}
|
||||
}, [existingFile, deleteFile, onFileChange])
|
||||
|
||||
// Fetch preview URL only when preview is toggled on
|
||||
const { data: previewUrlData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery(
|
||||
{ bucket: existingFile?.bucket || '', objectKey: existingFile?.objectKey || '', forDownload: false },
|
||||
{ enabled: showPreview && !!existingFile?.bucket && !!existingFile?.objectKey, staleTime: 10 * 60 * 1000 }
|
||||
)
|
||||
const previewUrl = typeof previewUrlData === 'string' ? previewUrlData : previewUrlData?.url
|
||||
|
||||
const canPreview = existingFile
|
||||
? existingFile.mimeType.startsWith('video/') ||
|
||||
existingFile.mimeType === 'application/pdf' ||
|
||||
existingFile.mimeType.startsWith('image/') ||
|
||||
isOfficeFile(existingFile.mimeType, existingFile.fileName)
|
||||
: false
|
||||
|
||||
const isFulfilled = !!existingFile
|
||||
const statusColor = isFulfilled
|
||||
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
|
||||
@@ -222,9 +282,9 @@ export function RequirementUploadSlot({
|
||||
)}
|
||||
|
||||
<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)}
|
||||
{[...new Set(requirement.acceptedMimeTypes.map(getMimeLabel))].map((label) => (
|
||||
<Badge key={label} variant="outline" className="text-xs">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{requirement.maxSizeMB && (
|
||||
@@ -235,10 +295,65 @@ export function RequirementUploadSlot({
|
||||
</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 className="ml-6 space-y-1.5">
|
||||
<div className="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>
|
||||
{existingFile.pageCount != null && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<FileText className="h-3 w-3" />
|
||||
{existingFile.pageCount} page{existingFile.pageCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{existingFile.detectedLang && existingFile.detectedLang !== 'und' && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Languages className="h-3 w-3" />
|
||||
{existingFile.detectedLang.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{existingFile.bucket && existingFile.objectKey && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{canPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs gap-1"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? (
|
||||
<><X className="h-3 w-3" /> Close Preview</>
|
||||
) : (
|
||||
<><Play className="h-3 w-3" /> Preview</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<ViewFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} />
|
||||
<DownloadFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} fileName={existingFile.fileName} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline preview panel */}
|
||||
{showPreview && existingFile && (
|
||||
<div className="ml-6 mt-2 rounded-lg border bg-muted/50 overflow-hidden">
|
||||
{isLoadingPreview ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
<FilePreview
|
||||
file={{ mimeType: existingFile.mimeType, fileName: existingFile.fileName }}
|
||||
url={previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<AlertCircle className="mr-2 h-4 w-4" />
|
||||
Failed to load preview
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -349,6 +464,11 @@ export function RequirementUploadList({ projectId, roundId, disabled }: Requirem
|
||||
size: existing.size,
|
||||
createdAt: existing.createdAt,
|
||||
requirementId: (existing as { requirementId?: string | null }).requirementId,
|
||||
bucket: (existing as { bucket?: string }).bucket,
|
||||
objectKey: (existing as { objectKey?: string }).objectKey,
|
||||
pageCount: (existing as { pageCount?: number | null }).pageCount,
|
||||
detectedLang: (existing as { detectedLang?: string | null }).detectedLang,
|
||||
analyzedAt: (existing as { analyzedAt?: string | null }).analyzedAt,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type UserAvatarProps = {
|
||||
email?: string | null
|
||||
profileImageKey?: string | null
|
||||
}
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
className?: string
|
||||
showEditOverlay?: boolean
|
||||
avatarUrl?: string | null
|
||||
@@ -23,6 +23,7 @@ const sizeClasses = {
|
||||
md: 'h-10 w-10',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16',
|
||||
'2xl': 'h-24 w-24',
|
||||
}
|
||||
|
||||
const textSizeClasses = {
|
||||
@@ -31,6 +32,7 @@ const textSizeClasses = {
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
xl: 'text-lg',
|
||||
'2xl': 'text-2xl',
|
||||
}
|
||||
|
||||
const iconSizeClasses = {
|
||||
@@ -39,6 +41,7 @@ const iconSizeClasses = {
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
xl: 'h-6 w-6',
|
||||
'2xl': 'h-8 w-8',
|
||||
}
|
||||
|
||||
export function UserAvatar({
|
||||
|
||||
Reference in New Issue
Block a user