Initial commit
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { ContactGroup, ImportMapping, ImportPreview, ImportResult } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Alert, Button, Select, Spinner } from '../../components/ui'
|
||||
import { Modal } from '../../components/Modal'
|
||||
|
||||
/**
|
||||
* Import kontaktow z pliku CSV.
|
||||
*
|
||||
* TRZY KROKI, NIE JEDEN
|
||||
* ------------------------------------------------------------------------
|
||||
* Wybor pliku, potem mapowanie kolumn z podgladem, dopiero na koncu import.
|
||||
*
|
||||
* Podglad jest tu najwazniejszy: pomylka w kolejnosci kolumn oznaczalaby setki
|
||||
* kontaktow z nazwiskiem w polu firmy, a odkrecanie tego recznie kosztuje
|
||||
* wielokrotnie wiecej niz jedno spojrzenie na piec przykladowych wierszy.
|
||||
*/
|
||||
|
||||
/** Pola, ktore da sie zmapowac. Kolejnosc jak w formularzu kontaktu. */
|
||||
const FIELDS: { key: keyof ImportMapping; label: string; required: boolean }[] = [
|
||||
{ key: 'email', label: 'Adres e-mail', required: true },
|
||||
{ key: 'firstName', label: 'Imię', required: false },
|
||||
{ key: 'lastName', label: 'Nazwisko', required: false },
|
||||
{ key: 'phone', label: 'Telefon', required: false },
|
||||
{ key: 'company', label: 'Firma', required: false },
|
||||
{ key: 'position', label: 'Stanowisko', required: false },
|
||||
]
|
||||
|
||||
export function ContactImportDialog({
|
||||
groups,
|
||||
onClose,
|
||||
}: {
|
||||
groups: ContactGroup[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [preview, setPreview] = useState<ImportPreview | null>(null)
|
||||
const [mapping, setMapping] = useState<ImportMapping | null>(null)
|
||||
const [groupId, setGroupId] = useState('')
|
||||
const [grantConsent, setGrantConsent] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [result, setResult] = useState<ImportResult | null>(null)
|
||||
|
||||
async function handleFile(selected: File) {
|
||||
setFile(selected)
|
||||
setBusy(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', selected)
|
||||
const response = await client.post<ImportPreview>('/admin/contacts/import/preview', form)
|
||||
setPreview(response.data)
|
||||
setMapping(response.data.suggested)
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się odczytać pliku'))
|
||||
setFile(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!file || !mapping) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await client.post<ImportResult>('/admin/contacts/import', form, {
|
||||
params: {
|
||||
emailColumn: mapping.email,
|
||||
firstNameColumn: mapping.firstName,
|
||||
lastNameColumn: mapping.lastName,
|
||||
phoneColumn: mapping.phone,
|
||||
companyColumn: mapping.company,
|
||||
positionColumn: mapping.position,
|
||||
groupId: groupId || undefined,
|
||||
grantConsent,
|
||||
},
|
||||
timeout: 0,
|
||||
})
|
||||
setResult(response.data)
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się zaimportować pliku'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- raport po imporcie ---
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<Modal title="Import zakończony" onClose={onClose} wide>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Summary label="Dodane" value={result.added} tone="ok" />
|
||||
<Summary label="Zaktualizowane" value={result.updated} tone="info" />
|
||||
<Summary label="Pominięte" value={result.skipped.length} tone="warn" />
|
||||
</div>
|
||||
|
||||
{result.skipped.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-800">
|
||||
Wiersze pominięte wraz z powodem
|
||||
</h3>
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
<th className="px-3 py-2">Wiersz</th>
|
||||
<th className="px-3 py-2">Adres</th>
|
||||
<th className="px-3 py-2">Powód</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{result.skipped.map((row) => (
|
||||
<tr key={`${row.line}-${row.email}`}>
|
||||
<td className="hx-data px-3 py-2 text-slate-500">{row.line}</td>
|
||||
<td className="hx-data break-all px-3 py-2">{row.email || '-'}</td>
|
||||
<td className="px-3 py-2 text-slate-600">{row.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end border-t border-slate-100 pt-4">
|
||||
<Button onClick={onClose}>Zamknij</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// --- wybor pliku i mapowanie ---
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Import kontaktów z pliku CSV"
|
||||
description="Duplikat po adresie aktualizuje istniejący kontakt, nie tworzy drugiego"
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const selected = e.target.files?.[0]
|
||||
if (selected) handleFile(selected)
|
||||
}}
|
||||
/>
|
||||
|
||||
{!preview && (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-8 text-center">
|
||||
<Icon name="upload" size={28} className="mx-auto text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Wybierz plik CSV z listą kontaktów.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Separatorem może być średnik albo przecinek. Pierwszy wiersz to nazwy kolumn.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button icon="upload" disabled={busy} onClick={() => fileInput.current?.click()}>
|
||||
{busy ? 'Wczytywanie...' : 'Wybierz plik'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{busy && preview && <Spinner />}
|
||||
|
||||
{preview && mapping && (
|
||||
<>
|
||||
<p className="text-sm text-slate-600">
|
||||
Plik zawiera <span className="hx-data font-semibold">{preview.totalRows}</span>{' '}
|
||||
wierszy z danymi. Sprawdź, czy kolumny zostały rozpoznane poprawnie.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{FIELDS.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
|
||||
{field.label}
|
||||
{field.required && <span className="text-alarm-600"> *</span>}
|
||||
</label>
|
||||
<Select
|
||||
value={String(mapping[field.key])}
|
||||
onChange={(e) =>
|
||||
setMapping({ ...mapping, [field.key]: Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="-1">Nie importuj</option>
|
||||
{preview.header.map((name, index) => (
|
||||
<option key={index} value={index}>
|
||||
{name || `Kolumna ${index + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mapping.email < 0 && (
|
||||
<Alert tone="warn">
|
||||
Wskaż kolumnę z adresem e-mail. Bez niej nie da się rozpoznać kontaktów.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* podglad: tak zostana zrozumiane pierwsze wiersze */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-800">
|
||||
Podgląd pierwszych wierszy
|
||||
</h3>
|
||||
<div className="overflow-x-auto rounded-md border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
{FIELDS.filter((f) => mapping[f.key] >= 0).map((f) => (
|
||||
<th key={f.key} className="whitespace-nowrap px-3 py-2">
|
||||
{f.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{preview.sample.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{FIELDS.filter((f) => mapping[f.key] >= 0).map((f) => (
|
||||
<td key={f.key} className="whitespace-nowrap px-3 py-2 text-slate-700">
|
||||
{row[mapping[f.key]] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-slate-200 p-3">
|
||||
<div className="w-full sm:w-64">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
|
||||
Dodaj wszystkich do grupy
|
||||
</label>
|
||||
<Select value={groupId} onChange={(e) => setGroupId(e.target.value)}>
|
||||
<option value="">Bez grupy</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-300"
|
||||
checked={grantConsent}
|
||||
onChange={(e) => setGrantConsent(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Zapisz zgodę marketingową dla importowanych kontaktów
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
Zaznacz tylko wtedy, gdy masz podstawę prawną: zgodę zebraną wcześniej albo
|
||||
inną przesłankę. Osoby, które wcześniej się wypisały, i tak jej nie dostaną.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
icon="upload"
|
||||
disabled={busy || mapping.email < 0}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{busy ? 'Importowanie...' : `Importuj ${preview.totalRows} wierszy`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Summary({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
tone: 'ok' | 'info' | 'warn'
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'ok' ? 'text-ok-600' : tone === 'warn' ? 'text-warn-600' : 'text-blue-700'
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 p-3 text-center">
|
||||
<p className={`hx-data text-2xl font-semibold ${toneClass}`}>{value}</p>
|
||||
<p className="hx-eyebrow mt-0.5 text-slate-500">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user