initial commit: rocket.new export of broadcastbeat
This commit is contained in:
415
src/app/dashboard/billing/components/BillingSettingsTab.tsx
Normal file
415
src/app/dashboard/billing/components/BillingSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
src/app/dashboard/billing/components/ClientsTab.tsx
Normal file
195
src/app/dashboard/billing/components/ClientsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
331
src/app/dashboard/billing/components/InvoicesTab.tsx
Normal file
331
src/app/dashboard/billing/components/InvoicesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
src/app/dashboard/billing/components/OverviewTab.tsx
Normal file
168
src/app/dashboard/billing/components/OverviewTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/app/dashboard/billing/components/PaymentsTab.tsx
Normal file
121
src/app/dashboard/billing/components/PaymentsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
src/app/dashboard/billing/components/ReportsTab.tsx
Normal file
195
src/app/dashboard/billing/components/ReportsTab.tsx
Normal 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: '1–30 days overdue', key: '1-30', color: 'text-yellow-400' },
|
||||
{ label: '31–60 days overdue', key: '31-60', color: 'text-orange-400' },
|
||||
{ label: '61–90 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>
|
||||
);
|
||||
}
|
||||
158
src/app/dashboard/billing/page.tsx
Normal file
158
src/app/dashboard/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user