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:
489
src/components/forms/notion-import-form.tsx
Normal file
489
src/components/forms/notion-import-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user