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,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>
);
}