322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|