mono/packages/ui/src/components/CampaignsManager.tsx
2026-03-21 20:18:25 +01:00

594 lines
30 KiB
TypeScript

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<string, string> = {
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 }) => (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[status || 'draft'] ?? STATUS_COLORS.draft}`}>
<T>{status || 'draft'}</T>
</span>
);
// ─── Campaign Form ────────────────────────────────────────────────────────────
const EMPTY: Partial<Campaign> = {
name: '', page_slug: '', page_id: '', subject: '',
group_ids: [], lang: '', tracking_id: '', vars: {}, status: 'draft',
};
const CampaignForm = ({
initial, onSave, onCancel, saving, groups, mailboxes
}: {
initial: Partial<Campaign>;
onSave: (c: Partial<Campaign>) => void;
onCancel: () => void;
saving: boolean;
groups: ContactGroup[];
mailboxes: MailboxItem[];
}) => {
const [c, setC] = useState<Partial<Campaign>>(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 (
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
<div className="space-y-1">
<Label><T>Campaign name</T></Label>
<Input value={c.name || ''} onChange={e => set('name', e.target.value)} placeholder={translate('March Newsletter')} />
</div>
<div className="space-y-1">
<Label><T>Subject</T></Label>
<Input value={c.subject || ''} onChange={e => set('subject', e.target.value)} placeholder={translate('Newsletter — March 2026')} />
</div>
<div className="grid grid-cols-2 gap-3">
{/* From Address */}
<div className="space-y-1 col-span-2">
<Label><T>Send From</T></Label>
<Select
value={c.meta?.settings?.from_address || 'default'}
onValueChange={v => set('meta', {
...(c.meta || {}),
settings: {
...((c.meta as any)?.settings || {}),
from_address: v === 'default' ? null : v
}
})}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={translate('System Default (config.json)')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"><T>System Default (config.json)</T></SelectItem>
{mailboxes.map(mb => (
<SelectItem key={mb.id} value={mb.user}>
{mb.user} {mb.label ? `(${mb.label})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label><T>Language</T></Label>
<Input value={c.lang || ''} onChange={e => set('lang', e.target.value)} placeholder="en, de…" />
</div>
<div className="space-y-1">
<Label><T>Tracking ID</T></Label>
<Input value={c.tracking_id || ''} onChange={e => set('tracking_id', e.target.value)} placeholder="mail-DD-HH-mm" />
</div>
</div>
{/* Page picker */}
<div className="space-y-1">
<Label className="flex items-center gap-1"><FileText className="h-3 w-3" /><T>Email page</T></Label>
<div className="flex items-center gap-2">
<Input readOnly value={c.page_slug || ''} placeholder={translate('Select a page…')} className="flex-1 cursor-pointer" onClick={() => setPagePicker(true)} />
<Button type="button" size="sm" variant="outline" onClick={() => setPagePicker(true)}>
<FileText className="h-3 w-3" />
</Button>
</div>
<PagePickerDialog
isOpen={pagePicker}
onClose={() => setPagePicker(false)}
onSelect={(page) => {
if (page) {
set('page_slug', page.slug);
set('page_id', page.id);
}
}}
currentValue={c.page_id || null}
/>
</div>
{/* Group picker */}
<div className="space-y-2">
<Label className="flex items-center gap-1"><Users className="h-3 w-3" /><T>Target groups</T></Label>
<ContactsPicker
value={c.group_ids || []}
onChange={(ids) => set('group_ids', ids)}
/>
{groupNames.length > 0 && (
<div className="flex flex-wrap gap-1">
{groupNames.map((name, i) => (
<Badge key={i} variant="secondary" className="text-xs">{name}</Badge>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}><T>Cancel</T></Button>
<Button onClick={() => onSave(c)} disabled={saving || !c.name?.trim()}>
<Check className="h-3 w-3 mr-1" /><T>{saving ? 'Saving…' : 'Save'}</T>
</Button>
</DialogFooter>
</div>
);
};
// ─── 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<Campaign[]>([]);
const [groups, setGroups] = useState<ContactGroup[]>([]);
const [mailboxes, setMailboxes] = useState<MailboxItem[]>([]);
const [loading, setLoading] = useState(true);
// Grid state from URL
const [filterModel, setFilterModel] = useState<GridFilterModel>(() => paramsToFilterModel(searchParams));
const [sortModel, setSortModel] = useState<GridSortModel>(() => paramsToSortModel(searchParams));
const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(() => paramsToVisibilityModel(searchParams));
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>(() => ({
page: parseInt(searchParams.get('page') || '0', 10),
pageSize: parseInt(searchParams.get('pageSize') || '25', 10),
}));
// Dialogs
const [editCampaign, setEditCampaign] = useState<Partial<Campaign> | null>(null);
const [isNew, setIsNew] = useState(false);
const [saving, setSaving] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [sendId, setSendId] = useState<string | null>(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<GridColDef[]>(() => [
{
field: 'name', headerName: translate('Name'), flex: 1, minWidth: 200,
renderCell: (params: any) => (
<div className="flex items-center gap-2 h-full min-w-0">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary/40 to-primary flex items-center justify-center text-white font-semibold text-xs shrink-0">
<Send className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex flex-col justify-center leading-tight mt-0.5">
<p className="font-medium text-sm truncate">{params.row.name || '—'}</p>
{params.row.subject && (
<p className="text-[11px] text-muted-foreground truncate">{params.row.subject}</p>
)}
</div>
</div>
)
},
{
field: 'actions', headerName: translate('Actions'), width: 130, align: 'right', headerAlign: 'right', sortable: false,
renderCell: (params: any) => (
<div className="flex items-center h-full justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity w-full pr-2">
<Button size="icon" variant="ghost" className="h-7 w-7 text-primary hover:text-primary"
onClick={(e) => { e.stopPropagation(); setSendId(params.row.id); }}>
<Send className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-green-600 hover:text-green-700 hover:bg-green-50"
onClick={(e) => {
e.stopPropagation();
// Open analytics pre-filtered by the tracking query
const url = `/admin/analytics?filter_search_contains=tracking%3D${params.row.tracking_id}`;
window.open(url, '_blank');
}}>
<BarChart2 className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={(e) => { e.stopPropagation(); setEditCampaign(params.row); setIsNew(false); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive"
onClick={(e) => { e.stopPropagation(); setDeleteId(params.row.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)
},
{
field: 'page_slug', headerName: translate('Page'), width: 160,
renderCell: (params: any) => (
<span className="text-xs text-muted-foreground truncate">
{params.value ? `/${params.value}` : '—'}
</span>
)
},
{
field: 'status', headerName: translate('Status'), width: 120,
renderCell: (params: any) => (
<div className="flex items-center h-full">
<StatusBadge status={params.row.status} />
</div>
)
},
{
field: 'group_ids', headerName: translate('Groups'), width: 180,
renderCell: (params: any) => {
const ids: string[] = params.row.group_ids || [];
return (
<div className="flex items-center gap-1 flex-wrap h-full py-1">
{ids.slice(0, 2).map(id => {
const g = groupById.get(id);
return <Badge key={id} variant="secondary" className="text-[10px] px-1.5 py-0 max-w-[66px] truncate">{g?.name || id.slice(0, 6)}</Badge>;
})}
{ids.length > 2 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">+{ids.length - 2}</Badge>
)}
</div>
);
}
},
{
field: 'stats', headerName: translate('Stats'), width: 140,
renderCell: (params: any) => {
const s = params.row.stats || {};
if (!s.total) return <span className="text-xs text-muted-foreground"></span>;
return (
<div className="flex items-center gap-1.5 h-full text-xs tabular-nums">
<span className="text-green-600">{s.sent ?? 0}</span>
{(s.failed ?? 0) > 0 && <span className="text-red-500">{s.failed}</span>}
{(s.skipped ?? 0) > 0 && <span className="text-muted-foreground">{s.skipped}</span>}
<span className="text-muted-foreground">/ {s.total}</span>
</div>
);
}
},
{
field: 'created_at', headerName: translate('Created'), width: 140,
renderCell: (params: any) => (
<span className="text-xs text-muted-foreground">
{params.value ? new Date(params.value).toLocaleDateString() : '—'}
</span>
)
},
], [groupById]);
// ── CRUD ──────────────────────────────────────────────────────────────────
const handleSave = async (data: Partial<Campaign>) => {
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 (
<div className="space-y-3">
{/* ── Toolbar ── */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[180px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input className="pl-8 h-8 text-sm" placeholder={translate('Search…')} value={q} onChange={e => setQ(e.target.value)} />
</div>
<Select value={filterStatus || '__all'} onValueChange={v => setFilterStatus(v === '__all' ? '' : v)}>
<SelectTrigger className="h-8 w-36 text-sm">
<ChevronDown className="h-3 w-3 mr-1 text-muted-foreground" />
<SelectValue placeholder={translate('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all"><T>All statuses</T></SelectItem>
{STATUSES.map(s => <SelectItem key={s} value={s}>{translate(s)}</SelectItem>)}
</SelectContent>
</Select>
<div className="flex items-center gap-1 ml-auto">
<Button size="sm" className="h-8 text-xs gap-1" onClick={() => { setEditCampaign({ ...EMPTY }); setIsNew(true); }}>
<Plus className="h-3 w-3" /><T>New Campaign</T>
</Button>
</div>
</div>
{/* ── DataGrid ── */}
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground"><T>Loading</T></div>
) : campaigns.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
<T>No campaigns found.</T>{' '}
<button className="underline" onClick={() => { setEditCampaign({ ...EMPTY }); setIsNew(true); }}>
<T>Create one</T>
</button>
</div>
) : (
<div className="h-[600px] w-full bg-background rounded-md border">
<MuiThemeProvider theme={muiTheme}>
<DataGrid
rows={campaigns}
columns={columns}
getRowId={(row) => 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))',
},
}}
/>
</MuiThemeProvider>
</div>
)}
<p className="text-xs text-muted-foreground text-right">
{campaigns.length} {translate(campaigns.length !== 1 ? 'campaigns' : 'campaign')}
</p>
{/* ── Edit / Create dialog ── */}
<Dialog open={!!editCampaign} onOpenChange={o => !o && setEditCampaign(null)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle><T>{isNew ? 'New Campaign' : 'Edit Campaign'}</T></DialogTitle>
</DialogHeader>
{editCampaign && (
<CampaignForm
initial={editCampaign}
onSave={handleSave}
onCancel={() => setEditCampaign(null)}
saving={saving}
groups={groups}
mailboxes={mailboxes}
/>
)}
</DialogContent>
</Dialog>
{/* ── Delete confirm ── */}
<AlertDialog open={!!deleteId} onOpenChange={o => !o && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle><T>Delete campaign?</T></AlertDialogTitle>
<AlertDialogDescription><T>This cannot be undone.</T></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
<T>Delete</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Send confirm ── */}
<AlertDialog open={!!sendId} onOpenChange={o => !o && !sending && setSendId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle><T>Send campaign?</T></AlertDialogTitle>
<AlertDialogDescription><T>This will send the email to all contacts in the target groups. This cannot be undone.</T></AlertDialogDescription>
</AlertDialogHeader>
<div className="py-3">
<Label className="text-sm"><T>Interval between sends (seconds)</T></Label>
<Input
type="number" min={0} step={0.5}
value={sendInterval}
onChange={e => setSendInterval(Math.max(0, parseFloat(e.target.value) || 0))}
className="mt-1 w-32 h-8 text-sm"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={sending}><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction onClick={handleSend} disabled={sending} className="bg-primary text-primary-foreground hover:bg-primary/90">
<Send className="h-3 w-3 mr-1" /><T>{sending ? 'Sending…' : 'Send'}</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};