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:
1 parent
25451d77eb
commit
49377bb2de
2 files changed
+406
-5
No files matched your search
@@ -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<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
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 { useFetch } from '@/hooks/use-fetch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { PageHeader } from '../primitives'
|
import { PageHeader } from '../primitives'
|
||||||
import { CreateDealDialog } from '../create-deal-dialog'
|
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 { formatEuro, formatDate } from '@/lib/format'
|
||||||
import { useAos } from '@/store/aos'
|
import { useAos } from '@/store/aos'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -37,6 +46,13 @@ export function CrmView() {
|
|||||||
const { data, loading, refetch } = useFetch<CrmData>('/api/aos/crm')
|
const { data, loading, refetch } = useFetch<CrmData>('/api/aos/crm')
|
||||||
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
|
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
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) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
@@ -60,10 +76,16 @@ export function CrmView() {
|
|||||||
title="CRM"
|
title="CRM"
|
||||||
description="Firmen, Ansprechpartner und Deal-Pipeline. Konfigurierbare Stages, DSGVO-Konsens-Protokoll pro Kontakt."
|
description="Firmen, Ansprechpartner und Deal-Pipeline. Konfigurierbare Stages, DSGVO-Konsens-Protokoll pro Kontakt."
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<div className="flex gap-2">
|
||||||
<Plus className="size-4" />
|
<Button size="sm" variant="outline" onClick={() => { setImportOpen(true); setImportStep('upload') }}>
|
||||||
Deal
|
<Upload className="size-4" />
|
||||||
</Button>
|
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 }))}
|
companies={data.companies.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
onCreated={refetch}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -284,3 +463,28 @@ function PersonsTable({ persons, onSelectCompany }: { persons: CrmData['persons'
|
|||||||
</Card>
|
</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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user