import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { toast } from 'sonner'; import { useSearchParams } from 'react-router-dom'; import { DataGrid, GridColDef, type GridFilterModel, type GridSortModel, type GridColumnVisibilityModel, type GridPaginationModel } from '@mui/x-data-grid'; import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles'; import { useMuiTheme } from '@/hooks/useMuiTheme'; import { filterModelToParams, paramsToFilterModel, sortModelToParams, paramsToSortModel, visibilityModelToParams, paramsToVisibilityModel, } from '@/components/grids/gridUtils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; import { Plus, Search, Trash2, Pencil, Check, FileText, Users, Send, ChevronDown, BarChart2 } from 'lucide-react'; import { T, translate } from '@/i18n'; import { Campaign, fetchCampaigns, createCampaign, updateCampaign, deleteCampaign, sendCampaign, } from '@/modules/campaigns/client-campaigns'; import { fetchContactGroups, ContactGroup } from '@/modules/contacts/client-contacts'; import { PagePickerDialog } from '@/modules/pages/PagePickerDialog'; import { ContactsPicker } from '@/components/ContactsPicker'; import { listMailboxes, type MailboxItem } from '@/modules/contacts/client-mailboxes'; // ─── Status badge ───────────────────────────────────────────────────────────── const STATUS_COLORS: Record = { draft: 'bg-slate-500/15 text-slate-600', scheduled: 'bg-blue-500/15 text-blue-600', sending: 'bg-yellow-500/15 text-yellow-600', sent: 'bg-green-500/15 text-green-600', failed: 'bg-red-500/15 text-red-600', }; const STATUSES = ['draft', 'scheduled', 'sending', 'sent', 'failed'] as const; const StatusBadge = ({ status }: { status?: string }) => ( {status || 'draft'} ); // ─── Campaign Form ──────────────────────────────────────────────────────────── const EMPTY: Partial = { name: '', page_slug: '', page_id: '', subject: '', group_ids: [], lang: '', tracking_id: '', vars: {}, status: 'draft', }; const CampaignForm = ({ initial, onSave, onCancel, saving, groups, mailboxes }: { initial: Partial; onSave: (c: Partial) => void; onCancel: () => void; saving: boolean; groups: ContactGroup[]; mailboxes: MailboxItem[]; }) => { const [c, setC] = useState>(initial); const [pagePicker, setPagePicker] = useState(false); const set = (k: keyof Campaign, v: any) => setC(prev => ({ ...prev, [k]: v })); const groupNames = useMemo(() => { const gMap = new Map(groups.map(g => [g.id, g.name])); return (c.group_ids || []).map(id => gMap.get(id) || id); }, [c.group_ids, groups]); return (
set('name', e.target.value)} placeholder={translate('March Newsletter')} />
set('subject', e.target.value)} placeholder={translate('Newsletter — March 2026')} />
{/* From Address */}
set('lang', e.target.value)} placeholder="en, de…" />
set('tracking_id', e.target.value)} placeholder="mail-DD-HH-mm" />
{/* Page picker */}
setPagePicker(true)} />
setPagePicker(false)} onSelect={(page) => { if (page) { set('page_slug', page.slug); set('page_id', page.id); } }} currentValue={c.page_id || null} />
{/* Group picker */}
set('group_ids', ids)} /> {groupNames.length > 0 && (
{groupNames.map((name, i) => ( {name} ))}
)}
); }; // ─── Main component ─────────────────────────────────────────────────────────── export const CampaignsManager = () => { const muiTheme = useMuiTheme(); const [searchParams, setSearchParams] = useSearchParams(); const q = searchParams.get('q') || ''; const filterStatus = searchParams.get('status') || ''; const setFilter = useCallback((key: string, value: string) => { setSearchParams(prev => { const next = new URLSearchParams(prev); if (value) next.set(key, value); else next.delete(key); return next; }, { replace: true }); }, [setSearchParams]); const setQ = useCallback((v: string) => setFilter('q', v), [setFilter]); const setFilterStatus = useCallback((v: string) => setFilter('status', v), [setFilter]); const [campaigns, setCampaigns] = useState([]); const [groups, setGroups] = useState([]); const [mailboxes, setMailboxes] = useState([]); const [loading, setLoading] = useState(true); // Grid state from URL const [filterModel, setFilterModel] = useState(() => paramsToFilterModel(searchParams)); const [sortModel, setSortModel] = useState(() => paramsToSortModel(searchParams)); const [columnVisibilityModel, setColumnVisibilityModel] = useState(() => paramsToVisibilityModel(searchParams)); const [paginationModel, setPaginationModel] = useState(() => ({ page: parseInt(searchParams.get('page') || '0', 10), pageSize: parseInt(searchParams.get('pageSize') || '25', 10), })); // Dialogs const [editCampaign, setEditCampaign] = useState | null>(null); const [isNew, setIsNew] = useState(false); const [saving, setSaving] = useState(false); const [deleteId, setDeleteId] = useState(null); const [sendId, setSendId] = useState(null); const [sending, setSending] = useState(false); const [sendInterval, setSendInterval] = useState(1); const load = async () => { setLoading(true); try { const [c, g, mbs] = await Promise.all([ fetchCampaigns({ q: q || undefined, status: filterStatus || undefined }), fetchContactGroups(), listMailboxes(), ]); setCampaigns(c); setGroups(g); setMailboxes(mbs); } catch (e: any) { toast.error(e.message); } finally { setLoading(false); } }; useEffect(() => { load(); }, [q, filterStatus]); // Group name lookup const groupById = useMemo(() => new Map(groups.map(g => [g.id, g])), [groups]); // ── Columns ── const columns = useMemo(() => [ { field: 'name', headerName: translate('Name'), flex: 1, minWidth: 200, renderCell: (params: any) => (

