Initial commit: MOPC platform with Docker deployment setup

Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth.
Includes production Dockerfile (multi-stage, port 7600), docker-compose
with registry-based image pull, Gitea Actions CI workflow, nginx config
for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

View File

@@ -0,0 +1,393 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import {
ArrowLeft,
Loader2,
Sparkles,
User,
Check,
Wand2,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Type for mentor suggestion from the API
interface MentorSuggestion {
mentorId: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
mentor: {
id: string
name: string | null
email: string
expertiseTags: string[]
assignmentCount: number
} | null
}
function MentorAssignmentContent({ projectId }: { projectId: string }) {
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
const utils = trpc.useUtils()
// Fetch project
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch suggestions
const { data: suggestions, isLoading: suggestionsLoading, refetch } = trpc.mentor.getSuggestions.useQuery(
{ projectId, limit: 5 },
{ enabled: !!project && !project.mentorAssignment }
)
// Assign mentor mutation
const assignMutation = trpc.mentor.assign.useMutation({
onSuccess: () => {
toast.success('Mentor assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Auto-assign mutation
const autoAssignMutation = trpc.mentor.autoAssign.useMutation({
onSuccess: () => {
toast.success('Mentor auto-assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Unassign mutation
const unassignMutation = trpc.mentor.unassign.useMutation({
onSuccess: () => {
toast.success('Mentor removed')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
const handleAssign = (mentorId: string, suggestion?: MentorSuggestion) => {
assignMutation.mutate({
projectId,
mentorId,
method: suggestion ? 'AI_SUGGESTED' : 'MANUAL',
aiConfidenceScore: suggestion?.confidenceScore,
expertiseMatchScore: suggestion?.expertiseMatchScore,
aiReasoning: suggestion?.reasoning,
})
}
if (projectLoading) {
return <MentorAssignmentSkeleton />
}
if (!project) {
return (
<Card>
<CardContent className="py-12 text-center">
<p>Project not found</p>
</CardContent>
</Card>
)
}
const hasMentor = !!project.mentorAssignment
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor Assignment</h1>
<p className="text-muted-foreground">{project.title}</p>
</div>
{/* Current Assignment */}
{hasMentor && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Mentor</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarFallback>
{getInitials(project.mentorAssignment!.mentor.name || project.mentorAssignment!.mentor.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{project.mentorAssignment!.mentor.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
</div>
</div>
<div className="text-right">
<Badge variant="outline" className="mb-2">
{project.mentorAssignment!.method.replace(/_/g, ' ')}
</Badge>
<div>
<Button
variant="destructive"
size="sm"
onClick={() => unassignMutation.mutate({ projectId })}
disabled={unassignMutation.isPending}
>
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Remove'
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* AI Suggestions */}
{!hasMentor && (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI-Suggested Mentors
</CardTitle>
<CardDescription>
Mentors matched based on expertise and project needs
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => refetch()}
disabled={suggestionsLoading}
>
{suggestionsLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Refresh'
)}
</Button>
<Button
onClick={() => autoAssignMutation.mutate({ projectId, useAI: true })}
disabled={autoAssignMutation.isPending}
>
{autoAssignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Wand2 className="mr-2 h-4 w-4" />
)}
Auto-Assign Best Match
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{suggestionsLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : suggestions?.suggestions.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No mentor suggestions available. Try adding more users with expertise tags.
</p>
) : (
<div className="space-y-4">
{suggestions?.suggestions.map((suggestion, index) => (
<div
key={suggestion.mentorId}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedMentorId === suggestion.mentorId
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div className="relative">
<Avatar className="h-12 w-12">
<AvatarFallback>
{suggestion.mentor ? getInitials(suggestion.mentor.name || suggestion.mentor.email) : '?'}
</AvatarFallback>
</Avatar>
{index === 0 && (
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
1
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{suggestion.mentor?.name || 'Unnamed'}</p>
<Badge variant="outline" className="text-xs">
{suggestion.mentor?.assignmentCount || 0} projects
</Badge>
</div>
<p className="text-sm text-muted-foreground">{suggestion.mentor?.email}</p>
{/* Expertise tags */}
{suggestion.mentor?.expertiseTags && suggestion.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{suggestion.mentor.expertiseTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{/* Match scores */}
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Confidence:</span>
<Progress value={suggestion.confidenceScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.confidenceScore * 100).toFixed(0)}%</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Expertise Match:</span>
<Progress value={suggestion.expertiseMatchScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.expertiseMatchScore * 100).toFixed(0)}%</span>
</div>
</div>
{/* AI Reasoning */}
{suggestion.reasoning && (
<p className="mt-2 text-sm text-muted-foreground italic">
&quot;{suggestion.reasoning}&quot;
</p>
)}
</div>
</div>
<Button
onClick={() => handleAssign(suggestion.mentorId, suggestion)}
disabled={assignMutation.isPending}
variant={selectedMentorId === suggestion.mentorId ? 'default' : 'outline'}
>
{assignMutation.isPending && selectedMentorId === suggestion.mentorId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" />
Assign
</>
)}
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Manual Assignment */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5" />
Manual Assignment
</CardTitle>
<CardDescription>
Search and select a mentor manually
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Use the AI suggestions above or search for a specific user in the Users section
to assign them as a mentor manually.
</p>
</CardContent>
</Card>
</>
)}
</div>
)
}
function MentorAssignmentSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-48" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function MentorAssignmentPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<MentorAssignmentSkeleton />}>
<MentorAssignmentContent projectId={id} />
</Suspense>
)
}