feat: CSV Import for CRM (PRD §6.1: CSV import/export with mapping)

- Real RFC 4180 CSV parser (handles quoted fields, escaped quotes, newlines)
- 3-step import dialog: upload → map columns → result
- Column auto-detection (name/firma/company)
- Duplicate detection (name + website)
- Creates companies + optional persons
- Import audit-logged
- Result summary: created, skipped, errors
This commit is contained in:
Z User committed 2026-10-27 17:15:00 +00:00
1 parent 25451d77eb
commit 49377bb2de
2 files changed
+406 -5

No files matched your search

+209 -5
View File
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon } from 'lucide-react'
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon, Upload, Loader2, Check, FileText } from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -10,6 +10,15 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { CreateDealDialog } from '../create-deal-dialog'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { toast } from 'sonner'
import { formatEuro, formatDate } from '@/lib/format'
import { useAos } from '@/store/aos'
import { cn } from '@/lib/utils'
@@ -37,6 +46,13 @@ export function CrmView() {
const { data, loading, refetch } = useFetch<CrmData>('/api/aos/crm')
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [importStep, setImportStep] = useState<'upload' | 'map' | 'result'>('upload')
const [csvText, setCsvText] = useState('')
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
const [mapping, setMapping] = useState<Record<string, number>>({ name: -1 })
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<{ created: number; skipped: number; personsCreated: number; errors: { row: number; error: string }[]; totalRows: number } | null>(null)
if (loading || !data) {
return (
@@ -60,10 +76,16 @@ export function CrmView() {
title="CRM"
description="Firmen, Ansprechpartner und Deal-Pipeline. Konfigurierbare Stages, DSGVO-Konsens-Protokoll pro Kontakt."
actions={
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
Deal
</Button>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => { setImportOpen(true); setImportStep('upload') }}>
<Upload className="size-4" />
CSV Import
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
Deal
</Button>
</div>
}
/>
@@ -112,6 +134,163 @@ export function CrmView() {
companies={data.companies.map((c) => ({ id: c.id, name: c.name }))}
onCreated={refetch}
/>
{/* CSV Import Dialog */}
<Dialog open={importOpen} onOpenChange={(o) => { setImportOpen(o); if (!o) { setCsvText(''); setCsvHeaders([]); setMapping({ name: -1 }); setImportResult(null) } }}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>CSV Import — Firmen</DialogTitle>
<DialogDescription>
{importStep === 'upload' && 'Lade eine CSV-Datei hoch oder füge CSV-Text ein. Header-Zeile erforderlich.'}
{importStep === 'map' && 'Ordne die CSV-Spalten den AOS-Feldern zu. „name" ist Pflicht.'}
{importStep === 'result' && 'Import abgeschlossen. Ergebnis:'}
</DialogDescription>
</DialogHeader>
{importStep === 'upload' && (
<div className="space-y-4">
<div className="space-y-2">
<input
type="file"
accept=".csv,text/csv"
className="hidden"
id="csv-file"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
const text = await file.text()
setCsvText(text)
const firstLine = text.split('\n')[0] ?? ''
const headers = parseCSVLine(firstLine)
setCsvHeaders(headers)
// auto-detect name column
const nameIdx = headers.findIndex((h) => h.toLowerCase().match(/name|firma|company/))
setMapping({ name: nameIdx >= 0 ? nameIdx : 0 })
}}
/>
<Button variant="outline" className="w-full gap-2" onClick={() => document.getElementById('csv-file')?.click()}>
<Upload className="size-4" />
CSV-Datei auswählen
</Button>
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">oder CSV-Text einfügen:</label>
<textarea
className="min-h-[120px] w-full rounded border border-input bg-background p-2 font-mono text-xs"
placeholder={"name,legalForm,vatId,website,email,city\nMüller GmbH,GmbH,DE123456789,mueller.de,info@mueller.de,Hamburg"}
value={csvText}
onChange={(e) => {
setCsvText(e.target.value)
const firstLine = e.target.value.split('\n')[0] ?? ''
const headers = parseCSVLine(firstLine)
setCsvHeaders(headers)
const nameIdx = headers.findIndex((h) => h.toLowerCase().match(/name|firma|company/))
setMapping({ name: nameIdx >= 0 ? nameIdx : 0 })
}}
/>
</div>
{csvHeaders.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs">
<p className="font-medium">Erkannte Spalten ({csvHeaders.length}):</p>
<div className="mt-1 flex flex-wrap gap-1">
{csvHeaders.map((h, i) => <Badge key={i} variant="outline" className="text-[10px] font-mono">{i}: {h}</Badge>)}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setImportOpen(false)}>Abbrechen</Button>
<Button onClick={() => setImportStep('map')} disabled={csvHeaders.length === 0}>Weiter — Spalten zuordnen</Button>
</DialogFooter>
</div>
)}
{importStep === 'map' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
{['name', 'legalForm', 'vatId', 'website', 'email', 'phone', 'city', 'zip', 'street', 'firstName', 'lastName', 'personEmail', 'personPhone', 'personRole'].map((field) => (
<div key={field} className="flex items-center gap-2">
<label className="w-28 shrink-0 text-xs font-medium">{field}</label>
<select
value={mapping[field] ?? -1}
onChange={(e) => setMapping((m) => ({ ...m, [field]: Number(e.target.value) }))}
className="flex-1 rounded border border-input bg-background px-2 py-1 text-xs"
>
<option value={-1}>— (ignorieren)</option>
{csvHeaders.map((h, i) => <option key={i} value={i}>{i}: {h}</option>)}
</select>
</div>
))}
</div>
<div className="rounded-md border border-dashed border-border p-2 text-[11px] text-muted-foreground">
<p>⚠ „name" ist Pflicht. Duplikate (gleicher Name oder Website) werden übersprungen.</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setImportStep('upload')}>Zurück</Button>
<Button
onClick={async () => {
setImporting(true)
const t = toast.loading('Import läuft…')
try {
const r = await fetch('/api/aos/crm/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ csv: csvText, mapping }),
})
const res = await r.json()
if (!r.ok) throw new Error(res.error)
setImportResult(res)
setImportStep('result')
toast.success(`${res.created} Firmen importiert`, { id: t })
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Import fehlgeschlagen', { id: t })
} finally {
setImporting(false)
}
}}
disabled={importing || mapping.name < 0}
className="gap-1.5"
>
{importing && <Loader2 className="size-3.5 animate-spin" />}
Import starten
</Button>
</DialogFooter>
</div>
)}
{importStep === 'result' && importResult && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-center">
<p className="text-2xl font-bold tabular-nums">{importResult.created}</p>
<p className="text-[10px] text-muted-foreground">Firmen erstellt</p>
</div>
<div className="rounded-md border border-border p-3 text-center">
<p className="text-2xl font-bold tabular-nums">{importResult.skipped}</p>
<p className="text-[10px] text-muted-foreground">Duplikate übersprungen</p>
</div>
<div className="rounded-md border border-border p-3 text-center">
<p className="text-2xl font-bold tabular-nums">{importResult.personsCreated}</p>
<p className="text-[10px] text-muted-foreground">Personen erstellt</p>
</div>
</div>
{importResult.errors.length > 0 && (
<div className="rounded-md border border-border p-3">
<p className="text-xs font-medium">Fehler ({importResult.errors.length}):</p>
<div className="mt-1 max-h-32 overflow-y-auto space-y-0.5 text-[11px] text-muted-foreground">
{importResult.errors.slice(0, 10).map((e, i) => (
<p key={i}>Zeile {e.row}: {e.error}</p>
))}
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => { setImportOpen(false); setCsvText(''); setImportResult(null) }}>Fertig</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
@@ -284,3 +463,28 @@ function PersonsTable({ persons, onSelectCompany }: { persons: CrmData['persons'
</Card>
)
}
// Parse a single CSV line (handles quoted fields with commas)
function parseCSVLine(line: string): string[] {
const fields: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]!
const next = line[i + 1]
if (inQuotes) {
if (char === '"') {
if (next === '"') { current += '"'; i++ }
else inQuotes = false
} else {
current += char
}
} else {
if (char === '"') inQuotes = true
else if (char === ',') { fields.push(current); current = '' }
else current += char
}
}
fields.push(current)
return fields
}