{params.row.name || '—'}

{params.row.subject && (

{params.row.subject}

)}
) }, { field: 'actions', headerName: translate('Actions'), width: 130, align: 'right', headerAlign: 'right', sortable: false, renderCell: (params: any) => (
) }, { field: 'page_slug', headerName: translate('Page'), width: 160, renderCell: (params: any) => ( {params.value ? `/${params.value}` : '—'} ) }, { field: 'status', headerName: translate('Status'), width: 120, renderCell: (params: any) => (
) }, { field: 'group_ids', headerName: translate('Groups'), width: 180, renderCell: (params: any) => { const ids: string[] = params.row.group_ids || []; return (
{ids.slice(0, 2).map(id => { const g = groupById.get(id); return {g?.name || id.slice(0, 6)}; })} {ids.length > 2 && ( +{ids.length - 2} )}
); } }, { field: 'stats', headerName: translate('Stats'), width: 140, renderCell: (params: any) => { const s = params.row.stats || {}; if (!s.total) return ; return (
{s.sent ?? 0}✓ {(s.failed ?? 0) > 0 && {s.failed}✕} {(s.skipped ?? 0) > 0 && {s.skipped}⊘} / {s.total}
); } }, { field: 'created_at', headerName: translate('Created'), width: 140, renderCell: (params: any) => ( {params.value ? new Date(params.value).toLocaleDateString() : '—'} ) }, ], [groupById]); // ── CRUD ────────────────────────────────────────────────────────────────── const handleSave = async (data: Partial) => { setSaving(true); try { if (isNew) { const created = await createCampaign(data); setCampaigns(prev => [created, ...prev]); toast.success(translate('Campaign created')); } else if (editCampaign?.id) { const updated = await updateCampaign(editCampaign.id, data); setCampaigns(prev => prev.map(x => x.id === updated.id ? updated : x)); toast.success(translate('Campaign saved')); } setEditCampaign(null); } catch (e: any) { toast.error(e.message); } finally { setSaving(false); } }; const handleDelete = async () => { if (!deleteId) return; try { await deleteCampaign(deleteId); setCampaigns(prev => prev.filter(c => c.id !== deleteId)); toast.success(translate('Campaign deleted')); } catch (e: any) { toast.error(e.message); } finally { setDeleteId(null); } }; const handleSend = async () => { if (!sendId) return; setSending(true); try { const stats = await sendCampaign(sendId, sendInterval); toast.success(translate(`Sent: ${stats.sent}/${stats.total}${stats.failed ? `, ${stats.failed} failed` : ''}`)); await load(); // Refresh to show updated status + stats } catch (e: any) { toast.error(e.message); } finally { setSending(false); setSendId(null); } }; // ── Render ──────────────────────────────────────────────────────────────── return (
{/* ── Toolbar ── */}
setQ(e.target.value)} />
{/* ── DataGrid ── */} {loading ? (
Loading…
) : campaigns.length === 0 ? (
No campaigns found.{' '}
) : (
row.id} showToolbar disableRowSelectionOnClick filterModel={filterModel} onFilterModelChange={(newFilterModel) => { setFilterModel(newFilterModel); setSearchParams(prev => { const p = new URLSearchParams(prev); Array.from(p.keys()).forEach(k => { if (k.startsWith('filter_')) p.delete(k); }); Object.entries(filterModelToParams(newFilterModel)).forEach(([k, v]) => p.set(k, v)); return p; }, { replace: true }); }} sortModel={sortModel} onSortModelChange={(newSortModel) => { setSortModel(newSortModel); setSearchParams(prev => { const p = new URLSearchParams(prev); p.delete('sort'); const sp = sortModelToParams(newSortModel); if (sp.sort) p.set('sort', sp.sort); return p; }, { replace: true }); }} columnVisibilityModel={columnVisibilityModel} onColumnVisibilityModelChange={(newModel) => { setColumnVisibilityModel(newModel); setSearchParams(prev => { const p = new URLSearchParams(prev); p.delete('hidden'); const vp = visibilityModelToParams(newModel); if (vp.hidden !== undefined) p.set('hidden', vp.hidden); return p; }, { replace: true }); }} paginationModel={paginationModel} onPaginationModelChange={(newPaginationModel) => { setPaginationModel(newPaginationModel); setSearchParams(prev => { const p = new URLSearchParams(prev); if (newPaginationModel.page !== 0) p.set('page', String(newPaginationModel.page)); else p.delete('page'); if (newPaginationModel.pageSize !== 25) p.set('pageSize', String(newPaginationModel.pageSize)); else p.delete('pageSize'); return p; }, { replace: true }); }} pageSizeOptions={[10, 25, 50]} getRowClassName={() => 'group cursor-pointer hover:bg-muted/30 transition-colors'} sx={{ border: 0, bgcolor: 'transparent', color: 'hsl(var(--foreground))', '& .MuiDataGrid-columnHeaders': { borderColor: 'hsl(var(--border))', }, '& .MuiDataGrid-cell': { borderColor: 'hsl(var(--border))', '&:focus, &:focus-within': { outline: 'none' }, }, '& .MuiDataGrid-columnHeader': { '&:focus, &:focus-within': { outline: 'none' }, }, '& .MuiDataGrid-footerContainer': { borderColor: 'hsl(var(--border))', }, '& .MuiTablePagination-root': { color: 'hsl(var(--muted-foreground))', }, '& .MuiDataGrid-overlay': { bgcolor: 'transparent', }, '& .MuiCheckbox-root': { color: 'hsl(var(--muted-foreground))', '&.Mui-checked': { color: 'hsl(var(--primary))', }, }, '& .MuiDataGrid-columnSeparator': { color: 'hsl(var(--border))', }, '& .MuiDataGrid-menuIcon button, & .MuiDataGrid-iconButtonContainer button': { color: 'hsl(var(--muted-foreground))', }, }} />
)}

{campaigns.length} {translate(campaigns.length !== 1 ? 'campaigns' : 'campaign')}

{/* ── Edit / Create dialog ── */} !o && setEditCampaign(null)}> {isNew ? 'New Campaign' : 'Edit Campaign'} {editCampaign && ( setEditCampaign(null)} saving={saving} groups={groups} mailboxes={mailboxes} /> )} {/* ── Delete confirm ── */} !o && setDeleteId(null)}> Delete campaign? This cannot be undone. Cancel Delete {/* ── Send confirm ── */} !o && !sending && setSendId(null)}> Send campaign? This will send the email to all contacts in the target groups. This cannot be undone.
setSendInterval(Math.max(0, parseFloat(e.target.value) || 0))} className="mt-1 w-32 h-8 text-sm" />
Cancel {sending ? 'Sending…' : 'Send'}
); };