import { useMemo, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { IMPORT_ENTITIES, IMPORT_ENTITY_DEFINITIONS, TEAMS, type ImportEntity, type PermissionGrant, } from '@pig/core'; import { AlertTriangle, CheckCircle2, FileSpreadsheet, LoaderCircle, Lock, Upload } from 'lucide-react'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Input } from '@/components/ui'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { ApiError, get, post } from '@/lib/api'; import { can } from '@/lib/permissions'; import { usePageTitle } from '@/lib/title'; import { NotionImportSource, type ImportedTable } from '@/components/NotionImportSource'; import { GoogleSheetsSource } from '@/components/GoogleSheetsSource'; type ParsedTable = ImportedTable; interface PreviewRow { rowNumber: number; key: string; action: 'create' | 'update' | 'error'; recordId: string | null; values: Record; errors: { field: string | null; message: string }[]; } interface Preview { digest: string; rows: PreviewRow[]; counts: { create: number; update: number; error: number }; } interface CommitResult { created: number; updated: number; total: number; } const MAX_FILE_BYTES = 5 * 1024 * 1024; export function Imports() { usePageTitle('Import data'); const [entity, setEntity] = useState('account'); const [source, setSource] = useState<'file' | 'notion' | 'google'>('file'); const [parsed, setParsed] = useState(null); const [mapping, setMapping] = useState>({}); const [keySourceColumn, setKeySourceColumn] = useState(''); const [preview, setPreview] = useState(null); const [fileError, setFileError] = useState(null); const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'), }); const allowed = TEAMS.some((team) => can(me, 'data:import', team)); const definition = IMPORT_ENTITY_DEFINITIONS[entity]; const adoptTable = (table: ParsedTable) => { setParsed(table); setPreview(null); setFileError(null); const nextMapping: Record = {}; const normalisedHeaders = new Map(table.headers.map((header) => [normalise(header), header])); for (const field of definition.fields) { const source = normalisedHeaders.get(normalise(field.key)) ?? normalisedHeaders.get(normalise(field.label)); if (source) nextMapping[field.key] = source; } setMapping(nextMapping); setKeySourceColumn(table.headers[0] ?? ''); }; const parse = useMutation({ mutationFn: async (file: File) => { if (file.size > MAX_FILE_BYTES) throw new Error('Import files may not exceed 5 MB.'); return post('/api/imports/parse', { fileName: file.name, mimeType: file.type || undefined, base64: arrayBufferToBase64(await file.arrayBuffer()), }); }, onSuccess: adoptTable, onError: (error) => setFileError(errorMessage(error)), }); const plan = useMemo(() => parsed ? { entity, sourceName: parsed.fileName, headers: parsed.headers, rows: parsed.rows, mapping, keySourceColumn, } : null, [entity, keySourceColumn, mapping, parsed]); const dryRun = useMutation({ mutationFn: () => { if (!plan) throw new Error('Choose and map a file first.'); return post('/api/imports/preview', plan); }, onSuccess: setPreview, }); const commit = useMutation({ mutationFn: () => { if (!plan || !preview) throw new Error('Run the dry run first.'); return post('/api/imports/commit', { ...plan, previewDigest: preview.digest }); }, }); const resetForEntity = (next: ImportEntity) => { setEntity(next); setParsed(null); setMapping({}); setKeySourceColumn(''); setPreview(null); setFileError(null); commit.reset(); }; if (me && !allowed) { // Keeps the heading the permitted view has. Returning the bare card left // the page with no h1 and no breadcrumb, so someone sent here by a link // landed on a refusal with nothing naming the page it came from. return (

Import data

} title="Import access required" description="A team administrator with data-import permission must run spreadsheet imports." />
); } return (

Import data

Stage source data, inspect every create or update, then commit the reviewed plan atomically. Nothing writes to PIG before review.

{IMPORT_ENTITIES.map((candidate) => { const candidateDefinition = IMPORT_ENTITY_DEFINITIONS[candidate]; return ( ); })}
1. Choose source

Import {definition.label.toLocaleLowerCase()} from a file, Notion, or a bounded Google Sheets range

{([ ['file', 'File'], ['notion', 'Notion'], ['google', 'Google Sheets'], ] as const).map(([value, label]) => ( ))}
{source === 'notion' ? : null} {source === 'google' ? : null} {source === 'file' ? (
{ const file = event.target.files?.[0]; if (file) parse.mutate(file); }} /> {parse.isPending ?

Parsing untrusted cells safely…

: null} {fileError ? : null}
) : null} {parsed ? (
Parsed {parsed.fileName} {parsed.rows.length} rows · {parsed.headers.length} columns{parsed.sheetName ? ` · ${parsed.sheetName}` : ''}
) : null} {parsed?.warnings.map((warning) =>

{warning}

)}
{parsed ? ( 2. Map source columns

Only mapped fields are written. Blank optional cells clear nullable fields; required and non-null defaulted fields are left unchanged on updates.

{definition.fields.map((field) => ( ))}
{dryRun.isError ? : null}
) : null} {preview ? ( 3. Review the exact plan
{preview.counts.create} create {preview.counts.update} update {preview.counts.error} errors
RowSource keyDecisionMapped values / errors {preview.rows.slice(0, 100).map((row) => ( {row.rowNumber} {row.key || 'Blank'} {row.action} {row.errors.length > 0 ?
    {row.errors.map((error) =>
  • {error.field ? `${error.field}: ` : ''}{error.message}
  • )}
:

{Object.entries(row.values).slice(0, 4).map(([key, value]) => `${key}: ${String(value)}`).join(' · ')}

}
))}
{preview.rows.length > 100 ?

Showing the first 100 of {preview.rows.length} rows. All rows were validated and will be committed.

: null} {preview.counts.error > 0 ? : null} {commit.data ?
Committed {commit.data.total} rows: {commit.data.created} created and {commit.data.updated} updated.
: null} {commit.isError ? : null}
) : null}
); } function ErrorNotice({ message }: { message: string }) { return

{message}

; } function arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ''; for (let offset = 0; offset < bytes.length; offset += 0x8000) { binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); } return btoa(binary); } function normalise(value: string): string { return value.toLocaleLowerCase().replace(/[^a-z0-9]/g, ''); } function errorMessage(error: unknown): string { if (error instanceof ApiError) return error.message; return error instanceof Error ? error.message : 'The import request failed.'; }