Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import {
|
||||
Users,
|
||||
@@ -23,8 +24,11 @@ import {
|
||||
GraduationCap,
|
||||
Waves,
|
||||
Crown,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { getInitials, formatDateOnly } from '@/lib/utils'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
@@ -36,6 +40,13 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
// Completion status display
|
||||
const completionBadge: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
|
||||
in_progress: { label: 'In Progress', variant: 'secondary' },
|
||||
completed: { label: 'Completed', variant: 'default' },
|
||||
paused: { label: 'Paused', variant: 'outline' },
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -44,7 +55,8 @@ function DashboardSkeleton() {
|
||||
<Skeleton className="h-4 w-64 mt-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
</div>
|
||||
@@ -66,6 +78,8 @@ export default function MentorDashboard() {
|
||||
}
|
||||
|
||||
const projects = assignments || []
|
||||
const completedCount = projects.filter((a) => a.completionStatus === 'completed').length
|
||||
const inProgressCount = projects.filter((a) => a.completionStatus === 'in_progress').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -80,7 +94,7 @@ export default function MentorDashboard() {
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
@@ -96,6 +110,29 @@ export default function MentorDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Completed
|
||||
</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{completedCount}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{projects.length > 0 && (
|
||||
<Progress
|
||||
value={(completedCount / projects.length) * 100}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{projects.length > 0 ? Math.round((completedCount / projects.length) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
@@ -141,6 +178,7 @@ export default function MentorDashboard() {
|
||||
const teamLead = project.teamMembers?.find(
|
||||
(m) => m.role === 'LEAD'
|
||||
)
|
||||
const badge = completionBadge[assignment.completionStatus] || completionBadge.in_progress
|
||||
|
||||
return (
|
||||
<Card key={assignment.id}>
|
||||
@@ -153,12 +191,12 @@ export default function MentorDashboard() {
|
||||
</span>
|
||||
{project.round && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>-</span>
|
||||
<span>{project.round.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CardTitle className="flex items-center gap-2 flex-wrap">
|
||||
{project.title}
|
||||
{project.status && (
|
||||
<Badge
|
||||
@@ -167,6 +205,18 @@ export default function MentorDashboard() {
|
||||
{project.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={badge.variant}>
|
||||
{assignment.completionStatus === 'completed' && (
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{assignment.completionStatus === 'in_progress' && (
|
||||
<Circle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{assignment.completionStatus === 'paused' && (
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
{project.teamName && (
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
@@ -242,10 +292,13 @@ export default function MentorDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment date */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assigned {formatDateOnly(assignment.assignedAt)}
|
||||
</p>
|
||||
{/* Assignment date + last viewed */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Assigned {formatDateOnly(assignment.assignedAt)}</span>
|
||||
{assignment.lastViewedAt && (
|
||||
<span>Last viewed {formatDateOnly(assignment.lastViewedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use } from 'react'
|
||||
import { Suspense, use, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -15,6 +15,19 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
@@ -32,8 +45,19 @@ import {
|
||||
FileText,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
StickyNote,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Target,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly, getInitials } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -65,6 +89,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
},
|
||||
})
|
||||
|
||||
// Track view when project loads
|
||||
const trackView = trpc.mentor.trackView.useMutation()
|
||||
useEffect(() => {
|
||||
if (project?.mentorAssignment?.id) {
|
||||
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project?.mentorAssignment?.id])
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
}
|
||||
@@ -99,6 +132,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
|
||||
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
||||
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
||||
const mentorAssignmentId = project.mentorAssignment?.id
|
||||
const programId = project.round?.program?.id
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -126,7 +161,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</span>
|
||||
{project.round && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>-</span>
|
||||
<span>{project.round.name}</span>
|
||||
</>
|
||||
)}
|
||||
@@ -157,6 +192,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Milestones Section */}
|
||||
{programId && mentorAssignmentId && (
|
||||
<MilestonesSection
|
||||
programId={programId}
|
||||
mentorAssignmentId={mentorAssignmentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Private Notes Section */}
|
||||
{mentorAssignmentId && (
|
||||
<NotesSection mentorAssignmentId={mentorAssignmentId} />
|
||||
)}
|
||||
|
||||
{/* Project Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -359,6 +407,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<CardContent>
|
||||
{project.files && project.files.length > 0 ? (
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
files={project.files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
@@ -367,6 +416,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
version: f.version,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
@@ -404,6 +454,386 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Milestones Section
|
||||
// =============================================================================
|
||||
|
||||
function MilestonesSection({
|
||||
programId,
|
||||
mentorAssignmentId,
|
||||
}: {
|
||||
programId: string
|
||||
mentorAssignmentId: string
|
||||
}) {
|
||||
const { data: milestones, isLoading } = trpc.mentor.getMilestones.useQuery({ programId })
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const completeMutation = trpc.mentor.completeMilestone.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.mentor.getMilestones.invalidate({ programId })
|
||||
if (data.allRequiredDone) {
|
||||
toast.success('All required milestones completed!')
|
||||
} else {
|
||||
toast.success('Milestone completed')
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const uncompleteMutation = trpc.mentor.uncompleteMilestone.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getMilestones.invalidate({ programId })
|
||||
toast.success('Milestone unchecked')
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!milestones || milestones.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const completedCount = milestones.filter(
|
||||
(m) => m.myCompletions.length > 0
|
||||
).length
|
||||
const totalRequired = milestones.filter((m) => m.isRequired).length
|
||||
const requiredCompleted = milestones.filter(
|
||||
(m) => m.isRequired && m.myCompletions.length > 0
|
||||
).length
|
||||
|
||||
const handleToggle = (milestoneId: string, isCompleted: boolean) => {
|
||||
if (isCompleted) {
|
||||
uncompleteMutation.mutate({ milestoneId, mentorAssignmentId })
|
||||
} else {
|
||||
completeMutation.mutate({ milestoneId, mentorAssignmentId })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
Milestones
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">
|
||||
{completedCount}/{milestones.length} done
|
||||
</Badge>
|
||||
</div>
|
||||
{totalRequired > 0 && (
|
||||
<CardDescription>
|
||||
{requiredCompleted}/{totalRequired} required milestones completed
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{milestones.map((milestone) => {
|
||||
const isCompleted = milestone.myCompletions.length > 0
|
||||
const isPending = completeMutation.isPending || uncompleteMutation.isPending
|
||||
|
||||
return (
|
||||
<div
|
||||
key={milestone.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : ''
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
disabled={isPending}
|
||||
onCheckedChange={() => handleToggle(milestone.id, isCompleted)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium ${isCompleted ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{milestone.name}
|
||||
</p>
|
||||
{milestone.isRequired && (
|
||||
<Badge variant="outline" className="text-xs">Required</Badge>
|
||||
)}
|
||||
</div>
|
||||
{milestone.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{milestone.description}
|
||||
</p>
|
||||
)}
|
||||
{isCompleted && milestone.myCompletions[0] && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Completed {formatDateOnly(milestone.myCompletions[0].completedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Notes Section
|
||||
// =============================================================================
|
||||
|
||||
function NotesSection({ mentorAssignmentId }: { mentorAssignmentId: string }) {
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [noteContent, setNoteContent] = useState('')
|
||||
const [isVisibleToAdmin, setIsVisibleToAdmin] = useState(true)
|
||||
|
||||
const { data: notes, isLoading } = trpc.mentor.getNotes.useQuery({ mentorAssignmentId })
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const createMutation = trpc.mentor.createNote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getNotes.invalidate({ mentorAssignmentId })
|
||||
toast.success('Note saved')
|
||||
resetForm()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.mentor.updateNote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getNotes.invalidate({ mentorAssignmentId })
|
||||
toast.success('Note updated')
|
||||
resetForm()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.mentor.deleteNote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getNotes.invalidate({ mentorAssignmentId })
|
||||
toast.success('Note deleted')
|
||||
setDeleteId(null)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setIsAdding(false)
|
||||
setEditingId(null)
|
||||
setNoteContent('')
|
||||
setIsVisibleToAdmin(true)
|
||||
}
|
||||
|
||||
const handleEdit = (note: { id: string; content: string; isVisibleToAdmin: boolean }) => {
|
||||
setEditingId(note.id)
|
||||
setNoteContent(note.content)
|
||||
setIsVisibleToAdmin(note.isVisibleToAdmin)
|
||||
setIsAdding(false)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!noteContent.trim()) return
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({
|
||||
noteId: editingId,
|
||||
content: noteContent.trim(),
|
||||
isVisibleToAdmin,
|
||||
})
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
mentorAssignmentId,
|
||||
content: noteContent.trim(),
|
||||
isVisibleToAdmin,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<StickyNote className="h-5 w-5" />
|
||||
Private Notes
|
||||
</CardTitle>
|
||||
{!isAdding && !editingId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsAdding(true)
|
||||
setEditingId(null)
|
||||
setNoteContent('')
|
||||
setIsVisibleToAdmin(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Note
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Personal notes about this mentorship (private to you unless shared with admin)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Add/Edit form */}
|
||||
{(isAdding || editingId) && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<Textarea
|
||||
value={noteContent}
|
||||
onChange={(e) => setNoteContent(e.target.value)}
|
||||
placeholder="Write your note..."
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="visible-to-admin"
|
||||
checked={isVisibleToAdmin}
|
||||
onCheckedChange={(checked) =>
|
||||
setIsVisibleToAdmin(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="visible-to-admin" className="text-sm flex items-center gap-1">
|
||||
{isVisibleToAdmin ? (
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
Visible to admins
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!noteContent.trim() || isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? 'Update' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes list */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : notes && notes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="p-4 rounded-lg border space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatDateOnly(note.createdAt)}</span>
|
||||
{note.isVisibleToAdmin ? (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
Admin visible
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<EyeOff className="h-3 w-3" />
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(note)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setDeleteId(note.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{note.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!isAdding && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No notes yet. Click "Add Note" to start taking notes.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Note</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this note? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && deleteMutation.mutate({ noteId: deleteId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Skeletons
|
||||
// =============================================================================
|
||||
|
||||
function ProjectDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
Reference in New Issue
Block a user