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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user