diff --git a/src/app/api/aos/crm/import/route.ts b/src/app/api/aos/crm/import/route.ts new file mode 100644 index 0000000..3b8a19b --- /dev/null +++ b/src/app/api/aos/crm/import/route.ts @@ -0,0 +1,197 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' +export const maxDuration = 30 + +// POST /api/aos/crm/import +// Import companies + persons from a CSV string. +// Body: { csv: string, mapping: { name: number, vatId: number, ... } } +// +// The mapping maps AOS field names to CSV column indices (0-based). +// Required: name. Optional: legalForm, vatId, website, email, phone, city, zip, street. +// Duplicate detection: skips companies with the same name or website. +export async function POST(req: Request) { + try { + const { csv, mapping } = (await req.json()) as { + csv: string + mapping: Record + } + + if (!csv?.trim()) return NextResponse.json({ error: 'csv data required' }, { status: 400 }) + if (!mapping?.name && mapping.name !== 0) return NextResponse.json({ error: 'mapping.name required' }, { status: 400 }) + + // Parse CSV (simple RFC 4180 parser — handles quoted fields with commas) + const lines = parseCSV(csv) + if (lines.length < 2) { + return NextResponse.json({ error: 'CSV must have a header row + at least 1 data row' }, { status: 422 }) + } + + const header = lines[0]! + const dataRows = lines.slice(1) + + // Get existing companies for dedup + const existingCompanies = await db.company.findMany({ select: { name: true, website: true } }) + const existingNames = new Set(existingCompanies.map((c) => c.name.toLowerCase())) + const existingWebsites = new Set(existingCompanies.filter((c) => c.website).map((c) => c.website!.toLowerCase())) + + let created = 0 + let skipped = 0 + let personsCreated = 0 + const errors: { row: number; error: string }[] = [] + + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]! + try { + const name = row[mapping.name]?.trim() + if (!name) { + errors.push({ row: i + 2, error: 'name is empty' }) + continue + } + + // Dedup by name or website + const website = mapping.website !== undefined ? row[mapping.website]?.trim() || null : null + if (existingNames.has(name.toLowerCase())) { + skipped++ + continue + } + if (website && existingWebsites.has(website.toLowerCase())) { + skipped++ + continue + } + + // Extract fields via mapping + const company = await db.company.create({ + data: { + name, + legalForm: mapping.legalForm !== undefined ? row[mapping.legalForm]?.trim() || null : null, + vatId: mapping.vatId !== undefined ? row[mapping.vatId]?.trim() || null : null, + website, + email: mapping.email !== undefined ? row[mapping.email]?.trim() || null : null, + phone: mapping.phone !== undefined ? row[mapping.phone]?.trim() || null : null, + city: mapping.city !== undefined ? row[mapping.city]?.trim() || null : null, + zip: mapping.zip !== undefined ? row[mapping.zip]?.trim() || null : null, + street: mapping.street !== undefined ? row[mapping.street]?.trim() || null : null, + source: 'import', + }, + }) + + existingNames.add(name.toLowerCase()) + if (website) existingWebsites.add(website.toLowerCase()) + created++ + + // Optional: create a person if first/last name columns are mapped + if (mapping.firstName !== undefined || mapping.lastName !== undefined) { + const firstName = mapping.firstName !== undefined ? row[mapping.firstName]?.trim() || '' : '' + const lastName = mapping.lastName !== undefined ? row[mapping.lastName]?.trim() || '' : '' + const personEmail = mapping.personEmail !== undefined ? row[mapping.personEmail]?.trim() || null : null + const personPhone = mapping.personPhone !== undefined ? row[mapping.personPhone]?.trim() || null : null + const personRole = mapping.personRole !== undefined ? row[mapping.personRole]?.trim() || null : null + + if (firstName || lastName) { + await db.person.create({ + data: { + companyId: company.id, + firstName: firstName || '(kein Vorname)', + lastName: lastName || '(kein Nachname)', + email: personEmail, + phone: personPhone, + role: personRole, + }, + }) + personsCreated++ + } + } + } catch (err) { + errors.push({ row: i + 2, error: err instanceof Error ? err.message : 'unknown error' }) + } + } + + // Audit + await db.auditLog.create({ + data: { + actor: 'max@nordlicht.digital', + action: 'crm.import:csv', + entity: 'Company', + entityId: 'batch', + after: JSON.stringify({ created, skipped, personsCreated, errors: errors.length, totalRows: dataRows.length }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ + created, + skipped, + personsCreated, + errors, + totalRows: dataRows.length, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// Simple RFC 4180 CSV parser — handles quoted fields, escaped quotes, newlines in quotes +function parseCSV(csv: string): string[][] { + const rows: string[][] = [] + let currentRow: string[] = [] + let currentField = '' + let inQuotes = false + let i = 0 + + while (i < csv.length) { + const char = csv[i]! + const next = csv[i + 1] + + if (inQuotes) { + if (char === '"') { + if (next === '"') { + // Escaped quote + currentField += '"' + i += 2 + } else { + // End of quoted field + inQuotes = false + i++ + } + } else { + currentField += char + i++ + } + } else { + if (char === '"') { + inQuotes = true + i++ + } else if (char === ',') { + currentRow.push(currentField) + currentField = '' + i++ + } else if (char === '\n' || char === '\r') { + // Handle \r\n + if (char === '\r' && next === '\n') i++ + currentRow.push(currentField) + currentField = '' + // Only push non-empty rows (skip blank lines) + if (currentRow.some((f) => f.trim())) { + rows.push(currentRow) + } + currentRow = [] + i++ + } else { + currentField += char + i++ + } + } + } + + // Last field/row + if (currentField || currentRow.length > 0) { + currentRow.push(currentField) + if (currentRow.some((f) => f.trim())) { + rows.push(currentRow) + } + } + + return rows +} diff --git a/src/components/aos/views/crm.tsx b/src/components/aos/views/crm.tsx index 6e3196a..7cc5456 100755 --- a/src/components/aos/views/crm.tsx +++ b/src/components/aos/views/crm.tsx @@ -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('/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([]) + const [mapping, setMapping] = useState>({ 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={ - +
+ + +
} /> @@ -112,6 +134,163 @@ export function CrmView() { companies={data.companies.map((c) => ({ id: c.id, name: c.name }))} onCreated={refetch} /> + + {/* CSV Import Dialog */} + { setImportOpen(o); if (!o) { setCsvText(''); setCsvHeaders([]); setMapping({ name: -1 }); setImportResult(null) } }}> + + + CSV Import — Firmen + + {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:'} + + + + {importStep === 'upload' && ( +
+
+ { + 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 }) + }} + /> + +
+
+ +