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,489 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { toast } from 'sonner'
import {
CheckCircle,
AlertCircle,
Loader2,
ArrowRight,
ArrowLeft,
Database,
} from 'lucide-react'
interface NotionImportFormProps {
roundId: string
roundName: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function NotionImportForm({
roundId,
roundName,
onSuccess,
}: NotionImportFormProps) {
const [step, setStep] = useState<Step>('connect')
const [apiKey, setApiKey] = useState('')
const [databaseId, setDatabaseId] = useState('')
const [isConnecting, setIsConnecting] = useState(false)
const [connectionError, setConnectionError] = useState<string | null>(null)
// Mapping state
const [mappings, setMappings] = useState({
title: '',
teamName: '',
description: '',
tags: '',
})
const [includeUnmapped, setIncludeUnmapped] = useState(true)
// Results
const [importResults, setImportResults] = useState<{
imported: number
skipped: number
errors: Array<{ recordId: string; error: string }>
} | null>(null)
const testConnection = trpc.notionImport.testConnection.useMutation()
const { data: schema, refetch: refetchSchema } =
trpc.notionImport.getDatabaseSchema.useQuery(
{ apiKey, databaseId },
{ enabled: false }
)
const { data: preview, refetch: refetchPreview } =
trpc.notionImport.previewData.useQuery(
{ apiKey, databaseId, limit: 5 },
{ enabled: false }
)
const importMutation = trpc.notionImport.importProjects.useMutation()
const handleConnect = async () => {
if (!apiKey || !databaseId) {
toast.error('Please enter both API key and database ID')
return
}
setIsConnecting(true)
setConnectionError(null)
try {
const result = await testConnection.mutateAsync({ apiKey })
if (!result.success) {
setConnectionError(result.error || 'Connection failed')
return
}
// Fetch schema
await refetchSchema()
setStep('map')
} catch (error) {
setConnectionError(
error instanceof Error ? error.message : 'Connection failed'
)
} finally {
setIsConnecting(false)
}
}
const handlePreview = async () => {
if (!mappings.title) {
toast.error('Please map the Title field')
return
}
await refetchPreview()
setStep('preview')
}
const handleImport = async () => {
setStep('import')
try {
const result = await importMutation.mutateAsync({
apiKey,
databaseId,
roundId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
description: mappings.description || undefined,
tags: mappings.tags || undefined,
},
includeUnmappedInMetadata: includeUnmapped,
})
setImportResults(result)
setStep('complete')
if (result.imported > 0) {
toast.success(`Imported ${result.imported} projects`)
onSuccess?.()
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Import failed'
)
setStep('preview')
}
}
const properties = schema?.properties || []
return (
<div className="space-y-6">
{/* Progress indicator */}
<div className="flex items-center gap-2">
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
<div key={s} className="flex items-center">
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step === s
? 'bg-primary text-primary-foreground'
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
</div>
))}
</div>
{/* Step 1: Connect */}
{step === 'connect' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Connect to Notion
</CardTitle>
<CardDescription>
Enter your Notion API key and database ID to connect
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">Notion API Key</Label>
<Input
id="apiKey"
type="password"
placeholder="secret_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Create an integration at{' '}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
notion.so/my-integrations
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="databaseId">Database ID</Label>
<Input
id="databaseId"
placeholder="abc123..."
value={databaseId}
onChange={(e) => setDatabaseId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
The ID from your Notion database URL
</p>
</div>
{connectionError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Failed</AlertTitle>
<AlertDescription>{connectionError}</AlertDescription>
</Alert>
)}
<Button
onClick={handleConnect}
disabled={isConnecting || !apiKey || !databaseId}
>
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Connect
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
{/* Step 2: Map columns */}
{step === 'map' && (
<Card>
<CardHeader>
<CardTitle>Map Columns</CardTitle>
<CardDescription>
Map Notion properties to project fields. Database: {schema?.title}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
Title <span className="text-destructive">*</span>
</Label>
<Select
value={mappings.title}
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property" />
</SelectTrigger>
<SelectContent>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}{' '}
<span className="text-muted-foreground">({p.type})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Team Name</Label>
<Select
value={mappings.teamName}
onValueChange={(v) =>
setMappings((m) => ({ ...m, teamName: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Select
value={mappings.description}
onValueChange={(v) =>
setMappings((m) => ({ ...m, description: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<Select
value={mappings.tags}
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties
.filter((p) => p.type === 'multi_select' || p.type === 'select')
.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="includeUnmapped"
checked={includeUnmapped}
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
/>
<Label htmlFor="includeUnmapped" className="font-normal">
Store unmapped columns in metadata
</Label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('connect')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handlePreview} disabled={!mappings.title}>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<Card>
<CardHeader>
<CardTitle>Preview Import</CardTitle>
<CardDescription>
Review the first {preview?.count || 0} records before importing
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium">Title</th>
<th className="px-4 py-2 text-left font-medium">Team</th>
<th className="px-4 py-2 text-left font-medium">Tags</th>
</tr>
</thead>
<tbody>
{preview?.records.map((record, i) => (
<tr key={i} className="border-t">
<td className="px-4 py-2">
{String(record.properties[mappings.title] || '-')}
</td>
<td className="px-4 py-2">
{mappings.teamName
? String(record.properties[mappings.teamName] || '-')
: '-'}
</td>
<td className="px-4 py-2">
{mappings.tags && record.properties[mappings.tags]
? (
record.properties[mappings.tags] as string[]
).map((tag, j) => (
<Badge key={j} variant="secondary" className="mr-1">
{tag}
</Badge>
))
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all records from the Notion database into{' '}
<strong>{roundName}</strong>.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('map')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handleImport}>
Import All Records
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Importing */}
{step === 'import' && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Importing projects...</p>
<p className="text-sm text-muted-foreground">
Please wait while we import your data from Notion
</p>
</CardContent>
</Card>
)}
{/* Step 5: Complete */}
{step === 'complete' && importResults && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<p className="text-lg font-medium">Import Complete</p>
<div className="mt-4 text-center">
<p className="text-2xl font-bold text-green-600">
{importResults.imported}
</p>
<p className="text-sm text-muted-foreground">projects imported</p>
</div>
{importResults.skipped > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{importResults.skipped} records skipped
</p>
)}
{importResults.errors.length > 0 && (
<div className="mt-4 w-full max-w-md">
<p className="text-sm font-medium text-destructive mb-2">
Errors ({importResults.errors.length}):
</p>
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
{importResults.errors.slice(0, 5).map((e, i) => (
<p key={i}>
{e.recordId}: {e.error}
</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}