/** * Widoki panelu administratora dla modułu marketingowego: Leady + grupy docelowe, Kampanie oraz * konfiguracja poczty SMTP i bramki SMS. Wydzielone z App.tsx (monolit 22k linii), aby go nie * powiększać. Komponent `Icon` przekazywany jest propem, żeby uniknąć cyklicznego importu. */ import { useCallback, useEffect, useState } from 'react'; import { apiFetch } from '../auth'; type IconComponent = (props: { name: string }) => JSX.Element | null; type LeadSource = 'REGISTERED_USER' | 'MANUAL' | 'IMPORT' | 'FORM'; type LeadStatus = 'NEW' | 'CONTACTED' | 'RESPONDED' | 'UNSUBSCRIBED'; type Lead = { id: number; name: string | null; email: string | null; phone: string | null; city: string | null; source: LeadSource; userId: number | null; status: LeadStatus; tags: string[]; emailConsent: boolean; smsConsent: boolean; emailOptOut: boolean; smsOptOut: boolean; notes: string | null; createdAt: string; }; type TargetGroup = { id: number; name: string; description: string | null; sources: LeadSource[]; city: string | null; tag: string | null; status: LeadStatus | null; requireEmail: boolean; requirePhone: boolean; requireEmailConsent: boolean; requireSmsConsent: boolean; memberCount: number; createdAt: string; }; type Channel = 'EMAIL' | 'SMS'; type CampaignStatus = 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'PAUSED' | 'COMPLETED' | 'CANCELLED'; type Campaign = { id: number; name: string; channel: Channel; subject: string | null; body: string; targetGroupId: number; scheduledAt: string | null; dailyLimit: number; status: CampaignStatus; sentToday: number; createdAt: string; createdBy: string | null; totalRecipients: number; sentCount: number; }; type CampaignStats = { total: number; queued: number; sent: number; delivered: number; failed: number; bounced: number; optedOut: number; }; const SOURCE_LABEL: Record = { REGISTERED_USER: 'Użytkownik', MANUAL: 'Ręczny', IMPORT: 'Import', FORM: 'Formularz', }; const STATUS_LABEL: Record = { NEW: 'Nowy', CONTACTED: 'Kontakt', RESPONDED: 'Odpowiedział', UNSUBSCRIBED: 'Wypisany', }; const CAMPAIGN_STATUS_LABEL: Record = { DRAFT: 'Robocza', SCHEDULED: 'Zaplanowana', SENDING: 'Wysyłka', PAUSED: 'Wstrzymana', COMPLETED: 'Zakończona', CANCELLED: 'Anulowana', }; const ALL_SOURCES: LeadSource[] = ['REGISTERED_USER', 'MANUAL', 'IMPORT', 'FORM']; function errorMessage(err: unknown): string { return err instanceof Error ? err.message : 'Wystąpił błąd. Spróbuj ponownie.'; } /* ============================ LEADY + GRUPY DOCELOWE ============================ */ export function AdminLeadsView({ Icon }: { Icon: IconComponent }) { const [section, setSection] = useState<'leads' | 'groups'>('leads'); return (
{section === 'leads' ? : }
); } function LeadsSection({ Icon }: { Icon: IconComponent }) { const [leads, setLeads] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [sourceFilter, setSourceFilter] = useState<'' | LeadSource>(''); const [search, setSearch] = useState(''); const [busyId, setBusyId] = useState(null); // Formularz nowego leada. const [form, setForm] = useState({ name: '', email: '', phone: '', city: '', tags: '' }); const [saving, setSaving] = useState(false); // Import. const [csv, setCsv] = useState(''); const [importTag, setImportTag] = useState(''); const [importing, setImporting] = useState(false); const [importResult, setImportResult] = useState(null); const load = useCallback(async () => { setLoading(true); setError(null); try { const params = new URLSearchParams(); if (sourceFilter) params.set('source', sourceFilter); if (search.trim()) params.set('search', search.trim()); const data = await apiFetch(`/admin/leads${params.toString() ? `?${params.toString()}` : ''}`); setLeads(data); } catch (err) { setError(errorMessage(err)); } finally { setLoading(false); } }, [sourceFilter, search]); useEffect(() => { const timer = setTimeout(load, 200); return () => clearTimeout(timer); }, [load]); const addLead = async () => { if (!form.email.trim() && !form.phone.trim()) { setError('Podaj co najmniej e-mail lub telefon.'); return; } setSaving(true); setError(null); try { await apiFetch('/admin/leads', { method: 'POST', body: JSON.stringify({ name: form.name.trim() || null, email: form.email.trim() || null, phone: form.phone.trim() || null, city: form.city.trim() || null, tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean), }), }); setForm({ name: '', email: '', phone: '', city: '', tags: '' }); await load(); } catch (err) { setError(errorMessage(err)); } finally { setSaving(false); } }; const runImport = async () => { if (!csv.trim()) return; setImporting(true); setImportResult(null); setError(null); try { const res = await apiFetch<{ imported: number; skipped: number; messages: string[] }>( '/admin/leads/import', { method: 'POST', body: JSON.stringify({ csv, defaultTag: importTag.trim() || null }) }, ); setImportResult(`Dodano ${res.imported}, pominięto ${res.skipped}.`); setCsv(''); await load(); } catch (err) { setError(errorMessage(err)); } finally { setImporting(false); } }; const optOut = async (lead: Lead) => { setBusyId(lead.id); try { await apiFetch(`/admin/leads/${lead.id}`, { method: 'PATCH', body: JSON.stringify({ emailOptOut: true, smsOptOut: true }), }); await load(); } catch (err) { setError(errorMessage(err)); } finally { setBusyId(null); } }; const remove = async (lead: Lead) => { setBusyId(lead.id); try { await apiFetch(`/admin/leads/${lead.id}`, { method: 'DELETE' }); await load(); } catch (err) { setError(errorMessage(err)); } finally { setBusyId(null); } }; return (

Baza leadów

{leads.length} kontaktów
setSearch(e.target.value)} placeholder="Szukaj po nazwie, e-mailu, telefonie, mieście..." />
setForm({ ...form, name: e.target.value })} /> setForm({ ...form, email: e.target.value })} /> setForm({ ...form, phone: e.target.value })} /> setForm({ ...form, city: e.target.value })} /> setForm({ ...form, tags: e.target.value })} />
{error &&

{error}

} {loading ? (

Ładowanie...

) : leads.length === 0 ? (

Brak leadów. Dodaj kontakt lub zaimportuj listę.

) : (
{leads.map((lead) => ( ))}
NazwaKontaktMiastoŹródłoStatusZgody
{lead.name || '—'}
{lead.email && {lead.email}} {lead.phone && {lead.phone}}
{lead.city || '—'} {SOURCE_LABEL[lead.source]} {STATUS_LABEL[lead.status]}
@ SMS
{!(lead.emailOptOut && lead.smsOptOut) && ( )}
)}

Import kontaktów (CSV)

Kolumny: nazwa;email;telefon;miasto — jeden kontakt w wierszu, nagłówek opcjonalny.