Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
@@ -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>
)
}