710 lines
34 KiB
TypeScript
710 lines
34 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
type Tab = 'overview' | 'campaigns' | 'distributions' | 'newsletters' | 'documents' | 'invoices' | 'reports';
|
|
|
|
interface Campaign {
|
|
id: string;
|
|
campaign_name: string;
|
|
client_name: string;
|
|
publication_id: string;
|
|
placement_zone: string;
|
|
start_date: string;
|
|
end_date: string;
|
|
status: string;
|
|
agreed_rate: number;
|
|
impressions: number;
|
|
clicks: number;
|
|
ctr: number;
|
|
mooninvoice_invoice_id?: string;
|
|
blackmagic_excluded: boolean;
|
|
}
|
|
|
|
interface EmailCampaign {
|
|
id: string;
|
|
campaign_name: string;
|
|
client_name: string;
|
|
publication_id: string;
|
|
subject: string;
|
|
send_date: string;
|
|
status: string;
|
|
sent_count: number;
|
|
agreed_rate?: number;
|
|
is_newsletter: boolean;
|
|
email_lists?: { list_name: string; subscriber_count: number };
|
|
}
|
|
|
|
interface Stats {
|
|
activeCampaigns: number;
|
|
pendingApprovals: number;
|
|
endingSoon: number;
|
|
emailsSentThisMonth: number;
|
|
revenueThisMonth: number;
|
|
recentActivity: Campaign[];
|
|
}
|
|
|
|
const PUBLICATIONS = [
|
|
{ id: 'avbeat', name: 'AV Beat', short: 'AVB' },
|
|
{ id: 'av-beat', name: 'AV Beat', short: 'AV' },
|
|
];
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
pending_info: 'bg-yellow-900/50 text-yellow-300 border-yellow-700',
|
|
pending_approval: 'bg-blue-900/50 text-blue-300 border-blue-700',
|
|
active: 'bg-green-900/50 text-green-300 border-green-700',
|
|
paused: 'bg-gray-700/50 text-gray-300 border-gray-600',
|
|
ended: 'bg-gray-800/50 text-gray-400 border-gray-700',
|
|
cancelled: 'bg-red-900/50 text-red-300 border-red-700',
|
|
draft: 'bg-gray-700/50 text-gray-300 border-gray-600',
|
|
scheduled: 'bg-purple-900/50 text-purple-300 border-purple-700',
|
|
sent: 'bg-green-900/50 text-green-300 border-green-700',
|
|
held: 'bg-red-900/50 text-red-300 border-red-700',
|
|
};
|
|
|
|
export default function AdOpsDashboard() {
|
|
const { user, loading } = useAuth();
|
|
const router = useRouter();
|
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
const [authChecked, setAuthChecked] = useState(false);
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
|
const [emailCampaigns, setEmailCampaigns] = useState<EmailCampaign[]>([]);
|
|
const [filterPub, setFilterPub] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState('');
|
|
const [filterClient, setFilterClient] = useState('');
|
|
const [showNewCampaign, setShowNewCampaign] = useState(false);
|
|
const [newCampaign, setNewCampaign] = useState({
|
|
campaign_name: '', client_name: '', publication_id: '', placement_zone: '',
|
|
destination_url: '', start_date: '', end_date: '', agreed_rate: '', notes: '',
|
|
});
|
|
const [saving, setSaving] = useState(false);
|
|
const [inboxLogs, setInboxLogs] = useState<any[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user) { router.replace('/'); return; }
|
|
|
|
fetch('/api/adops/stats')
|
|
.then(r => {
|
|
if (r.status === 404) { router.replace('/'); return null; }
|
|
return r.json();
|
|
})
|
|
.then(data => {
|
|
if (!data) return;
|
|
setIsAdmin(true);
|
|
setStats(data);
|
|
setAuthChecked(true);
|
|
})
|
|
.catch(() => router.replace('/'));
|
|
}, [user, loading, router]);
|
|
|
|
useEffect(() => {
|
|
if (!isAdmin) return;
|
|
loadCampaigns();
|
|
loadEmailCampaigns();
|
|
}, [isAdmin, filterPub, filterStatus, filterClient]);
|
|
|
|
async function loadCampaigns() {
|
|
const params = new URLSearchParams();
|
|
if (filterPub) params.set('publication', filterPub);
|
|
if (filterStatus) params.set('status', filterStatus);
|
|
if (filterClient) params.set('client', filterClient);
|
|
const res = await fetch(`/api/adops/campaigns?${params}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setCampaigns(data.campaigns || []);
|
|
}
|
|
}
|
|
|
|
async function loadEmailCampaigns() {
|
|
const res = await fetch('/api/adops/email-campaigns');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setEmailCampaigns(data.campaigns || []);
|
|
}
|
|
}
|
|
|
|
async function loadInboxLogs() {
|
|
const res = await fetch('/api/adops/poll');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setInboxLogs(data.logs || []);
|
|
}
|
|
}
|
|
|
|
async function handleCreateCampaign() {
|
|
setSaving(true);
|
|
const res = await fetch('/api/adops/campaigns', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
...newCampaign,
|
|
agreed_rate: parseFloat(newCampaign.agreed_rate) || null,
|
|
campaign_type: 'banner',
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
setShowNewCampaign(false);
|
|
setNewCampaign({ campaign_name: '', client_name: '', publication_id: '', placement_zone: '', destination_url: '', start_date: '', end_date: '', agreed_rate: '', notes: '' });
|
|
loadCampaigns();
|
|
}
|
|
setSaving(false);
|
|
}
|
|
|
|
async function handleStatusChange(campaignId: string, newStatus: string) {
|
|
await fetch('/api/adops/campaigns', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: campaignId, status: newStatus }),
|
|
});
|
|
loadCampaigns();
|
|
}
|
|
|
|
async function triggerPoll() {
|
|
await fetch('/api/adops/poll', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
loadInboxLogs();
|
|
}
|
|
|
|
if (loading || !authChecked) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
|
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isAdmin) return null;
|
|
|
|
const tabs: { id: Tab; label: string }[] = [
|
|
{ id: 'overview', label: 'Overview' },
|
|
{ id: 'campaigns', label: 'Banner Campaigns' },
|
|
{ id: 'distributions', label: 'Email Distributions' },
|
|
{ id: 'newsletters', label: 'Newsletters' },
|
|
{ id: 'documents', label: 'Documents' },
|
|
{ id: 'invoices', label: 'Invoices' },
|
|
{ id: 'reports', label: 'Reports' },
|
|
];
|
|
|
|
const newsletters = emailCampaigns.filter(c => c.is_newsletter);
|
|
const distributions = emailCampaigns.filter(c => !c.is_newsletter);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-950 text-gray-100">
|
|
{/* Header */}
|
|
<div className="border-b border-gray-800 bg-gray-900">
|
|
<div className="max-w-screen-xl mx-auto px-6 py-4">
|
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-orange-600 rounded-lg flex items-center justify-center">
|
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-lg font-bold text-white">Ad Ops & Email Marketing</h1>
|
|
<p className="text-xs text-gray-400">Ryan Salazar — Admin Only</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={triggerPoll}
|
|
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-200 text-xs rounded-md transition-colors"
|
|
>
|
|
Poll Inbox
|
|
</button>
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-orange-900/50 text-orange-300 border border-orange-700">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-orange-400" />
|
|
adops@avbeat.com
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 mt-4 border-b border-gray-800 -mb-px overflow-x-auto">
|
|
{tabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => { setActiveTab(tab.id); if (tab.id === 'overview') loadInboxLogs(); }}
|
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
|
activeTab === tab.id
|
|
? 'border-orange-500 text-orange-400' :'border-transparent text-gray-400 hover:text-gray-200'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<div className="max-w-screen-xl mx-auto px-6 py-6">
|
|
|
|
{/* OVERVIEW TAB */}
|
|
{activeTab === 'overview' && (
|
|
<div className="space-y-6">
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
{[
|
|
{ label: 'Active Campaigns', value: stats?.activeCampaigns || 0, color: 'text-green-400' },
|
|
{ label: 'Pending Approvals', value: stats?.pendingApprovals || 0, color: 'text-yellow-400' },
|
|
{ label: 'Ending in 7 Days', value: stats?.endingSoon || 0, color: 'text-orange-400' },
|
|
{ label: 'Emails This Month', value: stats?.emailsSentThisMonth || 0, color: 'text-blue-400' },
|
|
{ label: 'Revenue This Month', value: `$${(stats?.revenueThisMonth || 0).toLocaleString()}`, color: 'text-emerald-400' },
|
|
].map(card => (
|
|
<div key={card.label} className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
|
<p className="text-xs text-gray-400 mb-1">{card.label}</p>
|
|
<p className={`text-2xl font-bold ${card.color}`}>{card.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Recent Activity */}
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
|
<h3 className="text-sm font-semibold text-gray-200 mb-3">Recent Campaign Activity</h3>
|
|
{stats?.recentActivity?.length ? (
|
|
<div className="space-y-2">
|
|
{stats.recentActivity.map(c => (
|
|
<div key={c.id} className="flex items-center justify-between py-2 border-b border-gray-800 last:border-0">
|
|
<div>
|
|
<p className="text-sm text-gray-200">{c.campaign_name || c.client_name}</p>
|
|
<p className="text-xs text-gray-500">{c.publication_id} • {c.placement_zone}</p>
|
|
</div>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${STATUS_COLORS[c.status] || 'bg-gray-800 text-gray-400 border-gray-700'}`}>
|
|
{c.status.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-500">No recent activity</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Inbox Log */}
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold text-gray-200">Inbox Log</h3>
|
|
<button onClick={loadInboxLogs} className="text-xs text-gray-400 hover:text-gray-200">Refresh</button>
|
|
</div>
|
|
{inboxLogs.length ? (
|
|
<div className="space-y-2">
|
|
{inboxLogs.slice(0, 10).map(log => (
|
|
<div key={log.id} className="flex items-center justify-between py-2 border-b border-gray-800 last:border-0">
|
|
<div>
|
|
<p className="text-sm text-gray-200">{log.subject || '(no subject)'}</p>
|
|
<p className="text-xs text-gray-500">{log.from_email} • {new Date(log.received_at).toLocaleString()}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-400">{log.request_type}</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${
|
|
log.processing_status === 'completed' ? 'bg-green-900/50 text-green-300 border-green-700' :
|
|
log.processing_status === 'error'? 'bg-red-900/50 text-red-300 border-red-700' : 'bg-gray-700/50 text-gray-300 border-gray-600'
|
|
}`}>
|
|
{log.processing_status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-500">No inbox logs yet. Click "Poll Inbox" to check for new emails.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* BANNER CAMPAIGNS TAB */}
|
|
{activeTab === 'campaigns' && (
|
|
<div className="space-y-4">
|
|
{/* Filters + New Campaign */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<select
|
|
value={filterPub}
|
|
onChange={e => setFilterPub(e.target.value)}
|
|
className="bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded-md px-3 py-2"
|
|
>
|
|
<option value="">All Publications</option>
|
|
{PUBLICATIONS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
</select>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={e => setFilterStatus(e.target.value)}
|
|
className="bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded-md px-3 py-2"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
{['pending_info','pending_approval','active','paused','ended','cancelled'].map(s => (
|
|
<option key={s} value={s}>{s.replace('_', ' ')}</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
type="text"
|
|
placeholder="Search client..."
|
|
value={filterClient}
|
|
onChange={e => setFilterClient(e.target.value)}
|
|
className="bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded-md px-3 py-2 w-48"
|
|
/>
|
|
<button
|
|
onClick={() => setShowNewCampaign(true)}
|
|
className="ml-auto px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white text-sm font-medium rounded-md transition-colors"
|
|
>
|
|
+ New Campaign
|
|
</button>
|
|
</div>
|
|
|
|
{/* New Campaign Form */}
|
|
{showNewCampaign && (
|
|
<div className="bg-gray-900 border border-gray-700 rounded-lg p-5">
|
|
<h3 className="text-sm font-semibold text-gray-200 mb-4">New Banner Campaign</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{[
|
|
{ key: 'campaign_name', label: 'Campaign Name', type: 'text' },
|
|
{ key: 'client_name', label: 'Client Name', type: 'text' },
|
|
{ key: 'destination_url', label: 'Click-Through URL', type: 'url' },
|
|
{ key: 'agreed_rate', label: 'Agreed Rate ($)', type: 'number' },
|
|
{ key: 'start_date', label: 'Start Date', type: 'date' },
|
|
{ key: 'end_date', label: 'End Date', type: 'date' },
|
|
].map(field => (
|
|
<div key={field.key}>
|
|
<label className="block text-xs text-gray-400 mb-1">{field.label}</label>
|
|
<input
|
|
type={field.type}
|
|
value={(newCampaign as any)[field.key]}
|
|
onChange={e => setNewCampaign(prev => ({ ...prev, [field.key]: e.target.value }))}
|
|
className="w-full bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded-md px-3 py-2"
|
|
/>
|
|
</div>
|
|
))}
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">Publication</label>
|
|
<select
|
|
value={newCampaign.publication_id}
|
|
onChange={e => setNewCampaign(prev => ({ ...prev, publication_id: e.target.value }))}
|
|
className="w-full bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded-md px-3 py-2"
|
|
>
|
|
<option value="">Select publication</option>
|
|
{PUBLICATIONS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">Placement Zone</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. leaderboard_top"
|
|
value={newCampaign.placement_zone}
|
|
onChange={e => setNewCampaign(prev => ({ ...prev, placement_zone: e.target.value }))}
|
|
className="w-full bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded-md px-3 py-2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 mt-4">
|
|
<button
|
|
onClick={handleCreateCampaign}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white text-sm font-medium rounded-md transition-colors disabled:opacity-50"
|
|
>
|
|
{saving ? 'Creating...' : 'Create Campaign'}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowNewCampaign(false)}
|
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm rounded-md transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Campaigns Table */}
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-800 bg-gray-800/50">
|
|
{['Client', 'Publication', 'Zone', 'Dates', 'Status', 'Impressions', 'Clicks', 'CTR', 'Revenue', 'Invoice', 'Actions'].map(h => (
|
|
<th key={h} className="text-left px-4 py-3 text-xs font-medium text-gray-400 uppercase tracking-wide">{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{campaigns.length === 0 ? (
|
|
<tr><td colSpan={11} className="px-4 py-8 text-center text-gray-500">No campaigns found</td></tr>
|
|
) : campaigns.map(c => (
|
|
<tr key={c.id} className="border-b border-gray-800 hover:bg-gray-800/30">
|
|
<td className="px-4 py-3">
|
|
<p className="font-medium text-gray-200">{c.client_name}</p>
|
|
{c.blackmagic_excluded && <span className="text-xs text-yellow-400">⚠ BMD excluded from email</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-300">{PUBLICATIONS.find(p => p.id === c.publication_id)?.short || c.publication_id}</td>
|
|
<td className="px-4 py-3 text-gray-400 text-xs">{c.placement_zone}</td>
|
|
<td className="px-4 py-3 text-gray-400 text-xs">
|
|
<div>{c.start_date}</div>
|
|
<div>{c.end_date}</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${STATUS_COLORS[c.status] || 'bg-gray-800 text-gray-400 border-gray-700'}`}>
|
|
{c.status.replace(/_/g, ' ')}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-300">{c.impressions?.toLocaleString() || 0}</td>
|
|
<td className="px-4 py-3 text-gray-300">{c.clicks?.toLocaleString() || 0}</td>
|
|
<td className="px-4 py-3 text-gray-300">{((c.ctr || 0) * 100).toFixed(2)}%</td>
|
|
<td className="px-4 py-3 text-emerald-400 font-medium">${(c.agreed_rate || 0).toLocaleString()}</td>
|
|
<td className="px-4 py-3 text-xs text-gray-400">{c.mooninvoice_invoice_id || '—'}</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex gap-1">
|
|
{c.status === 'pending_approval' && (
|
|
<button
|
|
onClick={() => handleStatusChange(c.id, 'active')}
|
|
className="px-2 py-1 bg-green-700 hover:bg-green-600 text-white text-xs rounded"
|
|
>
|
|
Approve
|
|
</button>
|
|
)}
|
|
{c.status === 'active' && (
|
|
<button
|
|
onClick={() => handleStatusChange(c.id, 'paused')}
|
|
className="px-2 py-1 bg-yellow-700 hover:bg-yellow-600 text-white text-xs rounded"
|
|
>
|
|
Pause
|
|
</button>
|
|
)}
|
|
{c.status === 'paused' && (
|
|
<button
|
|
onClick={() => handleStatusChange(c.id, 'active')}
|
|
className="px-2 py-1 bg-blue-700 hover:bg-blue-600 text-white text-xs rounded"
|
|
>
|
|
Resume
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* EMAIL DISTRIBUTIONS TAB */}
|
|
{activeTab === 'distributions' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-base font-semibold text-gray-200">Email Distributions</h2>
|
|
<button className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white text-sm font-medium rounded-md transition-colors">
|
|
+ New Distribution
|
|
</button>
|
|
</div>
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-800 bg-gray-800/50">
|
|
{['Campaign', 'Publication', 'List', 'Send Date', 'Sent', 'Status', 'Revenue'].map(h => (
|
|
<th key={h} className="text-left px-4 py-3 text-xs font-medium text-gray-400 uppercase tracking-wide">{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{distributions.length === 0 ? (
|
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-500">No distributions yet</td></tr>
|
|
) : distributions.map(c => (
|
|
<tr key={c.id} className="border-b border-gray-800 hover:bg-gray-800/30">
|
|
<td className="px-4 py-3">
|
|
<p className="font-medium text-gray-200">{c.campaign_name}</p>
|
|
<p className="text-xs text-gray-500">{c.client_name}</p>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-300">{PUBLICATIONS.find(p => p.id === c.publication_id)?.short || c.publication_id}</td>
|
|
<td className="px-4 py-3 text-gray-400 text-xs">{c.email_lists?.list_name || '—'}</td>
|
|
<td className="px-4 py-3 text-gray-300">{c.send_date || '—'}</td>
|
|
<td className="px-4 py-3 text-gray-300">{c.sent_count?.toLocaleString() || 0}</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${STATUS_COLORS[c.status] || 'bg-gray-800 text-gray-400 border-gray-700'}`}>
|
|
{c.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-emerald-400">${(c.agreed_rate || 0).toLocaleString()}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* NEWSLETTERS TAB */}
|
|
{activeTab === 'newsletters' && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-base font-semibold text-gray-200">Bi-Weekly Newsletters</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{PUBLICATIONS.map(pub => {
|
|
const pubNewsletters = newsletters.filter(n => n.publication_id === pub.id);
|
|
const latest = pubNewsletters[0];
|
|
return (
|
|
<div key={pub.id} className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="font-semibold text-gray-200">{pub.name}</h3>
|
|
<span className="text-xs text-gray-400">Every other Thursday</span>
|
|
</div>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">Next send:</span>
|
|
<span className="text-gray-200">Thursday 11:00 AM ET</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">Total issues:</span>
|
|
<span className="text-gray-200">{pubNewsletters.length}</span>
|
|
</div>
|
|
{latest && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">Last status:</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${STATUS_COLORS[latest.status] || 'bg-gray-800 text-gray-400 border-gray-700'}`}>
|
|
{latest.status}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{latest?.status === 'pending_approval' && (
|
|
<button className="mt-3 w-full px-3 py-2 bg-green-700 hover:bg-green-600 text-white text-sm font-medium rounded-md transition-colors">
|
|
Approve Newsletter
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Newsletter History */}
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-gray-800">
|
|
<h3 className="text-sm font-semibold text-gray-200">Newsletter History</h3>
|
|
</div>
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-800 bg-gray-800/50">
|
|
{['Publication', 'Issue', 'Send Date', 'Sent', 'Status'].map(h => (
|
|
<th key={h} className="text-left px-4 py-3 text-xs font-medium text-gray-400 uppercase tracking-wide">{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{newsletters.length === 0 ? (
|
|
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">No newsletters yet</td></tr>
|
|
) : newsletters.map(n => (
|
|
<tr key={n.id} className="border-b border-gray-800 hover:bg-gray-800/30">
|
|
<td className="px-4 py-3 text-gray-200">{PUBLICATIONS.find(p => p.id === n.publication_id)?.name || n.publication_id}</td>
|
|
<td className="px-4 py-3 text-gray-400">#{n.newsletter_issue_number || '—'}</td>
|
|
<td className="px-4 py-3 text-gray-300">{n.send_date || '—'}</td>
|
|
<td className="px-4 py-3 text-gray-300">{n.sent_count?.toLocaleString() || 0}</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${STATUS_COLORS[n.status] || 'bg-gray-800 text-gray-400 border-gray-700'}`}>
|
|
{n.status}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* DOCUMENTS TAB */}
|
|
{activeTab === 'documents' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-base font-semibold text-gray-200">Insertion Orders & Documents</h2>
|
|
</div>
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg p-8 text-center">
|
|
<svg className="w-12 h-12 text-gray-600 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
<p className="text-gray-400 text-sm">Documents are attached via email and stored automatically.</p>
|
|
<p className="text-gray-500 text-xs mt-1">Jeff attaches IOs to campaign emails — they appear here once processed.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* INVOICES TAB */}
|
|
{activeTab === 'invoices' && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-base font-semibold text-gray-200">Campaign Invoices</h2>
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-800 bg-gray-800/50">
|
|
{['Campaign', 'Client', 'Publication', 'Amount', 'Invoice #', 'Status'].map(h => (
|
|
<th key={h} className="text-left px-4 py-3 text-xs font-medium text-gray-400 uppercase tracking-wide">{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{campaigns.filter(c => c.mooninvoice_invoice_id || c.agreed_rate).length === 0 ? (
|
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">No invoiced campaigns yet</td></tr>
|
|
) : campaigns.filter(c => c.agreed_rate).map(c => (
|
|
<tr key={c.id} className="border-b border-gray-800 hover:bg-gray-800/30">
|
|
<td className="px-4 py-3 text-gray-200">{c.campaign_name || c.client_name}</td>
|
|
<td className="px-4 py-3 text-gray-300">{c.client_name}</td>
|
|
<td className="px-4 py-3 text-gray-400">{PUBLICATIONS.find(p => p.id === c.publication_id)?.short || c.publication_id}</td>
|
|
<td className="px-4 py-3 text-emerald-400 font-medium">${(c.agreed_rate || 0).toLocaleString()}</td>
|
|
<td className="px-4 py-3 text-gray-400 text-xs">{c.mooninvoice_invoice_id || 'Pending'}</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${STATUS_COLORS[c.status] || 'bg-gray-800 text-gray-400 border-gray-700'}`}>
|
|
{c.status.replace(/_/g, ' ')}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* REPORTS TAB */}
|
|
{activeTab === 'reports' && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-base font-semibold text-gray-200">Performance Reports</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{PUBLICATIONS.map(pub => {
|
|
const pubCampaigns = campaigns.filter(c => c.publication_id === pub.id);
|
|
const totalRevenue = pubCampaigns.reduce((sum, c) => sum + (c.agreed_rate || 0), 0);
|
|
const activeCnt = pubCampaigns.filter(c => c.status === 'active').length;
|
|
const totalImpressions = pubCampaigns.reduce((sum, c) => sum + (c.impressions || 0), 0);
|
|
return (
|
|
<div key={pub.id} className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
|
<h3 className="font-semibold text-gray-200 mb-3">{pub.name}</h3>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between"><span className="text-gray-400">Active campaigns</span><span className="text-gray-200">{activeCnt}</span></div>
|
|
<div className="flex justify-between"><span className="text-gray-400">Total campaigns</span><span className="text-gray-200">{pubCampaigns.length}</span></div>
|
|
<div className="flex justify-between"><span className="text-gray-400">Total impressions</span><span className="text-gray-200">{totalImpressions.toLocaleString()}</span></div>
|
|
<div className="flex justify-between"><span className="text-gray-400">Total revenue</span><span className="text-emerald-400 font-medium">${totalRevenue.toLocaleString()}</span></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Export */}
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
|
<h3 className="text-sm font-semibold text-gray-200 mb-3">Export Reports</h3>
|
|
<div className="flex gap-3">
|
|
<button className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm rounded-md transition-colors">
|
|
Export Campaigns CSV
|
|
</button>
|
|
<button className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm rounded-md transition-colors">
|
|
Export Revenue Report
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|