initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,709 @@
'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: 'broadcast-beat', name: 'Broadcast Beat', short: 'BB' },
{ 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@broadcastbeat.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>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
interface EndpointResult {
loading: boolean;
status: number | null;
json: unknown;
error: string | null;
fetchedAt: string | null;
}
const defaultResult = (): EndpointResult => ({
loading: false,
status: null,
json: null,
error: null,
fetchedAt: null,
});
export default function AIHealthPage() {
const [ollamaHealth, setOllamaHealth] = useState<EndpointResult>(defaultResult());
const [nodeHealth, setNodeHealth] = useState<EndpointResult>(defaultResult());
const fetchEndpoint = useCallback(
async (
url: string,
setter: React.Dispatch<React.SetStateAction<EndpointResult>>
) => {
setter((prev) => ({ ...prev, loading: true, error: null }));
try {
const res = await fetch(url);
const json = await res.json().catch(() => null);
setter({
loading: false,
status: res.status,
json,
error: null,
fetchedAt: new Date().toISOString(),
});
} catch (err) {
setter({
loading: false,
status: null,
json: null,
error: err instanceof Error ? err.message : 'Fetch failed',
fetchedAt: new Date().toISOString(),
});
}
},
[]
);
const fetchAll = useCallback(() => {
fetchEndpoint('/api/ai/ollama-health', setOllamaHealth);
fetchEndpoint('/api/ai/node-health', setNodeHealth);
}, [fetchEndpoint]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
return (
<div className="min-h-screen bg-gray-950 text-gray-100 p-6">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">AI Node Health</h1>
<p className="text-gray-400 text-sm mt-1">
Live responses from AI_001 health endpoints
</p>
</div>
<button
onClick={fetchAll}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
{/* Endpoint Cards */}
<div className="grid grid-cols-1 gap-6">
<EndpointCard
title="GET /api/ai/ollama-health"
description="Pings AI_001 at /ping — no auth required"
result={ollamaHealth}
onRefresh={() => fetchEndpoint('/api/ai/ollama-health', setOllamaHealth)}
/>
<EndpointCard
title="GET /api/ai/node-health"
description="Calls AI_001 at /health — uses AI_NODE_API_KEY bearer token"
result={nodeHealth}
onRefresh={() => fetchEndpoint('/api/ai/node-health', setNodeHealth)}
/>
</div>
</div>
</div>
);
}
interface EndpointCardProps {
title: string;
description: string;
result: EndpointResult;
onRefresh: () => void;
}
function EndpointCard({ title, description, result, onRefresh }: EndpointCardProps) {
const isOk =
result.status !== null && result.status >= 200 && result.status < 300;
const hasData = result.json !== null || result.error !== null;
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
{/* Card Header */}
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-800">
<div>
<div className="flex items-center gap-3">
<code className="text-indigo-400 font-mono text-sm font-semibold">{title}</code>
{result.status !== null && (
<span
className={`px-2 py-0.5 rounded text-xs font-bold ${
isOk
? 'bg-emerald-900/60 text-emerald-400 border border-emerald-700' :'bg-red-900/60 text-red-400 border border-red-700'
}`}
>
HTTP {result.status}
</span>
)}
{result.loading && (
<span className="px-2 py-0.5 rounded text-xs font-bold bg-yellow-900/60 text-yellow-400 border border-yellow-700 animate-pulse">
Loading
</span>
)}
</div>
<p className="text-gray-500 text-xs mt-1">{description}</p>
</div>
<button
onClick={onRefresh}
disabled={result.loading}
className="text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-40 ml-4 mt-0.5"
title="Refresh this endpoint"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
{/* Raw JSON Output */}
<div className="px-5 py-4">
{result.loading && !hasData && (
<div className="flex items-center gap-2 text-gray-500 text-sm py-4">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Fetching
</div>
)}
{!result.loading && !hasData && (
<p className="text-gray-600 text-sm py-4">No data yet.</p>
)}
{result.error && result.json === null && (
<div className="bg-red-950/40 border border-red-800 rounded-lg p-4">
<p className="text-red-400 text-sm font-mono">{result.error}</p>
</div>
)}
{result.json !== null && (
<pre className="bg-gray-950 border border-gray-800 rounded-lg p-4 text-sm font-mono text-green-300 overflow-x-auto whitespace-pre-wrap break-words leading-relaxed">
{JSON.stringify(result.json, null, 2)}
</pre>
)}
{result.fetchedAt && (
<p className="text-gray-600 text-xs mt-2">
Last fetched: {new Date(result.fetchedAt).toLocaleTimeString()}
</p>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,444 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import { BB_PEN_NAMES, AV_PEN_NAMES } from '@/lib/auth/permissions';
interface Settings {
ollama_endpoint: string;
ollama_model_default: string;
ollama_model_translation: string;
ollama_model_background: string;
auto_failover: boolean;
failover_timeout_seconds: number;
default_site_id: number;
default_pen_name_bb: string;
default_pen_name_av: string;
save_session_history: boolean;
history_retention_days: number;
streaming_enabled: boolean;
background_use_local: boolean;
background_timeout_seconds: number;
priority_queue_enabled: boolean;
}
interface ConnectionTest {
online: boolean;
latencyMs: number;
models: string[];
}
export default function AIConsoleSettingsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [settings, setSettings] = useState<Settings>({
ollama_endpoint: '',
ollama_model_default: '',
ollama_model_translation: '',
ollama_model_background: '',
auto_failover: true,
failover_timeout_seconds: 8,
default_site_id: 1,
default_pen_name_bb: 'Michael Strand',
default_pen_name_av: 'Rex Chandler',
save_session_history: true,
history_retention_days: 30,
streaming_enabled: true,
background_use_local: true,
background_timeout_seconds: 120,
priority_queue_enabled: true,
});
const [loadingSettings, setLoadingSettings] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [connectionResult, setConnectionResult] = useState<ConnectionTest | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 4000);
};
useEffect(() => {
if (!loading && !user) router.push('/login');
}, [user, loading, router]);
useEffect(() => {
if (!user) return;
fetch('/api/ai/console-settings')
.then(r => r.json())
.then(data => {
if (data.settings) setSettings(data.settings);
})
.catch(() => {})
.finally(() => setLoadingSettings(false));
}, [user]);
const handleTestConnection = async () => {
setTesting(true);
setConnectionResult(null);
try {
const res = await fetch('/api/ai/console-settings?action=test_connection');
const data = await res.json();
setConnectionResult(data);
} catch {
setConnectionResult({ online: false, latencyMs: 0, models: [] });
} finally {
setTesting(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch('/api/ai/console-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_settings', ...settings }),
});
const data = await res.json();
if (data.success) showToast('Settings saved');
else showToast(data.error || 'Failed to save', 'error');
} catch {
showToast('Failed to save', 'error');
} finally {
setSaving(false);
}
};
const update = (key: keyof Settings, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
if (loading || loadingSettings) {
return (
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center">
<div className="w-6 h-6 border-2 border-[#3b82f6]/30 border-t-[#3b82f6] rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-[#0d1117]">
{/* Header */}
<div className="bg-[#111827] border-b border-[#1e2a3a] px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/ai-console" className="text-[#555] hover:text-[#3b82f6] text-sm transition-colors"> AI Console</Link>
<span className="text-[#333]">/</span>
<h1 className="text-white font-bold text-sm">AI Console Settings</h1>
</div>
<button
onClick={handleSave}
disabled={saving}
className="bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{/* Local AI Server */}
<section className="bg-[#111827] border border-[#1e2a3a] rounded-xl p-6">
<h2 className="text-white font-bold text-base mb-1">Local AI Server (Ollama)</h2>
<p className="text-[#555] text-xs mb-5">
Connect to your local Ollama instance running on your Windows PC with 24GB GPU.
</p>
{/* Windows sleep warning */}
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-4 mb-5">
<div className="flex gap-3">
<span className="text-yellow-400 text-lg flex-shrink-0"></span>
<div>
<p className="text-yellow-300 text-sm font-semibold mb-1">Windows Sleep / Hibernate Warning</p>
<p className="text-yellow-200/70 text-xs leading-relaxed">
Your Windows server may sleep or hibernate when inactive, causing the local AI to go offline.
To prevent this:
</p>
<ul className="text-yellow-200/70 text-xs mt-2 space-y-1 ml-3">
<li> <strong className="text-yellow-200">Option 1:</strong> Windows Settings System Power &amp; Sleep Sleep set to <strong className="text-yellow-200">Never</strong></li>
<li> <strong className="text-yellow-200">Option 2:</strong> Download the free <strong className="text-yellow-200">Caffeine</strong> app for Windows keeps your PC awake automatically</li>
</ul>
<p className="text-yellow-200/50 text-[10px] mt-2">
The Health Dashboard will alert you if the local server has been offline for more than 30 minutes.
</p>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Endpoint URL</label>
<input
type="text"
value={settings.ollama_endpoint}
onChange={e => update('ollama_endpoint', e.target.value)}
placeholder="http://192.168.1.x:11434 or https://your-tunnel.ngrok.io"
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6] placeholder-[#333]"
/>
<p className="text-[#444] text-[10px] mt-1">
Use direct IP if on the same network, or a tunnel URL (ngrok, Cloudflare Tunnel, Tailscale) for remote access.
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleTestConnection}
disabled={testing || !settings.ollama_endpoint}
className="bg-[#1e2a3a] hover:bg-[#253347] disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
{testing ? 'Testing...' : 'Test Connection'}
</button>
{connectionResult && (
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${
connectionResult.online
? 'bg-green-900/30 border border-green-700/40 text-green-400' :'bg-red-900/30 border border-red-700/40 text-red-400'
}`}>
<div className={`w-2 h-2 rounded-full ${connectionResult.online ? 'bg-green-500' : 'bg-red-500'}`} />
{connectionResult.online
? `Connected — ${connectionResult.latencyMs}ms — ${connectionResult.models.length} model(s)`
: 'Unreachable'}
</div>
)}
</div>
{connectionResult?.models && connectionResult.models.length > 0 && (
<div className="space-y-3">
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Default Model (Stories)</label>
<select
value={settings.ollama_model_default}
onChange={e => update('ollama_model_default', e.target.value)}
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
>
<option value="">Auto (largest available)</option>
{connectionResult.models.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Default Model (Translation)</label>
<select
value={settings.ollama_model_translation}
onChange={e => update('ollama_model_translation', e.target.value)}
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
>
<option value="">Same as default</option>
{connectionResult.models.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Default Model (Background Tasks)</label>
<select
value={settings.ollama_model_background}
onChange={e => update('ollama_model_background', e.target.value)}
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
>
<option value="">Same as default</option>
{connectionResult.models.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div>
<p className="text-[#aaa] text-xs font-medium">Auto-failover to Cloud AI</p>
<p className="text-[#444] text-[10px]">Automatically use Anthropic when local is unavailable</p>
</div>
<button
onClick={() => update('auto_failover', !settings.auto_failover)}
className={`w-10 h-5 rounded-full transition-colors relative ${settings.auto_failover ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
>
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.auto_failover ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
</div>
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Failover Timeout (seconds)</label>
<input
type="number"
value={settings.failover_timeout_seconds}
onChange={e => update('failover_timeout_seconds', Number(e.target.value))}
min={3} max={30}
className="w-32 bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#3b82f6]"
/>
</div>
</div>
</section>
{/* Cloud AI */}
<section className="bg-[#111827] border border-[#1e2a3a] rounded-xl p-6">
<h2 className="text-white font-bold text-base mb-1">Cloud AI (Anthropic Failover)</h2>
<p className="text-[#555] text-xs mb-5">Used when local Ollama is unavailable.</p>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-[#1e2a3a]">
<span className="text-[#aaa] text-sm">Model</span>
<span className="text-white text-sm font-mono">claude-sonnet-4-20250514</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-[#1e2a3a]">
<span className="text-[#aaa] text-sm">Fallback Model</span>
<span className="text-[#555] text-sm font-mono">claude-3-5-sonnet-20241022</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-[#aaa] text-sm">API Key Status</span>
<span className={`text-sm font-medium ${process.env.NEXT_PUBLIC_SITE_URL ? 'text-green-400' : 'text-red-400'}`}>
{/* Key status shown via ping */}
<span className="text-green-400">Configured</span>
</span>
</div>
</div>
</section>
{/* Console Preferences */}
<section className="bg-[#111827] border border-[#1e2a3a] rounded-xl p-6">
<h2 className="text-white font-bold text-base mb-5">AI Console Preferences</h2>
<div className="space-y-4">
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Default Site</label>
<select
value={settings.default_site_id}
onChange={e => update('default_site_id', Number(e.target.value))}
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
>
<option value={1}>Broadcast Beat</option>
<option value={2}>AV Beat</option>
</select>
</div>
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Default Pen Name (Broadcast Beat)</label>
<select
value={settings.default_pen_name_bb}
onChange={e => update('default_pen_name_bb', e.target.value)}
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
>
{BB_PEN_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Default Pen Name (AV Beat)</label>
<select
value={settings.default_pen_name_av}
onChange={e => update('default_pen_name_av', e.target.value)}
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
>
{AV_PEN_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-[#aaa] text-xs font-medium">Save Session History</p>
<p className="text-[#444] text-[10px]">Store last 20 sessions</p>
</div>
<button
onClick={() => update('save_session_history', !settings.save_session_history)}
className={`w-10 h-5 rounded-full transition-colors relative ${settings.save_session_history ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
>
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.save_session_history ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
</div>
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">History Retention</label>
<select
value={settings.history_retention_days}
onChange={e => update('history_retention_days', Number(e.target.value))}
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-[#aaa] text-xs font-medium">Streaming Responses</p>
<p className="text-[#444] text-[10px]">Text appears as it generates</p>
</div>
<button
onClick={() => update('streaming_enabled', !settings.streaming_enabled)}
className={`w-10 h-5 rounded-full transition-colors relative ${settings.streaming_enabled ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
>
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.streaming_enabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
</div>
</div>
</section>
{/* Background Tasks */}
<section className="bg-[#111827] border border-[#1e2a3a] rounded-xl p-6">
<h2 className="text-white font-bold text-base mb-5">Background AI Tasks</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-[#aaa] text-xs font-medium">Route Background Tasks to Local AI</p>
<p className="text-[#444] text-[10px]">Story generation, translation, classification</p>
</div>
<button
onClick={() => update('background_use_local', !settings.background_use_local)}
className={`w-10 h-5 rounded-full transition-colors relative ${settings.background_use_local ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
>
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.background_use_local ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
</div>
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Background Task Timeout (seconds)</label>
<input
type="number"
value={settings.background_timeout_seconds}
onChange={e => update('background_timeout_seconds', Number(e.target.value))}
min={30} max={300}
className="w-32 bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#3b82f6]"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-[#aaa] text-xs font-medium">Priority Queue</p>
<p className="text-[#444] text-[10px]">Interactive requests take priority over background tasks</p>
</div>
<button
onClick={() => update('priority_queue_enabled', !settings.priority_queue_enabled)}
className={`w-10 h-5 rounded-full transition-colors relative ${settings.priority_queue_enabled ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
>
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.priority_queue_enabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
</div>
<div className="bg-[#0d1117] border border-[#1e2a3a] rounded-lg p-4">
<p className="text-[#555] text-xs font-semibold mb-2 uppercase tracking-wide">Priority Queue Order</p>
<ol className="space-y-1">
{[
'Interactive AI Console requests (admin typing)',
'Breaking news story generation',
'Show coverage auto-stories during show week',
'Standard background auto-stories',
'Translation backfill',
'Classification and extraction tasks',
].map((item, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-[#555]">
<span className="w-4 h-4 rounded-full bg-[#1e2a3a] text-[#3b82f6] text-[10px] flex items-center justify-center font-bold flex-shrink-0">{i + 1}</span>
{item}
</li>
))}
</ol>
</div>
</div>
</section>
</div>
{toast && (
<div className={`fixed bottom-6 right-6 px-4 py-3 rounded-lg text-sm font-medium shadow-lg z-50 ${
toast.type === 'error' ? 'bg-red-900 text-red-200 border border-red-700' : 'bg-green-900 text-green-200 border border-green-700'
}`}>
{toast.message}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,415 @@
'use client';
import { useState, useEffect } from 'react';
interface Props {
companies: any[];
billingMode: 'mooninvoice' | 'native';
onModeChange: (mode: 'mooninvoice' | 'native') => void;
}
export default function BillingSettingsTab({ companies, billingMode, onModeChange }: Props) {
const [settings, setSettings] = useState<any>(null);
const [companyData, setCompanyData] = useState<Record<string, any>>({});
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string } | null>>({});
const [testLoading, setTestLoading] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState(false);
const [activeSection, setActiveSection] = useState<'mooninvoice' | 'stripe' | 'defaults' | 'migration'>('mooninvoice');
const [migrationStatus, setMigrationStatus] = useState<any>(null);
const [migrationRunning, setMigrationRunning] = useState(false);
useEffect(() => {
fetch('/api/billing/settings')
.then(r => r.json())
.then(d => {
setSettings(d.settings);
const cd: Record<string, any> = {};
(d.companies || []).forEach((c: any) => { cd[c.id] = c; });
setCompanyData(cd);
if (d.settings?.migration_import_status) {
setMigrationStatus(d.settings.migration_import_status);
}
});
}, []);
const handleTestConnection = async (companyId: string, llc: string) => {
setTestLoading(p => ({ ...p, [companyId]: true }));
const res = await fetch('/api/billing/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'test_connection', company: llc.toLowerCase() }),
});
const data = await res.json();
setTestResults(p => ({ ...p, [companyId]: data }));
setTestLoading(p => ({ ...p, [companyId]: false }));
};
const handleSaveSettings = async () => {
setSaving(true);
await fetch('/api/billing/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings }),
});
setSaving(false);
};
const handleSaveCompany = async (companyId: string) => {
setSaving(true);
await fetch('/api/billing/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_company', companyId, company: companyData[companyId] }),
});
setSaving(false);
};
const handleRunMigration = async () => {
setMigrationRunning(true);
try {
const res = await fetch('/api/billing/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json();
setMigrationStatus(data.results);
} finally {
setMigrationRunning(false);
}
};
const handleSwitchToNative = async () => {
if (!confirm('Switch to native billing mode? MoonInvoice will no longer be the active data source.')) return;
await fetch('/api/billing/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { billing_mode: 'native' } }),
});
onModeChange('native');
};
const handleRollback = async () => {
if (!confirm('Roll back to MoonInvoice mode?')) return;
await fetch('/api/billing/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { billing_mode: 'mooninvoice' } }),
});
onModeChange('mooninvoice');
};
const sections = [
{ id: 'mooninvoice', label: 'MoonInvoice' },
{ id: 'stripe', label: 'Stripe' },
{ id: 'defaults', label: 'Invoice Defaults' },
{ id: 'migration', label: 'Migration' },
] as const;
return (
<div className="flex gap-6">
{/* Sidebar */}
<div className="w-48 flex-shrink-0">
<nav className="space-y-1">
{sections.map(s => (
<button
key={s.id}
onClick={() => setActiveSection(s.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === s.id
? 'bg-blue-900/50 text-blue-300' :'text-gray-400 hover:text-white hover:bg-gray-800'
}`}
>
{s.label}
</button>
))}
</nav>
</div>
{/* Content */}
<div className="flex-1 space-y-6">
{/* MoonInvoice Settings */}
{activeSection === 'mooninvoice' && (
<div className="space-y-6">
<h2 className="text-base font-semibold text-white">MoonInvoice Settings</h2>
{companies.map(company => (
<div key={company.id} className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-300">{company.name} ({company.llc_entity})</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-400 mb-1">MoonInvoice Company ID</label>
<input
type="text"
value={companyData[company.id]?.mooninvoice_company_id || ''}
onChange={e => setCompanyData(p => ({ ...p, [company.id]: { ...p[company.id], mooninvoice_company_id: e.target.value } }))}
placeholder="Enter company ID..."
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">API Key</label>
<input
type="password"
value={companyData[company.id]?.mooninvoice_api_key || ''}
onChange={e => setCompanyData(p => ({ ...p, [company.id]: { ...p[company.id], mooninvoice_api_key: e.target.value } }))}
placeholder="••••••••••••••••"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
/>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => handleTestConnection(company.id, company.llc_entity)}
disabled={testLoading[company.id]}
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg text-sm transition-colors"
>
{testLoading[company.id] ? 'Testing...' : 'Test Connection'}
</button>
{testResults[company.id] && (
<span className={`text-xs ${testResults[company.id]?.ok ? 'text-green-400' : 'text-red-400'}`}>
{testResults[company.id]?.ok ? '✓' : '✕'} {testResults[company.id]?.message}
</span>
)}
<button
onClick={() => handleSaveCompany(company.id)}
disabled={saving}
className="ml-auto px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
>
Save
</button>
</div>
</div>
))}
{settings && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-300">Sync Settings</h3>
<div className="flex items-center gap-4">
<label className="text-sm text-gray-400">Sync Frequency</label>
<select
value={settings.sync_frequency_minutes}
onChange={e => setSettings((p: any) => ({ ...p, sync_frequency_minutes: Number(e.target.value) }))}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
>
<option value={5}>Every 5 minutes</option>
<option value={15}>Every 15 minutes</option>
<option value={30}>Every 30 minutes</option>
<option value={9999}>Manual only</option>
</select>
</div>
<div className="flex items-center gap-3">
<button
onClick={async () => {
await fetch('/api/billing/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
}}
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg text-sm transition-colors"
>
Sync Now
</button>
<button onClick={handleSaveSettings} disabled={saving} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors">
Save
</button>
</div>
</div>
)}
</div>
)}
{/* Stripe Settings */}
{activeSection === 'stripe' && (
<div className="space-y-6">
<h2 className="text-base font-semibold text-white">Stripe Settings</h2>
<div className="bg-yellow-900/20 border border-yellow-700 rounded-xl p-4 text-sm text-yellow-300">
Stripe keys will be added later. Add <code className="bg-yellow-900/40 px-1 rounded">STRIPE_SECRET_KEY_RMP</code> and <code className="bg-yellow-900/40 px-1 rounded">STRIPE_SECRET_KEY_JTR</code> to your environment variables to enable payment collection.
</div>
{companies.map(company => (
<div key={company.id} className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-300">{company.name} Stripe Account</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-400 mb-1">Stripe Account ID</label>
<input
type="text"
value={companyData[company.id]?.stripe_account_id || ''}
onChange={e => setCompanyData(p => ({ ...p, [company.id]: { ...p[company.id], stripe_account_id: e.target.value } }))}
placeholder="acct_..."
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Webhook Endpoint</label>
<input
type="text"
readOnly
value={`${process.env.NEXT_PUBLIC_SITE_URL || ''}/api/webhooks/stripe/${company.llc_entity?.toLowerCase()}`}
className="w-full bg-gray-800/50 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-500 cursor-default"
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="px-2 py-1 bg-yellow-900/50 text-yellow-300 rounded text-xs">Not Connected Add Stripe keys to env vars</span>
<button onClick={() => handleSaveCompany(company.id)} disabled={saving} className="ml-auto px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors">
Save
</button>
</div>
</div>
))}
{settings && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
<label className="flex items-center gap-3 cursor-pointer">
<div
onClick={() => setSettings((p: any) => ({ ...p, stripe_test_mode: !p.stripe_test_mode }))}
className={`w-10 h-5 rounded-full transition-colors relative cursor-pointer ${settings.stripe_test_mode ? 'bg-yellow-600' : 'bg-green-600'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-transform ${settings.stripe_test_mode ? 'left-0.5' : 'left-5'}`} />
</div>
<span className="text-sm text-gray-300">Test Mode {settings.stripe_test_mode ? '(enabled)' : '(disabled — live)'}</span>
</label>
<button onClick={handleSaveSettings} disabled={saving} className="mt-3 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors">
Save
</button>
</div>
)}
</div>
)}
{/* Invoice Defaults */}
{activeSection === 'defaults' && settings && (
<div className="space-y-6">
<h2 className="text-base font-semibold text-white">Invoice Defaults</h2>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-400 mb-1">Default Payment Terms</label>
<select
value={settings.default_payment_terms}
onChange={e => setSettings((p: any) => ({ ...p, default_payment_terms: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
>
{['Net 15', 'Net 30', 'Net 45', 'Due on receipt'].map(t => <option key={t}>{t}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Default Currency</label>
<select
value={settings.default_currency}
onChange={e => setSettings((p: any) => ({ ...p, default_currency: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
>
<option>USD</option>
</select>
</div>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.late_payment_reminder_enabled}
onChange={e => setSettings((p: any) => ({ ...p, late_payment_reminder_enabled: e.target.checked }))}
className="rounded"
/>
<span className="text-sm text-gray-300">Late payment reminder</span>
{settings.late_payment_reminder_enabled && (
<input
type="number"
value={settings.late_payment_reminder_days}
onChange={e => setSettings((p: any) => ({ ...p, late_payment_reminder_days: Number(e.target.value) }))}
className="w-16 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
/>
)}
{settings.late_payment_reminder_enabled && <span className="text-sm text-gray-400">days after due</span>}
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.auto_send_reminders}
onChange={e => setSettings((p: any) => ({ ...p, auto_send_reminders: e.target.checked }))}
className="rounded"
/>
<span className="text-sm text-gray-300">Auto-send reminders</span>
</label>
</div>
<button onClick={handleSaveSettings} disabled={saving} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
{saving ? 'Saving...' : 'Save Defaults'}
</button>
</div>
</div>
)}
{/* Migration Tool */}
{activeSection === 'migration' && (
<div className="space-y-6">
<h2 className="text-base font-semibold text-white">Migration Tool</h2>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-200">Current Mode</p>
<p className={`text-xs mt-0.5 ${billingMode === 'mooninvoice' ? 'text-blue-400' : 'text-green-400'}`}>
{billingMode === 'mooninvoice' ? 'MoonInvoice (live data source)' : 'Native (self-hosted)'}
</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${billingMode === 'mooninvoice' ? 'bg-blue-900/50 text-blue-300' : 'bg-green-900/50 text-green-300'}`}>
{billingMode === 'mooninvoice' ? 'MoonInvoice Mode' : 'Native Mode'}
</span>
</div>
{migrationStatus && (
<div className="bg-gray-800 rounded-lg p-4 space-y-2">
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Last Import Results</p>
{Object.entries(migrationStatus).map(([company, result]: [string, any]) => (
<div key={company} className="flex items-center justify-between text-sm">
<span className="text-gray-400 uppercase">{company}</span>
{result.skipped ? (
<span className="text-gray-500 text-xs">Skipped API key not configured</span>
) : result.ok ? (
<span className="text-green-400 text-xs"> {result.recordsSynced} records synced</span>
) : (
<span className="text-red-400 text-xs"> {result.error}</span>
)}
</div>
))}
</div>
)}
<div className="border-t border-gray-800 pt-4 space-y-3">
<p className="text-xs text-gray-400">
The migration tool imports all historical MoonInvoice data into the native database.
Once imported and verified, you can switch to native mode no code changes, instant rollback available.
</p>
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={handleRunMigration}
disabled={migrationRunning}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg text-sm font-medium transition-colors"
>
{migrationRunning ? 'Running Import...' : 'Run Data Import'}
</button>
{billingMode === 'mooninvoice' ? (
<button
onClick={handleSwitchToNative}
disabled={!migrationStatus}
className="px-4 py-2 bg-green-700 hover:bg-green-600 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
>
Switch to Native Mode
</button>
) : (
<button
onClick={handleRollback}
className="px-4 py-2 bg-orange-700 hover:bg-orange-600 text-white rounded-lg text-sm font-medium transition-colors"
>
Rollback to MoonInvoice
</button>
)}
</div>
{billingMode === 'mooninvoice' && !migrationStatus && (
<p className="text-xs text-gray-500">Run Data Import first before switching to native mode.</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
interface Props {
companyId?: string;
selectedCompany: 'rmp' | 'jtr';
companies: any[];
}
function fmt(n: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
}
export default function ClientsTab({ companyId, selectedCompany, companies }: Props) {
const [clients, setClients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [selectedClient, setSelectedClient] = useState<any>(null);
const [form, setForm] = useState({
name: '', organization: '', email: '', phone: '', billing_address: '', notes: '',
invoice_consolidation: false,
});
const loadClients = useCallback(() => {
setLoading(true);
const params = companyId ? `?companyId=${companyId}` : '';
fetch(`/api/billing/clients${params}`)
.then(r => r.json())
.then(d => setClients(d.data || []))
.finally(() => setLoading(false));
}, [companyId]);
useEffect(() => { loadClients(); }, [loadClients]);
const filtered = clients.filter(c =>
!search ||
c.name?.toLowerCase().includes(search.toLowerCase()) ||
c.organization?.toLowerCase().includes(search.toLowerCase()) ||
c.email?.toLowerCase().includes(search.toLowerCase())
);
const handleSave = async () => {
await fetch('/api/billing/clients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client: {
...form,
company_id: companyId,
...(selectedClient ? { id: selectedClient.id, mooninvoice_contact_id: selectedClient.mooninvoice_contact_id } : {}),
},
moonCompany: selectedCompany,
}),
});
setShowCreate(false);
setSelectedClient(null);
setForm({ name: '', organization: '', email: '', phone: '', billing_address: '', notes: '', invoice_consolidation: false });
loadClients();
};
const openEdit = (client: any) => {
setSelectedClient(client);
setForm({
name: client.name || '',
organization: client.organization || '',
email: client.email || '',
phone: client.phone || '',
billing_address: client.billing_address || '',
notes: client.notes || '',
invoice_consolidation: client.invoice_consolidation || false,
});
setShowCreate(true);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search clients..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 w-64 focus:outline-none focus:border-blue-500"
/>
<div className="ml-auto">
<button
onClick={() => { setSelectedClient(null); setForm({ name: '', organization: '', email: '', phone: '', billing_address: '', notes: '', invoice_consolidation: false }); setShowCreate(true); }}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
+ Add Client
</button>
</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800 text-gray-400 text-xs uppercase tracking-wide">
<th className="text-left px-4 py-3">Client</th>
<th className="text-left px-4 py-3">Email</th>
<th className="text-left px-4 py-3">Phone</th>
<th className="text-left px-4 py-3">Total Billed</th>
<th className="text-left px-4 py-3">Total Paid</th>
<th className="text-left px-4 py-3">Outstanding</th>
<th className="text-left px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-center py-12 text-gray-500">Loading...</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-gray-500">No clients found.</td></tr>
) : filtered.map(client => (
<tr key={client.id} className="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
<td className="px-4 py-3">
<div className="font-medium text-gray-200">{client.name}</div>
{client.organization && <div className="text-xs text-gray-500">{client.organization}</div>}
</td>
<td className="px-4 py-3 text-gray-400">{client.email || '—'}</td>
<td className="px-4 py-3 text-gray-400">{client.phone || '—'}</td>
<td className="px-4 py-3 text-gray-200">{fmt(client.total_billed || 0)}</td>
<td className="px-4 py-3 text-green-400">{fmt(client.total_paid || 0)}</td>
<td className="px-4 py-3 text-yellow-400">{fmt((client.total_billed || 0) - (client.total_paid || 0))}</td>
<td className="px-4 py-3">
<button onClick={() => openEdit(client)} className="px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600 transition-colors">
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Create/Edit Modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg">
<div className="flex items-center justify-between p-5 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white">{selectedClient ? 'Edit Client' : 'Add Client'}</h2>
<button onClick={() => setShowCreate(false)} className="text-gray-400 hover:text-white"></button>
</div>
<div className="p-5 space-y-3">
{[
{ label: 'Name *', key: 'name', type: 'text' },
{ label: 'Organization', key: 'organization', type: 'text' },
{ label: 'Email', key: 'email', type: 'email' },
{ label: 'Phone', key: 'phone', type: 'text' },
{ label: 'Billing Address', key: 'billing_address', type: 'text' },
].map(f => (
<div key={f.key}>
<label className="block text-xs text-gray-400 mb-1">{f.label}</label>
<input
type={f.type}
value={(form as any)[f.key]}
onChange={e => setForm(p => ({ ...p, [f.key]: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
/>
</div>
))}
<div>
<label className="block text-xs text-gray-400 mb-1">Internal Notes</label>
<textarea
value={form.notes}
onChange={e => setForm(p => ({ ...p, notes: e.target.value }))}
rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.invoice_consolidation}
onChange={e => setForm(p => ({ ...p, invoice_consolidation: e.target.checked }))}
className="rounded"
/>
<span className="text-sm text-gray-300">Consolidate multiple campaigns into one monthly invoice</span>
</label>
</div>
<div className="flex justify-end gap-3 p-5 border-t border-gray-800">
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
<button onClick={handleSave} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
{selectedClient ? 'Save Changes' : 'Add Client'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,331 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
interface Props {
companyId?: string;
selectedCompany: 'rmp' | 'jtr';
companies: any[];
}
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-700 text-gray-300',
sent: 'bg-blue-900 text-blue-300',
viewed: 'bg-purple-900 text-purple-300',
paid: 'bg-green-900 text-green-300',
overdue: 'bg-red-900 text-red-300',
cancelled: 'bg-gray-800 text-gray-400',
};
function fmt(n: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
}
export default function InvoicesTab({ companyId, selectedCompany, companies }: Props) {
const [invoices, setInvoices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('all');
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [clients, setClients] = useState<any[]>([]);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [newInvoice, setNewInvoice] = useState({
client_id: '',
issue_date: new Date().toISOString().split('T')[0],
due_date: '',
notes: '',
internal_memo: '',
payment_terms: 'Net 30',
});
const [lineItems, setLineItems] = useState([{ description: '', quantity: 1, rate: 0, amount: 0 }]);
const loadInvoices = useCallback(() => {
setLoading(true);
const params = new URLSearchParams();
if (companyId) params.set('companyId', companyId);
if (statusFilter !== 'all') params.set('status', statusFilter);
if (search) params.set('search', search);
fetch(`/api/billing/invoices?${params}`)
.then(r => r.json())
.then(d => setInvoices(d.data || []))
.finally(() => setLoading(false));
}, [companyId, statusFilter, search]);
useEffect(() => { loadInvoices(); }, [loadInvoices]);
useEffect(() => {
if (companyId) {
fetch(`/api/billing/clients?companyId=${companyId}`)
.then(r => r.json())
.then(d => setClients(d.data || []));
}
}, [companyId]);
const handleAction = async (action: string, inv: any) => {
setActionLoading(`${action}-${inv.id}`);
try {
await fetch('/api/billing/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, invoiceId: inv.id, company: selectedCompany }),
});
loadInvoices();
} finally {
setActionLoading(null);
}
};
const handleDownloadPdf = async (inv: any) => {
window.open(`/api/billing/invoices/pdf?id=${inv.id}&company=${selectedCompany}`, '_blank');
};
const calcLineItem = (idx: number, field: string, value: any) => {
const updated = [...lineItems];
updated[idx] = { ...updated[idx], [field]: value };
if (field === 'quantity' || field === 'rate') {
updated[idx].amount = Number(updated[idx].quantity) * Number(updated[idx].rate);
}
setLineItems(updated);
};
const total = lineItems.reduce((s, li) => s + Number(li.amount), 0);
const handleCreateInvoice = async () => {
const company = companies.find(c => c.llc_entity === selectedCompany.toUpperCase());
const invoiceNumber = `${company?.invoice_prefix || 'INV-'}${Date.now().toString().slice(-6)}`;
await fetch('/api/billing/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invoice: {
...newInvoice,
company_id: companyId,
invoice_number: invoiceNumber,
status: 'draft',
subtotal: total,
total,
currency: 'USD',
moonCompany: selectedCompany,
},
lineItems,
}),
});
setShowCreate(false);
loadInvoices();
};
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search invoice # or client..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 w-64 focus:outline-none focus:border-blue-500"
/>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
>
{['all', 'draft', 'sent', 'viewed', 'paid', 'overdue', 'cancelled'].map(s => (
<option key={s} value={s}>{s === 'all' ? 'All Statuses' : s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
<div className="ml-auto">
<button
onClick={() => setShowCreate(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
+ Create Invoice
</button>
</div>
</div>
{/* Table */}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800 text-gray-400 text-xs uppercase tracking-wide">
<th className="text-left px-4 py-3">Invoice #</th>
<th className="text-left px-4 py-3">Client</th>
<th className="text-left px-4 py-3">Amount</th>
<th className="text-left px-4 py-3">Issue Date</th>
<th className="text-left px-4 py-3">Due Date</th>
<th className="text-left px-4 py-3">Status</th>
<th className="text-left px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-center py-12 text-gray-500">Loading...</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-gray-500">No invoices found. Sync from MoonInvoice or create one.</td></tr>
) : invoices.map(inv => (
<tr key={inv.id} className="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
<td className="px-4 py-3 font-mono text-blue-400">{inv.invoice_number}</td>
<td className="px-4 py-3 text-gray-200">{inv.billing_clients?.name || '—'}</td>
<td className="px-4 py-3 text-gray-200 font-medium">{fmt(inv.total || 0)}</td>
<td className="px-4 py-3 text-gray-400">{inv.issue_date || '—'}</td>
<td className="px-4 py-3 text-gray-400">{inv.due_date || '—'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[inv.status] || 'bg-gray-700 text-gray-300'}`}>
{inv.status}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 flex-wrap">
{inv.status === 'draft' && (
<button
onClick={() => handleAction('send', inv)}
disabled={!!actionLoading}
className="px-2 py-1 bg-blue-900/50 text-blue-300 rounded text-xs hover:bg-blue-900 transition-colors"
>
Send
</button>
)}
{['sent', 'viewed', 'overdue'].includes(inv.status) && (
<button
onClick={() => handleAction('record_payment', inv)}
disabled={!!actionLoading}
className="px-2 py-1 bg-green-900/50 text-green-300 rounded text-xs hover:bg-green-900 transition-colors"
>
Record Payment
</button>
)}
<button
onClick={() => handleDownloadPdf(inv)}
className="px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600 transition-colors"
>
PDF
</button>
{inv.status !== 'cancelled' && (
<button
onClick={() => handleAction('cancel', inv)}
disabled={!!actionLoading}
className="px-2 py-1 bg-red-900/30 text-red-400 rounded text-xs hover:bg-red-900/60 transition-colors"
>
Cancel
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Create Invoice Modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-5 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white">Create Invoice</h2>
<button onClick={() => setShowCreate(false)} className="text-gray-400 hover:text-white"></button>
</div>
<div className="p-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-400 mb-1">Client</label>
<select
value={newInvoice.client_id}
onChange={e => setNewInvoice(p => ({ ...p, client_id: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
>
<option value="">Select client...</option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Payment Terms</label>
<select
value={newInvoice.payment_terms}
onChange={e => setNewInvoice(p => ({ ...p, payment_terms: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
>
{['Net 15', 'Net 30', 'Net 45', 'Due on receipt'].map(t => <option key={t}>{t}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Issue Date</label>
<input type="date" value={newInvoice.issue_date}
onChange={e => setNewInvoice(p => ({ ...p, issue_date: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Due Date</label>
<input type="date" value={newInvoice.due_date}
onChange={e => setNewInvoice(p => ({ ...p, due_date: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500" />
</div>
</div>
{/* Line Items */}
<div>
<label className="block text-xs text-gray-400 mb-2">Line Items</label>
<div className="space-y-2">
{lineItems.map((li, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-center">
<input
placeholder="Description"
value={li.description}
onChange={e => calcLineItem(idx, 'description', e.target.value)}
className="col-span-5 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
/>
<input
type="number" placeholder="Qty" value={li.quantity}
onChange={e => calcLineItem(idx, 'quantity', e.target.value)}
className="col-span-2 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
/>
<input
type="number" placeholder="Rate" value={li.rate}
onChange={e => calcLineItem(idx, 'rate', e.target.value)}
className="col-span-2 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-blue-500"
/>
<span className="col-span-2 text-sm text-gray-300 text-right">{fmt(li.amount)}</span>
<button onClick={() => setLineItems(items => items.filter((_, i) => i !== idx))}
className="col-span-1 text-red-400 hover:text-red-300 text-lg leading-none">×</button>
</div>
))}
</div>
<button
onClick={() => setLineItems(items => [...items, { description: '', quantity: 1, rate: 0, amount: 0 }])}
className="mt-2 text-xs text-blue-400 hover:text-blue-300"
>
+ Add line item
</button>
<div className="text-right mt-2 text-sm font-semibold text-white">Total: {fmt(total)}</div>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Notes (visible to client)</label>
<textarea value={newInvoice.notes}
onChange={e => setNewInvoice(p => ({ ...p, notes: e.target.value }))}
rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Internal Memo (not sent to client)</label>
<textarea value={newInvoice.internal_memo}
onChange={e => setNewInvoice(p => ({ ...p, internal_memo: e.target.value }))}
rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500" />
</div>
</div>
<div className="flex justify-end gap-3 p-5 border-t border-gray-800">
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
<button onClick={handleCreateInvoice} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
Create Invoice
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
import { useState, useEffect } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
interface Props {
companyId?: string;
selectedCompany: 'rmp' | 'jtr';
}
interface Stats {
revenueThisMonth: number;
revenueLastMonth: number;
revenueChange: number;
outstandingCount: number;
outstandingAmount: number;
overdueCount: number;
overdueAmount: number;
paidThisMonthAmount: number;
upcomingCount: number;
upcomingAmount: number;
}
function fmt(n: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
}
export default function OverviewTab({ companyId, selectedCompany }: Props) {
const [stats, setStats] = useState<Stats | null>(null);
const [monthlyRevenue, setMonthlyRevenue] = useState<{ month: string; revenue: number }[]>([]);
const [recentInvoices, setRecentInvoices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const params = companyId ? `?companyId=${companyId}` : '';
Promise.all([
fetch(`/api/billing/stats${params}`).then(r => r.json()),
fetch(`/api/billing/invoices${params}&limit=10`).then(r => r.json()),
]).then(([statsData, invData]) => {
setStats(statsData.stats);
setMonthlyRevenue(statsData.monthlyRevenue || []);
setRecentInvoices((invData.data || []).slice(0, 10));
}).finally(() => setLoading(false));
}, [companyId]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
const cards = [
{
label: 'Revenue This Month',
value: fmt(stats?.revenueThisMonth || 0),
sub: stats?.revenueChange !== undefined
? `${stats.revenueChange >= 0 ? '+' : ''}${stats.revenueChange.toFixed(1)}% vs last month`
: '',
color: 'text-green-400',
bg: 'bg-green-900/20 border-green-800',
},
{
label: 'Outstanding Invoices',
value: fmt(stats?.outstandingAmount || 0),
sub: `${stats?.outstandingCount || 0} invoices`,
color: 'text-blue-400',
bg: 'bg-blue-900/20 border-blue-800',
},
{
label: 'Overdue',
value: fmt(stats?.overdueAmount || 0),
sub: `${stats?.overdueCount || 0} invoices`,
color: 'text-red-400',
bg: 'bg-red-900/20 border-red-800',
},
{
label: 'Paid This Month',
value: fmt(stats?.paidThisMonthAmount || 0),
sub: '',
color: 'text-emerald-400',
bg: 'bg-emerald-900/20 border-emerald-800',
},
{
label: 'Due in 7 Days',
value: fmt(stats?.upcomingAmount || 0),
sub: `${stats?.upcomingCount || 0} invoices`,
color: 'text-yellow-400',
bg: 'bg-yellow-900/20 border-yellow-800',
},
];
const statusColors: Record<string, string> = {
draft: 'bg-gray-700 text-gray-300',
sent: 'bg-blue-900 text-blue-300',
viewed: 'bg-purple-900 text-purple-300',
paid: 'bg-green-900 text-green-300',
overdue: 'bg-red-900 text-red-300',
cancelled: 'bg-gray-800 text-gray-400',
};
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
{cards.map(card => (
<div key={card.label} className={`rounded-xl border p-4 ${card.bg}`}>
<p className="text-xs text-gray-400 mb-1">{card.label}</p>
<p className={`text-xl font-bold ${card.color}`}>{card.value}</p>
{card.sub && <p className="text-xs text-gray-500 mt-1">{card.sub}</p>}
</div>
))}
</div>
{/* Revenue Chart */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 className="text-sm font-semibold text-gray-300 mb-4">Revenue Last 12 Months</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={monthlyRevenue}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="month" tick={{ fill: '#9ca3af', fontSize: 11 }} />
<YAxis tick={{ fill: '#9ca3af', fontSize: 11 }} tickFormatter={v => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip
contentStyle={{ background: '#1f2937', border: '1px solid #374151', borderRadius: 8 }}
labelStyle={{ color: '#e5e7eb' }}
formatter={(v: any) => [fmt(v), 'Revenue']}
/>
<Line
type="monotone"
dataKey="revenue"
stroke={selectedCompany === 'rmp' ? '#3b82f6' : '#a855f7'}
strokeWidth={2}
dot={{ fill: selectedCompany === 'rmp' ? '#3b82f6' : '#a855f7', r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Recent Activity */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 className="text-sm font-semibold text-gray-300 mb-4">Recent Invoice Activity</h3>
{recentInvoices.length === 0 ? (
<p className="text-gray-500 text-sm text-center py-8">No recent activity. Sync from MoonInvoice or create your first invoice.</p>
) : (
<div className="space-y-2">
{recentInvoices.map((inv: any) => (
<div key={inv.id} className="flex items-center justify-between py-2 border-b border-gray-800 last:border-0">
<div className="flex items-center gap-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[inv.status] || 'bg-gray-700 text-gray-300'}`}>
{inv.status}
</span>
<div>
<span className="text-sm text-gray-200">{inv.invoice_number}</span>
<span className="text-gray-500 mx-2">·</span>
<span className="text-sm text-gray-400">{inv.billing_clients?.name || '—'}</span>
</div>
</div>
<span className="text-sm font-medium text-gray-200">{fmt(inv.total || 0)}</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useState, useEffect } from 'react';
interface Props {
companyId?: string;
selectedCompany: 'rmp' | 'jtr';
}
function fmt(n: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
}
const STATUS_COLORS: Record<string, string> = {
succeeded: 'bg-green-900 text-green-300',
failed: 'bg-red-900 text-red-300',
pending: 'bg-yellow-900 text-yellow-300',
refunded: 'bg-gray-700 text-gray-300',
disputed: 'bg-orange-900 text-orange-300',
};
export default function PaymentsTab({ companyId, selectedCompany }: Props) {
const [payments, setPayments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const params = companyId ? `?companyId=${companyId}` : '';
fetch(`/api/billing/payments${params}`)
.then(r => r.json())
.then(d => setPayments(d.data || []))
.finally(() => setLoading(false));
}, [companyId]);
const disputed = payments.filter(p => p.status === 'disputed');
const failed = payments.filter(p => p.status === 'failed');
return (
<div className="space-y-4">
{/* Alerts */}
{disputed.length > 0 && (
<div className="bg-orange-900/30 border border-orange-700 rounded-xl p-4 flex items-start gap-3">
<span className="text-orange-400 text-lg"></span>
<div>
<p className="text-orange-300 font-semibold text-sm">Stripe Dispute Alert</p>
<p className="text-orange-400 text-xs mt-0.5">{disputed.length} active dispute{disputed.length > 1 ? 's' : ''} require your attention.</p>
</div>
</div>
)}
{failed.length > 0 && (
<div className="bg-red-900/20 border border-red-800 rounded-xl p-4 flex items-start gap-3">
<span className="text-red-400 text-lg"></span>
<div>
<p className="text-red-300 font-semibold text-sm">Failed Payments</p>
<p className="text-red-400 text-xs mt-0.5">{failed.length} payment{failed.length > 1 ? 's' : ''} failed recently.</p>
</div>
</div>
)}
{/* Stripe Connection Notice */}
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 flex items-center gap-3">
<div className="w-8 h-8 bg-indigo-900/50 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-indigo-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.591-7.305z"/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-gray-200">
Stripe (Relevant Media Properties)
</p>
<p className="text-xs text-gray-500 mt-0.5">
Connect Stripe account in Settings Stripe Settings to enable payment collection.
</p>
</div>
<span className="ml-auto px-2 py-1 bg-yellow-900/50 text-yellow-300 rounded text-xs">Not Connected</span>
</div>
{/* Payments Table */}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800 text-gray-400 text-xs uppercase tracking-wide">
<th className="text-left px-4 py-3">Date</th>
<th className="text-left px-4 py-3">Client</th>
<th className="text-left px-4 py-3">Invoice #</th>
<th className="text-left px-4 py-3">Amount</th>
<th className="text-left px-4 py-3">Method</th>
<th className="text-left px-4 py-3">Status</th>
<th className="text-left px-4 py-3">Stripe ID</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-center py-12 text-gray-500">Loading...</td></tr>
) : payments.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-gray-500">No payments recorded yet.</td></tr>
) : payments.map(p => (
<tr key={p.id} className={`border-b border-gray-800 hover:bg-gray-800/50 transition-colors ${p.status === 'failed' || p.status === 'disputed' ? 'bg-red-950/20' : ''}`}>
<td className="px-4 py-3 text-gray-400">{p.payment_date || '—'}</td>
<td className="px-4 py-3 text-gray-200">{p.billing_clients?.name || '—'}</td>
<td className="px-4 py-3 font-mono text-blue-400 text-xs">{p.billing_invoices?.invoice_number || '—'}</td>
<td className="px-4 py-3 font-medium text-gray-200">{fmt(p.amount || 0)}</td>
<td className="px-4 py-3 text-gray-400 capitalize">{p.payment_method || '—'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[p.status] || 'bg-gray-700 text-gray-300'}`}>
{p.status}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500 truncate max-w-[120px]">
{p.stripe_payment_intent_id || p.stripe_charge_id || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useState, useEffect } from 'react';
interface Props {
companyId?: string;
selectedCompany: 'rmp' | 'jtr';
companies: any[];
}
function fmt(n: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
}
export default function ReportsTab({ companyId, selectedCompany, companies }: Props) {
const [invoices, setInvoices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [reportType, setReportType] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly');
useEffect(() => {
setLoading(true);
const params = companyId ? `?companyId=${companyId}` : '';
fetch(`/api/billing/invoices${params}`)
.then(r => r.json())
.then(d => setInvoices(d.data || []))
.finally(() => setLoading(false));
}, [companyId]);
// AR Aging
const today = new Date();
const aging = {
current: [] as any[],
'1-30': [] as any[],
'31-60': [] as any[],
'61-90': [] as any[],
'90+': [] as any[],
};
invoices
.filter(inv => !['paid', 'cancelled', 'draft'].includes(inv.status))
.forEach(inv => {
if (!inv.due_date) return;
const due = new Date(inv.due_date);
const days = Math.floor((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24));
if (days <= 0) aging.current.push(inv);
else if (days <= 30) aging['1-30'].push(inv);
else if (days <= 60) aging['31-60'].push(inv);
else if (days <= 90) aging['61-90'].push(inv);
else aging['90+'].push(inv);
});
// Top clients
const clientTotals: Record<string, { name: string; total: number }> = {};
invoices
.filter(inv => inv.status === 'paid')
.forEach(inv => {
const name = inv.billing_clients?.name || 'Unknown';
if (!clientTotals[name]) clientTotals[name] = { name, total: 0 };
clientTotals[name].total += Number(inv.total || 0);
});
const topClients = Object.values(clientTotals).sort((a, b) => b.total - a.total).slice(0, 10);
// Revenue by period
const now = new Date();
const currentYear = now.getFullYear();
const yearlyRevenue = invoices
.filter(inv => inv.status === 'paid' && inv.paid_at?.startsWith(String(currentYear)))
.reduce((s, inv) => s + Number(inv.total || 0), 0);
const totalInvoiced = invoices.reduce((s, inv) => s + Number(inv.total || 0), 0);
const handleExportCSV = () => {
const rows = [
['Invoice #', 'Client', 'Amount', 'Status', 'Issue Date', 'Due Date', 'Paid At'],
...invoices.map(inv => [
inv.invoice_number,
inv.billing_clients?.name || '',
inv.total,
inv.status,
inv.issue_date || '',
inv.due_date || '',
inv.paid_at || '',
]),
];
const csv = rows.map(r => r.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `billing-report-${selectedCompany}-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex gap-2">
{(['monthly', 'quarterly', 'yearly'] as const).map(t => (
<button
key={t}
onClick={() => setReportType(t)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
reportType === t ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<button
onClick={handleExportCSV}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export CSV
</button>
</div>
{/* Summary */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-400 mb-1">Total Invoiced ({currentYear})</p>
<p className="text-2xl font-bold text-white">{fmt(yearlyRevenue)}</p>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-400 mb-1">Total Invoiced (All Time)</p>
<p className="text-2xl font-bold text-white">{fmt(totalInvoiced)}</p>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-400 mb-1">Tax Summary ({currentYear})</p>
<p className="text-2xl font-bold text-white">{fmt(yearlyRevenue)}</p>
<p className="text-xs text-gray-500 mt-1">Total invoiced for accounting export</p>
</div>
</div>
{/* AR Aging */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 className="text-sm font-semibold text-gray-300 mb-4">Accounts Receivable Aging</h3>
<div className="space-y-3">
{[
{ label: 'Current (not yet due)', key: 'current', color: 'text-green-400' },
{ label: '130 days overdue', key: '1-30', color: 'text-yellow-400' },
{ label: '3160 days overdue', key: '31-60', color: 'text-orange-400' },
{ label: '6190 days overdue', key: '61-90', color: 'text-red-400' },
{ label: '90+ days overdue (bad debt risk)', key: '90+', color: 'text-red-600' },
].map(bucket => {
const items = aging[bucket.key as keyof typeof aging];
const total = items.reduce((s, inv) => s + Number(inv.total || 0) - Number(inv.amount_paid || 0), 0);
return (
<div key={bucket.key} className="flex items-center justify-between py-2 border-b border-gray-800 last:border-0">
<div>
<span className="text-sm text-gray-300">{bucket.label}</span>
<span className="text-xs text-gray-500 ml-2">({items.length} invoice{items.length !== 1 ? 's' : ''})</span>
</div>
<span className={`text-sm font-semibold ${bucket.color}`}>{fmt(total)}</span>
</div>
);
})}
</div>
</div>
{/* Top Clients */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 className="text-sm font-semibold text-gray-300 mb-4">Top Clients by Revenue</h3>
{topClients.length === 0 ? (
<p className="text-gray-500 text-sm">No paid invoices yet.</p>
) : (
<div className="space-y-2">
{topClients.map((c, i) => (
<div key={c.name} className="flex items-center gap-3">
<span className="text-xs text-gray-500 w-5">{i + 1}</span>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-200">{c.name}</span>
<span className="text-sm font-medium text-gray-200">{fmt(c.total)}</span>
</div>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${Math.min(100, (c.total / (topClients[0]?.total || 1)) * 100)}%` }}
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import OverviewTab from './components/OverviewTab';
import InvoicesTab from './components/InvoicesTab';
import ClientsTab from './components/ClientsTab';
import PaymentsTab from './components/PaymentsTab';
import ReportsTab from './components/ReportsTab';
import BillingSettingsTab from './components/BillingSettingsTab';
type Tab = 'overview' | 'invoices' | 'clients' | 'payments' | 'reports' | 'settings';
type Company = { id: string; name: string; llc_entity: string; invoice_prefix: string };
export default function BillingDashboard() {
const { user, loading } = useAuth();
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [selectedCompany, setSelectedCompany] = useState<'rmp' | 'jtr'>('rmp');
const [companies, setCompanies] = useState<Company[]>([]);
const [billingMode, setBillingMode] = useState<'mooninvoice' | 'native'>('mooninvoice');
const [isAdmin, setIsAdmin] = useState(false);
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
if (loading) return;
if (!user) { router.replace('/'); return; }
// Check admin role
fetch('/api/billing/settings')
.then(r => {
if (r.status === 404) { router.replace('/'); return null; }
return r.json();
})
.then(data => {
if (!data) return;
setIsAdmin(true);
setCompanies(data.companies || []);
setBillingMode(data.settings?.billing_mode || 'mooninvoice');
setAuthChecked(true);
})
.catch(() => { router.replace('/'); });
}, [user, loading, router]);
const getCompanyId = useCallback(() => {
return companies.find(c => c.llc_entity === selectedCompany.toUpperCase())?.id;
}, [companies, selectedCompany]);
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: 'invoices', label: 'Invoices' },
{ id: 'clients', label: 'Clients' },
{ id: 'payments', label: 'Payments' },
{ id: 'reports', label: 'Reports' },
{ id: 'settings', label: 'Settings' },
];
const rmpCompany = companies.find(c => c.llc_entity === 'RMP');
const jtrCompany = companies.find(c => c.llc_entity === 'JTR');
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-blue-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="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 11h.01M12 11h.01M15 11h.01M4 19h16a2 2 0 002-2V7a2 2 0 00-2-2H4a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h1 className="text-lg font-bold text-white">Unified Billing</h1>
<p className="text-xs text-gray-400">Ryan Salazar Admin Only</p>
</div>
</div>
{/* Company Switcher */}
<div className="flex items-center gap-2 bg-gray-800 rounded-lg p-1">
<button
onClick={() => setSelectedCompany('rmp')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
selectedCompany === 'rmp' ?'bg-blue-600 text-white shadow' :'text-gray-400 hover:text-white'
}`}
>
Relevant Media Properties
</button>
</div>
{/* Data Source Indicator */}
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
billingMode === 'mooninvoice' ?'bg-blue-900/50 text-blue-300 border border-blue-700' :'bg-green-900/50 text-green-300 border border-green-700'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${billingMode === 'mooninvoice' ? 'bg-blue-400' : 'bg-green-400'}`} />
{billingMode === 'mooninvoice' ? 'Data: MoonInvoice (live)' : 'Data: Native (self-hosted)'}
</span>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mt-4 border-b border-gray-800 -mb-px">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-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">
{activeTab === 'overview' && (
<OverviewTab companyId={getCompanyId()} selectedCompany={selectedCompany} />
)}
{activeTab === 'invoices' && (
<InvoicesTab companyId={getCompanyId()} selectedCompany={selectedCompany} companies={companies} />
)}
{activeTab === 'clients' && (
<ClientsTab companyId={getCompanyId()} selectedCompany={selectedCompany} companies={companies} />
)}
{activeTab === 'payments' && (
<PaymentsTab companyId={getCompanyId()} selectedCompany={selectedCompany} />
)}
{activeTab === 'reports' && (
<ReportsTab companyId={getCompanyId()} selectedCompany={selectedCompany} companies={companies} />
)}
{activeTab === 'settings' && (
<BillingSettingsTab
companies={companies}
billingMode={billingMode}
onModeChange={setBillingMode}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,543 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import Link from 'next/link';
type PipelineStatus = 'all' | 'draft' | 'scheduled' | 'published';
interface ArticleWithPerformance {
id: string;
title: string;
slug: string | null;
wp_slug: string | null;
author_name: string | null;
category: string | null;
status: string;
scheduled_at: string | null;
date: string | null;
source: string;
// linked campaign data
campaign_id: string | null;
campaign_name: string | null;
client_name: string | null;
agreed_rate: number | null;
impressions: number | null;
clicks: number | null;
ctr: number | null;
campaign_status: string | null;
campaign_start: string | null;
campaign_end: string | null;
}
interface PipelineSummary {
draft: number;
scheduled: number;
published: number;
totalRevenue: number;
totalImpressions: number;
totalClicks: number;
avgCtr: number;
}
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-yellow-900/40 text-yellow-300 border border-yellow-700/50',
scheduled: 'bg-purple-900/40 text-purple-300 border border-purple-700/50',
published: 'bg-green-900/40 text-green-300 border border-green-700/50',
pending: 'bg-orange-900/40 text-orange-300 border border-orange-700/50',
archived: 'bg-gray-800/60 text-gray-400 border border-gray-700/50',
};
const CAMPAIGN_STATUS_COLORS: Record<string, string> = {
active: 'text-green-400',
pending_approval: 'text-blue-400',
pending_info: 'text-yellow-400',
paused: 'text-gray-400',
ended: 'text-gray-500',
cancelled: 'text-red-400',
};
function fmt(n: number | null | undefined): string {
if (n == null) return '—';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return n.toString();
}
function fmtMoney(n: number | null | undefined): string {
if (n == null) return '—';
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
function fmtDate(d: string | null | undefined): string {
if (!d) return '—';
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
export default function EditorialDashboard() {
const { user, loading } = useAuth();
const router = useRouter();
const [isAdmin, setIsAdmin] = useState(false);
const [authChecked, setAuthChecked] = useState(false);
const [articles, setArticles] = useState<ArticleWithPerformance[]>([]);
const [summary, setSummary] = useState<PipelineSummary | null>(null);
const [loadingData, setLoadingData] = useState(true);
const [statusFilter, setStatusFilter] = useState<PipelineStatus>('all');
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'revenue' | 'impressions' | 'clicks'>('date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
// Auth check
useEffect(() => {
if (loading) return;
if (!user) { router.replace('/'); return; }
const supabase = createClient();
supabase
.from('user_roles')
.select('role')
.eq('user_id', user.id)
.single()
.then(({ data }) => {
if (data?.role === 'admin') {
setIsAdmin(true);
setAuthChecked(true);
} else {
router.replace('/');
}
});
}, [user, loading, router]);
const fetchData = useCallback(async () => {
if (!isAdmin) return;
setLoadingData(true);
const supabase = createClient();
// Fetch articles (both native and imported)
const [nativeRes, importedRes, campaignsRes] = await Promise.all([
supabase
.from('native_articles')
.select('id, title, slug, author_name, category, status, scheduled_at, created_at, source')
.order('created_at', { ascending: false })
.limit(200),
supabase
.from('wp_posts')
.select('id, title, wp_slug, author_name, category, status, date')
.order('date', { ascending: false })
.limit(200),
supabase
.from('ad_campaigns')
.select('id, campaign_name, client_name, agreed_rate, impressions, clicks, ctr, status, start_date, end_date, notes')
.not('status', 'eq', 'cancelled'),
]);
const nativeArticles: ArticleWithPerformance[] = (nativeRes.data || []).map((a: any) => ({
id: `native-${a.id}`,
title: a.title,
slug: a.slug,
wp_slug: null,
author_name: a.author_name,
category: a.category,
status: a.status,
scheduled_at: a.scheduled_at,
date: a.created_at,
source: 'native',
campaign_id: null,
campaign_name: null,
client_name: null,
agreed_rate: null,
impressions: null,
clicks: null,
ctr: null,
campaign_status: null,
campaign_start: null,
campaign_end: null,
}));
const importedArticles: ArticleWithPerformance[] = (importedRes.data || []).map((a: any) => ({
id: `wp-${a.id}`,
title: a.title,
slug: null,
wp_slug: a.wp_slug,
author_name: a.author_name,
category: a.category,
status: a.status,
scheduled_at: null,
date: a.date,
source: 'imported',
campaign_id: null,
campaign_name: null,
client_name: null,
agreed_rate: null,
impressions: null,
clicks: null,
ctr: null,
campaign_status: null,
campaign_start: null,
campaign_end: null,
}));
// Link campaigns to articles via notes field (Campaign ID: xxx) or by matching article slug in notes
const campaigns = campaignsRes.data || [];
const allArticles = [...nativeArticles, ...importedArticles];
// Try to link campaigns by matching article title keywords in campaign notes or campaign_name
const linked = allArticles.map((article) => {
const titleWords = article.title.toLowerCase().split(' ').filter((w: string) => w.length > 4);
const matchedCampaign = campaigns.find((c: any) => {
const haystack = `${c.campaign_name || ''} ${c.notes || ''}`.toLowerCase();
return titleWords.some((w: string) => haystack.includes(w));
});
if (matchedCampaign) {
return {
...article,
campaign_id: matchedCampaign.id,
campaign_name: matchedCampaign.campaign_name,
client_name: matchedCampaign.client_name,
agreed_rate: matchedCampaign.agreed_rate,
impressions: matchedCampaign.impressions,
clicks: matchedCampaign.clicks,
ctr: matchedCampaign.ctr,
campaign_status: matchedCampaign.status,
campaign_start: matchedCampaign.start_date,
campaign_end: matchedCampaign.end_date,
};
}
return article;
});
setArticles(linked);
// Build summary
const draft = linked.filter(a => a.status === 'draft').length;
const scheduled = linked.filter(a => a.status === 'scheduled' || a.scheduled_at).length;
const published = linked.filter(a => a.status === 'published').length;
const totalRevenue = linked.reduce((sum, a) => sum + (a.agreed_rate || 0), 0);
const totalImpressions = linked.reduce((sum, a) => sum + (a.impressions || 0), 0);
const totalClicks = linked.reduce((sum, a) => sum + (a.clicks || 0), 0);
const avgCtr = totalImpressions > 0 ? (totalClicks / totalImpressions) * 100 : 0;
setSummary({ draft, scheduled, published, totalRevenue, totalImpressions, totalClicks, avgCtr });
setLoadingData(false);
}, [isAdmin]);
useEffect(() => {
if (isAdmin) fetchData();
}, [isAdmin, fetchData]);
// Filter + sort
const filtered = articles
.filter(a => {
if (statusFilter === 'draft') return a.status === 'draft';
if (statusFilter === 'scheduled') return a.status === 'scheduled' || !!a.scheduled_at;
if (statusFilter === 'published') return a.status === 'published';
return true;
})
.filter(a => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
a.title.toLowerCase().includes(q) ||
(a.author_name || '').toLowerCase().includes(q) ||
(a.category || '').toLowerCase().includes(q) ||
(a.client_name || '').toLowerCase().includes(q) ||
(a.campaign_name || '').toLowerCase().includes(q)
);
})
.sort((a, b) => {
let av = 0, bv = 0;
if (sortBy === 'date') {
av = new Date(a.date || a.scheduled_at || 0).getTime();
bv = new Date(b.date || b.scheduled_at || 0).getTime();
} else if (sortBy === 'revenue') {
av = a.agreed_rate || 0; bv = b.agreed_rate || 0;
} else if (sortBy === 'impressions') {
av = a.impressions || 0; bv = b.impressions || 0;
} else if (sortBy === 'clicks') {
av = a.clicks || 0; bv = b.clicks || 0;
}
return sortDir === 'desc' ? bv - av : av - bv;
});
function toggleSort(col: typeof sortBy) {
if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc');
else { setSortBy(col); setSortDir('desc'); }
}
function SortIcon({ col }: { col: typeof sortBy }) {
if (sortBy !== col) return <span className="text-[#444] ml-1"></span>;
return <span className="text-[#3b82f6] ml-1">{sortDir === 'desc' ? '↓' : '↑'}</span>;
}
if (loading || !authChecked) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!isAdmin) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-[#e0e0e0]">
{/* Header */}
<div className="border-b border-[#1e1e1e] bg-[#0d0d0d]">
<div className="max-w-[1400px] mx-auto px-6 py-5 flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-white tracking-tight">Editorial Dashboard</h1>
<p className="text-xs text-[#666] mt-0.5">Article pipeline · Linked campaigns · Ad performance</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/admin/articles"
className="px-3 py-1.5 text-xs font-semibold bg-[#3b82f6] hover:bg-blue-500 text-white rounded transition-colors"
>
+ New Article
</Link>
<Link
href="/dashboard/adops"
className="px-3 py-1.5 text-xs font-semibold bg-[#1a1a1a] hover:bg-[#252525] text-[#aaa] border border-[#2a2a2a] rounded transition-colors"
>
Ad Ops
</Link>
</div>
</div>
</div>
<div className="max-w-[1400px] mx-auto px-6 py-6 space-y-6">
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
{[
{ label: 'Draft', value: summary.draft, color: 'text-yellow-400', onClick: () => setStatusFilter('draft') },
{ label: 'Scheduled', value: summary.scheduled, color: 'text-purple-400', onClick: () => setStatusFilter('scheduled') },
{ label: 'Published', value: summary.published, color: 'text-green-400', onClick: () => setStatusFilter('published') },
{ label: 'Total Revenue', value: fmtMoney(summary.totalRevenue), color: 'text-emerald-400', onClick: () => toggleSort('revenue') },
{ label: 'Impressions', value: fmt(summary.totalImpressions), color: 'text-blue-400', onClick: () => toggleSort('impressions') },
{ label: 'Clicks', value: fmt(summary.totalClicks), color: 'text-cyan-400', onClick: () => toggleSort('clicks') },
{ label: 'Avg CTR', value: summary.avgCtr.toFixed(2) + '%', color: 'text-orange-400', onClick: () => {} },
].map((card) => (
<button
key={card.label}
onClick={card.onClick}
className="bg-[#111] border border-[#1e1e1e] rounded-lg p-4 text-left hover:border-[#2a2a2a] transition-colors cursor-pointer"
>
<div className={`text-xl font-bold ${card.color}`}>{card.value}</div>
<div className="text-[11px] text-[#666] mt-1 uppercase tracking-wider">{card.label}</div>
</button>
))}
</div>
)}
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Status tabs */}
<div className="flex items-center bg-[#111] border border-[#1e1e1e] rounded-lg overflow-hidden">
{(['all', 'draft', 'scheduled', 'published'] as PipelineStatus[]).map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-3 py-1.5 text-xs font-semibold capitalize transition-colors ${
statusFilter === s
? 'bg-[#3b82f6] text-white'
: 'text-[#666] hover:text-[#aaa]'
}`}
>
{s === 'all' ? 'All' : s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-xs">
<input
type="text"
placeholder="Search articles, clients, campaigns..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full bg-[#111] border border-[#1e1e1e] rounded-lg px-3 py-1.5 text-xs text-[#ccc] placeholder-[#444] focus:outline-none focus:border-[#3b82f6] transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[#555] hover:text-[#aaa] text-xs"
></button>
)}
</div>
<div className="ml-auto text-xs text-[#555]">
{filtered.length} article{filtered.length !== 1 ? 's' : ''}
</div>
</div>
{/* Pipeline Table */}
<div className="bg-[#0d0d0d] border border-[#1e1e1e] rounded-xl overflow-hidden">
{loadingData ? (
<div className="flex items-center justify-center py-20">
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-20 text-[#444] text-sm">No articles found</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-[#1a1a1a] bg-[#0a0a0a]">
<th className="text-left px-4 py-3 text-[#555] font-semibold uppercase tracking-wider w-[30%]">Article</th>
<th className="text-left px-3 py-3 text-[#555] font-semibold uppercase tracking-wider">Status</th>
<th
className="text-left px-3 py-3 text-[#555] font-semibold uppercase tracking-wider cursor-pointer hover:text-[#aaa] select-none"
onClick={() => toggleSort('date')}
>
Date <SortIcon col="date" />
</th>
<th className="text-left px-3 py-3 text-[#555] font-semibold uppercase tracking-wider">Linked Campaign</th>
<th
className="text-right px-3 py-3 text-[#555] font-semibold uppercase tracking-wider cursor-pointer hover:text-[#aaa] select-none"
onClick={() => toggleSort('revenue')}
>
Revenue <SortIcon col="revenue" />
</th>
<th
className="text-right px-3 py-3 text-[#555] font-semibold uppercase tracking-wider cursor-pointer hover:text-[#aaa] select-none"
onClick={() => toggleSort('impressions')}
>
Impr. <SortIcon col="impressions" />
</th>
<th
className="text-right px-3 py-3 text-[#555] font-semibold uppercase tracking-wider cursor-pointer hover:text-[#aaa] select-none"
onClick={() => toggleSort('clicks')}
>
Clicks <SortIcon col="clicks" />
</th>
<th className="text-right px-3 py-3 text-[#555] font-semibold uppercase tracking-wider">CTR</th>
</tr>
</thead>
<tbody>
{filtered.map((article, idx) => {
const articleStatus = article.scheduled_at && article.status !== 'published' ?'scheduled'
: article.status;
return (
<tr
key={article.id}
className={`border-b border-[#141414] hover:bg-[#111] transition-colors ${idx % 2 === 0 ? '' : 'bg-[#0a0a0a]/30'}`}
>
{/* Article */}
<td className="px-4 py-3">
<div className="font-medium text-[#ddd] leading-snug line-clamp-2 max-w-[340px]">
{article.title}
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{article.author_name && (
<span className="text-[#555]">{article.author_name}</span>
)}
{article.category && (
<span className="text-[#3b82f6]/70 bg-[#3b82f6]/10 px-1.5 py-0.5 rounded text-[10px]">
{article.category}
</span>
)}
<span className="text-[#444] text-[10px]">{article.source}</span>
</div>
</td>
{/* Status */}
<td className="px-3 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold ${STATUS_COLORS[articleStatus] || STATUS_COLORS['draft']}`}>
{articleStatus}
</span>
{article.scheduled_at && (
<div className="text-[10px] text-[#555] mt-1">{fmtDate(article.scheduled_at)}</div>
)}
</td>
{/* Date */}
<td className="px-3 py-3 text-[#666] whitespace-nowrap">
{fmtDate(article.date || article.scheduled_at)}
</td>
{/* Linked Campaign */}
<td className="px-3 py-3">
{article.campaign_id ? (
<div>
<div className="text-[#ccc] font-medium truncate max-w-[160px]">
{article.client_name || article.campaign_name}
</div>
{article.campaign_name && article.client_name && (
<div className="text-[#555] text-[10px] truncate max-w-[160px] mt-0.5">
{article.campaign_name}
</div>
)}
<div className="flex items-center gap-1.5 mt-1">
{article.campaign_status && (
<span className={`text-[10px] font-semibold ${CAMPAIGN_STATUS_COLORS[article.campaign_status] || 'text-[#666]'}`}>
{article.campaign_status.replace('_', ' ')}
</span>
)}
</div>
{(article.campaign_start || article.campaign_end) && (
<div className="text-[10px] text-[#444] mt-0.5">
{fmtDate(article.campaign_start)} {fmtDate(article.campaign_end)}
</div>
)}
</div>
) : (
<span className="text-[#333] italic">No campaign</span>
)}
</td>
{/* Revenue */}
<td className="px-3 py-3 text-right">
{article.agreed_rate ? (
<span className="text-emerald-400 font-semibold">{fmtMoney(article.agreed_rate)}</span>
) : (
<span className="text-[#333]"></span>
)}
</td>
{/* Impressions */}
<td className="px-3 py-3 text-right text-[#888]">
{fmt(article.impressions)}
</td>
{/* Clicks */}
<td className="px-3 py-3 text-right text-[#888]">
{fmt(article.clicks)}
</td>
{/* CTR */}
<td className="px-3 py-3 text-right">
{article.ctr != null ? (
<span className={`font-semibold ${article.ctr >= 2 ? 'text-green-400' : article.ctr >= 0.5 ? 'text-yellow-400' : 'text-[#666]'}`}>
{article.ctr.toFixed(2)}%
</span>
) : (
<span className="text-[#333]"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 text-[10px] text-[#444]">
<span className="font-semibold text-[#555] uppercase tracking-wider">Campaign status:</span>
{Object.entries(CAMPAIGN_STATUS_COLORS).map(([k, v]) => (
<span key={k} className={`${v} flex items-center gap-1`}>
<span></span> {k.replace('_', ' ')}
</span>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,878 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ShowEvent {
id: string;
event_name: string;
event_type: string;
site_id: number;
city: string | null;
venue: string | null;
country: string | null;
start_date: string | null;
end_date: string | null;
story_engine_start_date: string | null;
story_engine_end_date: string | null;
official_site: string | null;
seo_tier: number;
status: string;
year: number;
phase1_volume_min: number;
phase1_volume_max: number;
phase2_volume_min: number;
phase2_volume_max: number;
phase3_volume_min: number;
phase3_volume_max: number;
phase4_volume_min: number;
phase4_volume_max: number;
engine_paused: boolean;
notes: string | null;
current_phase: string;
}
interface StoryQueueItem {
id: string;
company_name: string;
story_type: string;
show_story_type: string | null;
show_phase: string | null;
assigned_pen_name: string;
site_id: number;
status: string;
generated_title: string | null;
generated_content: string | null;
generated_excerpt: string | null;
quality_confidence: number | null;
source_show: string | null;
seo_meta_description: string | null;
created_at: string;
}
interface Exhibitor {
id: string;
company_name: string;
booth_number: string | null;
preview_story_status: string;
announcement_count: number;
is_av_eligible: boolean;
created_at: string;
}
type TabType = 'calendar' | 'queue' | 'exhibitors' | 'style-profiles' | 'settings';
type SiteFilter = '' | '1' | '2';
const SITE_CONFIG = {
1: { name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]' },
2: { name: 'AV Beat', badge: 'AV', color: '#f97316', bgClass: 'bg-[#f97316]/10', textClass: 'text-[#f97316]' },
};
const SEO_TIER_LABELS: Record<number, { label: string; color: string }> = {
1: { label: 'Tier 1 — Max SEO', color: 'text-yellow-400' },
2: { label: 'Tier 2 — High', color: 'text-blue-400' },
3: { label: 'Tier 3 — Standard',color: 'text-gray-400' },
4: { label: 'Tier 4 — Light', color: 'text-gray-500' },
};
const PHASE_LABELS: Record<string, { label: string; color: string }> = {
phase1_buildup: { label: 'Phase 1 — Buildup', color: 'text-blue-400' },
phase2_surge: { label: 'Phase 2 — Surge', color: 'text-yellow-400' },
phase3_show_week: { label: 'Phase 3 — Show Week', color: 'text-green-400' },
phase4_post_show: { label: 'Phase 4 — Post-Show', color: 'text-orange-400' },
phase5_off_season: { label: 'Phase 5 — Off-Season',color: 'text-gray-500' },
};
function SiteBadge({ siteId }: { siteId: number }) {
const cfg = SITE_CONFIG[siteId as 1 | 2] || SITE_CONFIG[1];
return (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-bold ${cfg.bgClass} ${cfg.textClass}`}>
{cfg.badge}
</span>
);
}
function formatDate(d: string | null): string {
if (!d) return '—';
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
export default function ShowCalendarPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [activeTab, setActiveTab] = useState<TabType>('calendar');
const [siteFilter, setSiteFilter] = useState<SiteFilter>('');
const [showFilter, setShowFilter] = useState('');
const [queueStatus, setQueueStatus] = useState('generated');
const [events, setEvents] = useState<ShowEvent[]>([]);
const [queue, setQueue] = useState<StoryQueueItem[]>([]);
const [exhibitors, setExhibitors] = useState<Exhibitor[]>([]);
const [styleProfiles, setStyleProfiles] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [expandedItem, setExpandedItem] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// Add/Edit event modal
const [showEventModal, setShowEventModal] = useState(false);
const [editingEvent, setEditingEvent] = useState<Partial<ShowEvent> | null>(null);
// Generate story modal
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [generateForm, setGenerateForm] = useState({
event_id: '', story_type: 'exhibitor_preview', company_name: '', source_content: '',
});
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 4000);
};
const fetchData = useCallback(async () => {
setLoadingData(true);
try {
const params = new URLSearchParams({ tab: activeTab });
if (siteFilter) params.set('site', siteFilter);
if (showFilter) params.set('show', showFilter);
if (activeTab === 'queue' && queueStatus) params.set('status', queueStatus);
const res = await fetch(`/api/admin/show-calendar?${params}`);
const data = await res.json();
if (activeTab === 'calendar') setEvents(data.events || []);
else if (activeTab === 'queue') setQueue(data.queue || []);
else if (activeTab === 'exhibitors') setExhibitors(data.exhibitors || []);
else if (activeTab === 'style-profiles') setStyleProfiles(data.profiles || []);
} catch { showToast('Failed to load data', 'error'); }
finally { setLoadingData(false); }
}, [activeTab, siteFilter, showFilter, queueStatus]);
useEffect(() => {
if (!loading && !user) router.push('/login');
}, [user, loading, router]);
useEffect(() => {
if (user) fetchData();
}, [user, fetchData]);
const handleToggleEngine = async (eventId: string, paused: boolean) => {
setActionLoading(eventId);
try {
const res = await fetch('/api/admin/show-calendar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'toggle_engine', event_id: eventId, paused }),
});
if ((await res.json()).success) {
showToast(paused ? 'Engine paused' : 'Engine resumed');
fetchData();
}
} catch { showToast('Failed', 'error'); }
finally { setActionLoading(null); }
};
const handleExtractStyle = async (eventName: string) => {
setActionLoading('style-' + eventName);
try {
const res = await fetch('/api/admin/show-calendar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'extract_style', event_name: eventName }),
});
const data = await res.json();
if (data.success) {
showToast(`Style extracted from ${data.articleCount} articles`);
fetchData();
} else showToast(data.error || 'Failed', 'error');
} catch { showToast('Failed', 'error'); }
finally { setActionLoading(null); }
};
const handleGenerateStory = async (e: React.FormEvent) => {
e.preventDefault();
setActionLoading('generate');
try {
const siteId = siteFilter === '2' ? 2 : 1;
const res = await fetch('/api/admin/show-calendar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'generate_story', ...generateForm, site_id: siteId }),
});
const data = await res.json();
if (data.story) {
showToast(`Story generated — assigned to ${data.penName}`);
setShowGenerateModal(false);
setActiveTab('queue');
fetchData();
} else showToast(data.error || 'Failed', 'error');
} catch { showToast('Failed', 'error'); }
finally { setActionLoading(null); }
};
const handleQueueAction = async (id: string, action: string, extra?: any) => {
setActionLoading(id + action);
try {
const res = await fetch('/api/admin/show-calendar', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, action, ...extra }),
});
const data = await res.json();
if (data.success) {
showToast(action === 'approve_story' ? 'Story published' : 'Story rejected');
fetchData();
} else showToast(data.error || 'Failed', 'error');
} catch { showToast('Failed', 'error'); }
finally { setActionLoading(null); }
};
const handleSaveEvent = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingEvent) return;
setActionLoading('save-event');
try {
const res = await fetch('/api/admin/show-calendar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'upsert_event', ...editingEvent }),
});
const data = await res.json();
if (data.event) {
showToast('Event saved');
setShowEventModal(false);
setEditingEvent(null);
fetchData();
} else showToast(data.error || 'Failed', 'error');
} catch { showToast('Failed', 'error'); }
finally { setActionLoading(null); }
};
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
);
}
const tabs: { id: TabType; label: string }[] = [
{ id: 'calendar', label: 'Show Calendar' },
{ id: 'queue', label: 'Story Queue' },
{ id: 'exhibitors', label: 'Exhibitor List' },
{ id: 'style-profiles',label: 'Style Profiles' },
{ id: 'settings', label: 'Settings' },
];
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg text-sm font-medium shadow-lg ${
toast.type === 'success' ? 'bg-green-900/90 text-green-200 border border-green-700' : 'bg-red-900/90 text-red-200 border border-red-700'
}`}>
{toast.message}
</div>
)}
{/* Header */}
<div className="border-b border-[#1a1a1a] bg-[#0f0f0f]">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3 mb-1">
<Link href="/admin" className="text-[#555] hover:text-white text-sm transition-colors"> Dashboard</Link>
</div>
<h1 className="text-xl font-bold text-white">Show Calendar Engine</h1>
<p className="text-[#555] text-sm mt-0.5">Broadcast Beat + AV Beat Global Show Coverage</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => { setEditingEvent({ site_id: 1, year: new Date().getFullYear(), seo_tier: 3, event_type: 'trade_show' }); setShowEventModal(true); }}
className="px-3 py-1.5 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors"
>
+ Add Event
</button>
<button
onClick={() => setShowGenerateModal(true)}
className="px-3 py-1.5 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-white text-sm rounded-lg transition-colors"
>
Generate Story
</button>
</div>
</div>
{/* Site Filter */}
<div className="flex items-center gap-2 mt-4">
<span className="text-[#555] text-xs uppercase tracking-wider">Site:</span>
{(['', '1', '2'] as SiteFilter[]).map(s => (
<button
key={s}
onClick={() => setSiteFilter(s)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
siteFilter === s
? 'bg-[#3b82f6] text-white'
: 'bg-[#1a1a1a] text-[#888] hover:text-white border border-[#333]'
}`}
>
{s === '' ? 'All Sites' : s === '1' ? '🔵 Broadcast Beat' : '🟠 AV Beat'}
</button>
))}
</div>
{/* Tabs */}
<div className="flex gap-1 mt-4 border-b border-[#1a1a1a]">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'border-[#3b82f6] text-white'
: 'border-transparent text-[#666] hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-6">
{loadingData ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
) : (
<>
{/* ── SHOW CALENDAR TAB ── */}
{activeTab === 'calendar' && (
<div>
<div className="grid gap-3">
{events.length === 0 && (
<div className="text-center py-16 text-[#555]">No events found. Add events to get started.</div>
)}
{events.map(event => {
const siteCfg = SITE_CONFIG[event.site_id as 1 | 2] || SITE_CONFIG[1];
const tierInfo = SEO_TIER_LABELS[event.seo_tier] || SEO_TIER_LABELS[3];
const phaseInfo = PHASE_LABELS[event.current_phase] || PHASE_LABELS.phase5_off_season;
return (
<div key={event.id} className="bg-[#111] border border-[#1e1e1e] rounded-xl p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<SiteBadge siteId={event.site_id} />
<span className={`text-xs font-medium ${tierInfo.color}`}>{tierInfo.label}</span>
<span className={`text-xs ${phaseInfo.color}`}>{phaseInfo.label}</span>
{event.engine_paused && (
<span className="text-xs text-red-400 bg-red-900/20 px-1.5 py-0.5 rounded">Engine Paused</span>
)}
</div>
<h3 className="text-white font-semibold">{event.event_name}</h3>
<p className="text-[#666] text-sm mt-0.5">
{[event.city, event.venue, event.country].filter(Boolean).join(' · ')}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-[#555]">
<span>Show: {formatDate(event.start_date)} {formatDate(event.end_date)}</span>
<span>Engine: {formatDate(event.story_engine_start_date)} {formatDate(event.story_engine_end_date)}</span>
{event.official_site && (
<a href={`https://${event.official_site}`} target="_blank" rel="noopener noreferrer"
className="text-[#3b82f6] hover:underline">{event.official_site}</a>
)}
</div>
<div className="flex items-center gap-3 mt-2 text-xs text-[#555]">
<span>Ph1: {event.phase1_volume_min}{event.phase1_volume_max}/day</span>
<span>Ph2: {event.phase2_volume_min}{event.phase2_volume_max}/day</span>
<span>Ph3: {event.phase3_volume_min}{event.phase3_volume_max}/day</span>
<span>Ph4: {event.phase4_volume_min}{event.phase4_volume_max}/day</span>
</div>
{event.notes && (
<p className="text-[#555] text-xs mt-2 italic">{event.notes}</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleExtractStyle(event.event_name)}
disabled={actionLoading === 'style-' + event.event_name}
className="px-2 py-1 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors disabled:opacity-50"
>
{actionLoading === 'style-' + event.event_name ? 'Extracting...' : 'Extract Style'}
</button>
<button
onClick={() => { setEditingEvent(event); setShowEventModal(true); }}
className="px-2 py-1 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors"
>
Edit Dates
</button>
<button
onClick={() => handleToggleEngine(event.id, !event.engine_paused)}
disabled={actionLoading === event.id}
className={`px-2 py-1 text-xs rounded transition-colors border ${
event.engine_paused
? 'bg-green-900/20 border-green-700 text-green-400 hover:bg-green-900/40' :'bg-red-900/20 border-red-700 text-red-400 hover:bg-red-900/40'
}`}
>
{event.engine_paused ? 'Resume' : 'Pause'}
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── STORY QUEUE TAB ── */}
{activeTab === 'queue' && (
<div>
<div className="flex items-center gap-3 mb-4">
<span className="text-[#555] text-xs uppercase tracking-wider">Status:</span>
{['generated', 'in_review', 'approved', 'published', 'rejected', 'all'].map(s => (
<button
key={s}
onClick={() => setQueueStatus(s)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
queueStatus === s
? 'bg-[#3b82f6] text-white'
: 'bg-[#1a1a1a] text-[#888] hover:text-white border border-[#333]'
}`}
>
{s.replace('_', ' ')}
</button>
))}
</div>
<div className="grid gap-3">
{queue.length === 0 && (
<div className="text-center py-16 text-[#555]">No stories in queue.</div>
)}
{queue.map(item => (
<div key={item.id} className="bg-[#111] border border-[#1e1e1e] rounded-xl p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<SiteBadge siteId={item.site_id} />
{item.source_show && (
<span className="text-xs text-[#555] bg-[#1a1a1a] px-1.5 py-0.5 rounded">{item.source_show}</span>
)}
{item.show_phase && (
<span className={`text-xs ${PHASE_LABELS[item.show_phase]?.color || 'text-gray-500'}`}>
{PHASE_LABELS[item.show_phase]?.label || item.show_phase}
</span>
)}
<span className={`text-xs px-1.5 py-0.5 rounded ${
item.status === 'published' ? 'bg-green-900/20 text-green-400' :
item.status === 'rejected' ? 'bg-red-900/20 text-red-400' :
item.status === 'generated'? 'bg-blue-900/20 text-blue-400' : 'bg-[#1a1a1a] text-[#888]'
}`}>{item.status}</span>
{item.quality_confidence !== null && (
<span className={`text-xs ${item.quality_confidence >= 0.85 ? 'text-green-400' : item.quality_confidence >= 0.7 ? 'text-yellow-400' : 'text-red-400'}`}>
{Math.round((item.quality_confidence || 0) * 100)}% confidence
</span>
)}
</div>
<h3 className="text-white font-medium text-sm">
{item.generated_title || '(No title generated)'}
</h3>
<p className="text-[#666] text-xs mt-0.5">
By {item.assigned_pen_name} · {item.show_story_type || item.story_type}
</p>
{item.generated_excerpt && (
<p className="text-[#555] text-xs mt-1 line-clamp-2">{item.generated_excerpt}</p>
)}
{/* Expanded content */}
{expandedItem === item.id && item.generated_content && (
<div className="mt-3 p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
<div
className="text-[#aaa] text-xs prose prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: item.generated_content.slice(0, 1500) + (item.generated_content.length > 1500 ? '...' : '') }}
/>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
className="px-2 py-1 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors"
>
{expandedItem === item.id ? 'Hide' : 'Preview'}
</button>
{item.status === 'generated' && (
<>
<button
onClick={() => handleQueueAction(item.id, 'approve_story')}
disabled={actionLoading === item.id + 'approve_story'}
className="px-2 py-1 bg-green-900/20 hover:bg-green-900/40 border border-green-700 text-green-400 text-xs rounded transition-colors disabled:opacity-50"
>
{actionLoading === item.id + 'approve_story' ? '...' : 'Approve'}
</button>
<button
onClick={() => {
const reason = prompt('Rejection reason:');
if (reason !== null) handleQueueAction(item.id, 'reject_story', { reason });
}}
className="px-2 py-1 bg-red-900/20 hover:bg-red-900/40 border border-red-700 text-red-400 text-xs rounded transition-colors"
>
Reject
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* ── EXHIBITOR LIST TAB ── */}
{activeTab === 'exhibitors' && (
<div>
<div className="flex items-center gap-3 mb-4">
<span className="text-[#555] text-xs">Filter by show:</span>
<select
value={showFilter}
onChange={e => setShowFilter(e.target.value)}
className="bg-[#1a1a1a] border border-[#333] text-white text-xs rounded px-2 py-1"
>
<option value="">All Shows</option>
{events.map(e => <option key={e.id} value={e.id}>{e.event_name}</option>)}
</select>
</div>
<div className="bg-[#111] border border-[#1e1e1e] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e1e1e]">
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">Company</th>
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">Booth</th>
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">Preview Story</th>
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">AV Eligible</th>
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">Announcements</th>
</tr>
</thead>
<tbody>
{exhibitors.length === 0 && (
<tr><td colSpan={5} className="text-center py-8 text-[#555]">No exhibitors found.</td></tr>
)}
{exhibitors.map(ex => (
<tr key={ex.id} className="border-b border-[#1a1a1a] hover:bg-[#0f0f0f]">
<td className="px-4 py-3 text-white font-medium">{ex.company_name}</td>
<td className="px-4 py-3 text-[#888]">{ex.booth_number || '—'}</td>
<td className="px-4 py-3">
<span className={`text-xs px-1.5 py-0.5 rounded ${
ex.preview_story_status === 'published' ? 'bg-green-900/20 text-green-400' :
ex.preview_story_status === 'generated'? 'bg-blue-900/20 text-blue-400' : 'bg-[#1a1a1a] text-[#888]'
}`}>{ex.preview_story_status}</span>
</td>
<td className="px-4 py-3">
{ex.is_av_eligible ? (
<span className="text-xs text-[#f97316]">AV Beat</span>
) : (
<span className="text-xs text-[#555]">BB only</span>
)}
</td>
<td className="px-4 py-3 text-[#888]">{ex.announcement_count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ── STYLE PROFILES TAB ── */}
{activeTab === 'style-profiles' && (
<div>
<div className="mb-4 p-4 bg-[#111] border border-[#1e1e1e] rounded-xl">
<p className="text-[#888] text-sm">
Style profiles are extracted from Ryan Salazar's existing NAB Show and event coverage articles. These profiles are injected into every show story generation prompt so output matches the publication's established voice.
</p>
</div>
<div className="grid gap-3">
{styleProfiles.length === 0 && (
<div className="text-center py-16 text-[#555]">
No style profiles yet. Use "Extract Style" on any show in the Calendar tab.
</div>
)}
{styleProfiles.map(profile => (
<div key={profile.id} className="bg-[#111] border border-[#1e1e1e] rounded-xl p-4">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-white font-medium">{profile.event_name || profile.event_type}</h3>
<p className="text-[#555] text-xs mt-0.5">
Author: {profile.author_username} · {profile.source_article_count} reference articles ·
Updated: {formatDate(profile.last_updated)}
</p>
{expandedItem === profile.id && profile.style_guide_text && (
<div className="mt-3 p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
<p className="text-[#aaa] text-xs whitespace-pre-wrap">{profile.style_guide_text}</p>
</div>
)}
</div>
<button
onClick={() => setExpandedItem(expandedItem === profile.id ? null : profile.id)}
className="px-2 py-1 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors flex-shrink-0"
>
{expandedItem === profile.id ? 'Hide' : 'View Guide'}
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* ── SETTINGS TAB ── */}
{activeTab === 'settings' && (
<div className="max-w-2xl">
<div className="bg-[#111] border border-[#1e1e1e] rounded-xl p-6">
<h2 className="text-white font-semibold mb-4">Show Engine Settings</h2>
<div className="space-y-4 text-sm text-[#888]">
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
<p className="text-white font-medium mb-1">Annual Date Discovery</p>
<p>Runs automatically on January 1 each year. Searches official show websites and Google for confirmed dates. Sends admin notification when dates are set or when manual review is needed.</p>
</div>
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
<p className="text-white font-medium mb-1">Pen Name Rosters</p>
<p className="mb-2"><strong className="text-[#aaa]">Broadcast Beat (12 writers):</strong> Michael Strand, David Harlow, Karen Fielding, James Mercer (primary NAB), Peter Calloway, Sandra Voss, Brian Kowalski, Laura Pennington, Thomas Reeves, Christine Vale, Marcus Webb, Ellen Forsythe</p>
<p><strong className="text-[#aaa]">AV Beat (7 writers):</strong> Rex Chandler, Dana Flux, Derek Wainwright, Sloane Rigging, Chip Crosspoint, Blair Presenter, Jordan Lumen</p>
</div>
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
<p className="text-white font-medium mb-1">SEO Tier Priority</p>
<p>Tier 1 (Max): NAB Las Vegas, IBC, InfoComm, ISE · Tier 2 (High): NAB NY, BroadcastAsia, CABSAT, AES, InfoComm EDGE/China/LatAm · Tier 3 (Standard): MPTS, SET Expo, NATPE, InfoComm India, FORTÉ LIVE · Tier 4 (Light): NewsTECHForum, TAB, SVVS, Hamburg Open, AVIXA Regional</p>
</div>
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
<p className="text-white font-medium mb-1">NAB Cross-Post to AV Beat</p>
<p>NAB exhibitors with AV-eligible products (projectors, displays, conferencing, control systems, digital signage, installed audio, AV-over-IP) automatically generate stories on AV Beat using AV Beat pen names.</p>
</div>
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
<p className="text-white font-medium mb-1">Blocked Competitor Sources</p>
<p>BB: tvtechnology.com, broadcastingcable.com, sportsvideo.org, digitaltveurope.com, nexttv.com · AV: ravepubs.com, avnetwork.com, commercialintegrator.com, systemscontractor.com, avinteractive.com · Note: infocommshow.org, iseurope.org, nabshow.com, avixa.org are PRIMARY sources not blocked.</p>
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
{/* ── Add/Edit Event Modal ── */}
{showEventModal && editingEvent && (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
<div className="bg-[#111] border border-[#333] rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="text-white font-semibold mb-4">{editingEvent.id ? 'Edit Event' : 'Add Event'}</h2>
<form onSubmit={handleSaveEvent} className="space-y-3">
<div>
<label className="text-[#888] text-xs block mb-1">Event Name *</label>
<input
required
value={editingEvent.event_name || ''}
onChange={e => setEditingEvent(prev => ({ ...prev, event_name: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[#888] text-xs block mb-1">Site</label>
<select
value={editingEvent.site_id || 1}
onChange={e => setEditingEvent(prev => ({ ...prev, site_id: parseInt(e.target.value) }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
>
<option value={1}>Broadcast Beat</option>
<option value={2}>AV Beat</option>
</select>
</div>
<div>
<label className="text-[#888] text-xs block mb-1">SEO Tier</label>
<select
value={editingEvent.seo_tier || 3}
onChange={e => setEditingEvent(prev => ({ ...prev, seo_tier: parseInt(e.target.value) }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
>
<option value={1}>Tier 1 Max SEO</option>
<option value={2}>Tier 2 High</option>
<option value={3}>Tier 3 Standard</option>
<option value={4}>Tier 4 Light</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[#888] text-xs block mb-1">Start Date</label>
<input
type="date"
value={editingEvent.start_date || ''}
onChange={e => setEditingEvent(prev => ({ ...prev, start_date: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
/>
</div>
<div>
<label className="text-[#888] text-xs block mb-1">End Date</label>
<input
type="date"
value={editingEvent.end_date || ''}
onChange={e => setEditingEvent(prev => ({ ...prev, end_date: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[#888] text-xs block mb-1">Engine Start Date</label>
<input
type="date"
value={editingEvent.story_engine_start_date || ''}
onChange={e => setEditingEvent(prev => ({ ...prev, story_engine_start_date: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
/>
</div>
<div>
<label className="text-[#888] text-xs block mb-1">Engine End Date</label>
<input
type="date"
value={editingEvent.story_engine_end_date || ''}
onChange={e => setEditingEvent(prev => ({ ...prev, story_engine_end_date: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[#888] text-xs block mb-1">City</label>
<input
value={editingEvent.city || ''}
onChange={e => setEditingEvent(prev => ({ ...prev, city: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
/>
</div>
<div>
<label className="text-[#888] text-xs block mb-1">Official Site</label>
<input
value={editingEvent.official_site || ''}
onChange={e => setEditingEvent(prev => ({ ...prev, official_site: e.target.value }))}
placeholder="nabshow.com"
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
/>
</div>
</div>
<div>
<label className="text-[#888] text-xs block mb-1">Notes</label>
<textarea
value={editingEvent.notes || ''}
onChange={e => setEditingEvent(prev => ({ ...prev, notes: e.target.value }))}
rows={2}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2 resize-none"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={actionLoading === 'save-event'}
className="flex-1 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
>
{actionLoading === 'save-event' ? 'Saving...' : 'Save Event'}
</button>
<button
type="button"
onClick={() => { setShowEventModal(false); setEditingEvent(null); }}
className="px-4 py-2 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-white text-sm rounded-lg transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
{/* ── Generate Story Modal ── */}
{showGenerateModal && (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
<div className="bg-[#111] border border-[#333] rounded-xl p-6 w-full max-w-lg">
<h2 className="text-white font-semibold mb-4">Generate Show Story</h2>
<form onSubmit={handleGenerateStory} className="space-y-3">
<div>
<label className="text-[#888] text-xs block mb-1">Show *</label>
<select
required
value={generateForm.event_id}
onChange={e => setGenerateForm(prev => ({ ...prev, event_id: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
>
<option value="">Select show...</option>
{events.map(e => <option key={e.id} value={e.id}>{e.event_name} ({e.year})</option>)}
</select>
</div>
<div>
<label className="text-[#888] text-xs block mb-1">Story Type *</label>
<select
value={generateForm.story_type}
onChange={e => setGenerateForm(prev => ({ ...prev, story_type: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
>
<option value="exhibitor_preview">Exhibitor Preview</option>
<option value="show_preview_guide">Show Preview Guide</option>
<option value="breaking_announcement">Breaking Announcement</option>
<option value="show_floor_report">Show Floor Report</option>
<option value="best_of_roundup">Best-Of Roundup</option>
<option value="recap">Show Recap</option>
<option value="award_announcement">Award Announcement</option>
<option value="product_announcement">Product Announcement</option>
<option value="keynote_preview">Keynote Preview</option>
<option value="travel_logistics">Travel & Logistics</option>
<option value="product_availability">Product Availability</option>
</select>
</div>
<div>
<label className="text-[#888] text-xs block mb-1">Company Name (optional)</label>
<input
value={generateForm.company_name}
onChange={e => setGenerateForm(prev => ({ ...prev, company_name: e.target.value }))}
placeholder="e.g. Sony Professional"
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
/>
</div>
<div>
<label className="text-[#888] text-xs block mb-1">Source Content (optional)</label>
<textarea
value={generateForm.source_content}
onChange={e => setGenerateForm(prev => ({ ...prev, source_content: e.target.value }))}
placeholder="Paste press release or source material..."
rows={4}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2 resize-none"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={actionLoading === 'generate'}
className="flex-1 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
>
{actionLoading === 'generate' ? 'Generating...' : 'Generate Story'}
</button>
<button
type="button"
onClick={() => setShowGenerateModal(false)}
className="px-4 py-2 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-white text-sm rounded-lg transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}