All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
'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 {
|
|
programId: string
|
|
stageName?: string
|
|
onSuccess?: () => void
|
|
}
|
|
|
|
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
|
|
|
|
export function NotionImportForm({
|
|
programId,
|
|
stageName,
|
|
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,
|
|
programId,
|
|
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>{stageName}</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>
|
|
)
|
|
}
|