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,210 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
interface Client {
id: string;
company_name: string;
contact_name: string;
contact_email: string;
contact_phone: string;
billing_address: string;
notes: string;
status: string;
created_at: string;
active_orders?: number;
total_revenue?: number;
}
function fmt(cents: number) {
return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 });
}
function validateEmail(email: string) {
return !email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export default function ClientsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [clients, setClients] = useState<Client[]>([]);
const [filter, setFilter] = useState('active');
const [search, setSearch] = useState('');
const [loadingData, setLoadingData] = useState(true);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ company_name: '', contact_name: '', contact_email: '', contact_phone: '', billing_address: '', notes: '' });
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [successMsg, setSuccessMsg] = useState('');
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
const load = useCallback(async () => {
setLoadingData(true);
const params = new URLSearchParams({ status: filter, search });
const r = await fetch(`/api/accounting/clients?${params}`);
const d = await r.json();
setClients(d.clients ?? []);
setLoadingData(false);
}, [filter, search]);
useEffect(() => { if (user) load(); }, [user, load]);
function validate() {
const errs: Record<string, string> = {};
if (!form.company_name.trim()) errs.company_name = 'Company name is required.';
if (!validateEmail(form.contact_email)) errs.contact_email = 'Enter a valid email address.';
return errs;
}
function handleChange(key: string, value: string) {
setForm(p => ({ ...p, [key]: value }));
if (errors[key]) setErrors(p => { const n = { ...p }; delete n[key]; return n; });
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length > 0) { setErrors(errs); return; }
setSaving(true);
try {
const res = await fetch('/api/accounting/clients', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) });
if (!res.ok) throw new Error('Failed to create client');
setShowForm(false);
setForm({ company_name: '', contact_name: '', contact_email: '', contact_phone: '', billing_address: '', notes: '' });
setErrors({});
setSuccessMsg(`Client "${form.company_name}" created successfully.`);
setTimeout(() => setSuccessMsg(''), 4000);
load();
} catch {
setErrors({ _form: 'Something went wrong. Please try again.' });
} finally {
setSaving(false);
}
}
function handleCancel() {
setShowForm(false);
setErrors({});
setForm({ company_name: '', contact_name: '', contact_email: '', contact_phone: '', billing_address: '', notes: '' });
}
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-7xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Clients</h1>
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]"> Accounting</Link>
</div>
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 transition-colors">+ New Client</button>
</div>
{/* Success Toast */}
{successMsg && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400 text-xs">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
{successMsg}
</div>
)}
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{['active','inactive','all'].map(s => (
<button key={s} onClick={() => setFilter(s)} className={`px-3 py-1 rounded text-xs capitalize ${filter === s ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888] hover:text-white'}`}>{s}</button>
))}
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search clients…" className="ml-auto px-3 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#3b82f6]" />
</div>
{/* New Client Form */}
{showForm && (
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">New Client</h3>
{errors._form && (
<div className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-xs">
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{errors._form}
</div>
)}
<div className="grid grid-cols-2 gap-3">
{[
{ key: 'company_name', label: 'Company Name', required: true },
{ key: 'contact_name', label: 'Contact Name' },
{ key: 'contact_email', label: 'Email' },
{ key: 'contact_phone', label: 'Phone' },
].map(f => (
<div key={f.key}>
<label className="text-xs text-[#888] block mb-1">
{f.label}{f.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
<input
type={f.key === 'contact_email' ? 'email' : 'text'}
value={(form as any)[f.key]}
onChange={e => handleChange(f.key, e.target.value)}
className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors[f.key] ? 'border-red-500/60' : 'border-[#252525]'}`}
/>
{errors[f.key] && <p className="mt-1 text-xs text-red-400">{errors[f.key]}</p>}
</div>
))}
<div className="col-span-2">
<label className="text-xs text-[#888] block mb-1">Billing Address</label>
<input value={form.billing_address} onChange={e => handleChange('billing_address', e.target.value)}
className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
<div className="col-span-2">
<label className="text-xs text-[#888] block mb-1">Notes</label>
<textarea value={form.notes} onChange={e => handleChange('notes', e.target.value)} rows={2}
className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create Client'}</button>
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
</div>
</form>
)}
{/* Table */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Company','Contact','Email','Active Orders','Total Revenue','Status',''].map(h => (
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{loadingData ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">Loading</td></tr>
) : clients.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No clients found</td></tr>
) : clients.map(c => (
<tr key={c.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
<td className="px-4 py-2 text-white font-medium">{c.company_name}</td>
<td className="px-4 py-2 text-[#ccc]">{c.contact_name}</td>
<td className="px-4 py-2 text-[#888]">{c.contact_email}</td>
<td className="px-4 py-2 text-[#ccc]">{c.active_orders ?? 0}</td>
<td className="px-4 py-2 text-green-400">{fmt(c.total_revenue ?? 0)}</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${c.status === 'active' ? 'bg-green-400/10 text-green-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{c.status}</span>
</td>
<td className="px-4 py-2">
<Link href={`/admin/accounting/clients/${c.id}`} className="text-xs text-[#3b82f6] hover:underline">View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
function fmt(cents: number) { return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 }); }
export default function CommissionsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [commissions, setCommissions] = useState<any[]>([]);
const [summary, setSummary] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [year, setYear] = useState(new Date().getFullYear());
const [filterStaff, setFilterStaff] = useState('');
const [staff, setStaff] = useState<any[]>([]);
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
const load = useCallback(async () => {
setLoadingData(true);
const params = new URLSearchParams({ year: String(year) });
if (filterStaff) params.set('staff_id', filterStaff);
const [cr, sr, staffR] = await Promise.all([
fetch(`/api/accounting/commissions?${params}`),
fetch(`/api/accounting/commissions/summary?year=${year}`),
fetch('/api/accounting/staff'),
]);
const [cd, sd, staffD] = await Promise.all([cr.json(), sr.json(), staffR.json()]);
setCommissions(cd.commissions ?? []);
setSummary(sd.summary ?? []);
setStaff(staffD.staff ?? []);
setLoadingData(false);
}, [year, filterStaff]);
useEffect(() => { if (user) load(); }, [user, load]);
async function markPaid(id: string) {
await fetch(`/api/accounting/commissions/${id}/mark-paid`, { method: 'POST' });
load();
}
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-6xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Commissions</h1>
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]"> Accounting</Link>
</div>
<div className="flex gap-2">
<select value={year} onChange={e => setYear(Number(e.target.value))} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
{[2024, 2025, 2026, 2027].map(y => <option key={y} value={y}>{y}</option>)}
</select>
<select value={filterStaff} onChange={e => setFilterStaff(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
<option value="">All Staff</option>
{staff.map((s: any) => <option key={s.id} value={s.id}>{s.full_name}</option>)}
</select>
</div>
</div>
{/* Summary per staff */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{summary.map((s: any) => {
const pct = s.next_threshold_cents ? Math.min(100, Math.round((s.ytd_sales_cents / s.next_threshold_cents) * 100)) : 100;
return (
<div key={s.staff_id} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-white">{s.full_name}</p>
<span className="text-xs text-[#888]">{s.current_tier}</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div><p className="text-[#555]">YTD Sales</p><p className="text-white font-medium">{fmt(s.ytd_sales_cents ?? 0)}</p></div>
<div><p className="text-[#555]">Earned</p><p className="text-green-400 font-medium">{fmt(s.earned_cents ?? 0)}</p></div>
<div><p className="text-[#555]">Outstanding</p><p className="text-yellow-400 font-medium">{fmt(s.outstanding_cents ?? 0)}</p></div>
</div>
{s.next_threshold_cents && (
<div>
<div className="flex justify-between text-xs text-[#555] mb-1">
<span>Tier progress</span><span>{pct}%</span>
</div>
<div className="h-1.5 bg-[#1a1a1a] rounded-full overflow-hidden">
<div className="h-full bg-[#3b82f6] rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
)}
</div>
);
})}
</div>
{/* Detail table */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Staff','Invoice','Sale Amount','Rate','Commission','YTD at Time','Paid',''].map(h => (
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{loadingData ? (
<tr><td colSpan={8} className="px-4 py-8 text-center text-[#555] text-xs">Loading</td></tr>
) : commissions.length === 0 ? (
<tr><td colSpan={8} className="px-4 py-8 text-center text-[#555] text-xs">No commission records</td></tr>
) : commissions.map((c: any) => (
<tr key={c.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
<td className="px-3 py-2 text-white text-xs">{c.rmp_sales_staff?.full_name ?? '—'}</td>
<td className="px-3 py-2 text-[#3b82f6] text-xs font-mono">{c.rmp_invoices?.invoice_number ?? '—'}</td>
<td className="px-3 py-2 text-[#ccc] text-xs">{fmt(c.sale_amount_cents ?? 0)}</td>
<td className="px-3 py-2 text-[#888] text-xs">{((c.commission_rate ?? 0) * 100).toFixed(1)}%</td>
<td className="px-3 py-2 text-green-400 text-xs">{fmt(c.commission_amount_cents ?? 0)}</td>
<td className="px-3 py-2 text-[#555] text-xs">{fmt(c.ytd_sales_at_time_cents ?? 0)}</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${c.paid ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{c.paid ? 'Paid' : 'Unpaid'}</span>
</td>
<td className="px-3 py-2">
{!c.paid && <button onClick={() => markPaid(c.id)} className="text-xs text-green-400 hover:underline">Mark Paid</button>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
const DOC_TYPES = ['sales_agreement','contract','po','other'];
export default function DocumentsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [docs, setDocs] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [filterType, setFilterType] = useState('');
const [filterYear, setFilterYear] = useState('');
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ document_type: 'sales_agreement', title: '', year: String(new Date().getFullYear()), person: '', file_url: '', notes: '' });
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
const load = useCallback(async () => {
setLoadingData(true);
const params = new URLSearchParams();
if (filterType) params.set('type', filterType);
if (filterYear) params.set('year', filterYear);
const r = await fetch(`/api/accounting/documents?${params}`);
const d = await r.json();
setDocs(d.documents ?? []);
setLoadingData(false);
}, [filterType, filterYear]);
useEffect(() => { if (user) load(); }, [user, load]);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
await fetch('/api/accounting/documents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...form, year: form.year ? parseInt(form.year) : null }) });
setSaving(false);
setShowForm(false);
load();
}
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-5xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Document Vault</h1>
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]"> Accounting</Link>
</div>
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Upload Document</button>
</div>
{/* Year tabs */}
<div className="flex gap-1 flex-wrap">
<button onClick={() => setFilterYear('')} className={`px-3 py-1 rounded text-xs ${!filterYear ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888]'}`}>All Years</button>
{years.map(y => (
<button key={y} onClick={() => setFilterYear(String(y))} className={`px-3 py-1 rounded text-xs ${filterYear === String(y) ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888]'}`}>{y}</button>
))}
</div>
<div className="flex gap-2">
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
<option value="">All Types</option>
{DOC_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
</select>
</div>
{showForm && (
<form onSubmit={handleCreate} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Upload Document</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-[#888] block mb-1">Type</label>
<select value={form.document_type} onChange={e => setForm(p => ({ ...p, document_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
{DOC_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
</select>
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Title *</label>
<input required value={form.title} onChange={e => setForm(p => ({ ...p, title: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
{form.document_type === 'sales_agreement' && (
<>
<div>
<label className="text-xs text-[#888] block mb-1">Person *</label>
<input required value={form.person} onChange={e => setForm(p => ({ ...p, person: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Year *</label>
<input type="number" required value={form.year} onChange={e => setForm(p => ({ ...p, year: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
</>
)}
<div className="col-span-2">
<label className="text-xs text-[#888] block mb-1">File URL</label>
<input value={form.file_url} onChange={e => setForm(p => ({ ...p, file_url: e.target.value }))} placeholder="https://…" className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
<div className="col-span-2">
<label className="text-xs text-[#888] block mb-1">Notes</label>
<textarea value={form.notes} onChange={e => setForm(p => ({ ...p, notes: e.target.value }))} rows={2} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Save Document'}</button>
<button type="button" onClick={() => setShowForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
</div>
</form>
)}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Type','Title','Person','Year','Date',''].map(h => (
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{loadingData ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-[#555] text-xs">Loading</td></tr>
) : docs.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-[#555] text-xs">No documents found</td></tr>
) : docs.map((d: any) => (
<tr key={d.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
<td className="px-4 py-2 text-[#888] text-xs capitalize">{d.document_type?.replace(/_/g, ' ')}</td>
<td className="px-4 py-2 text-white text-xs">{d.title}</td>
<td className="px-4 py-2 text-[#ccc] text-xs">{d.person ?? '—'}</td>
<td className="px-4 py-2 text-[#888] text-xs">{d.year ?? '—'}</td>
<td className="px-4 py-2 text-[#555] text-xs">{d.uploaded_at ? new Date(d.uploaded_at).toLocaleDateString() : '—'}</td>
<td className="px-4 py-2 flex gap-2">
{d.file_url && <a href={d.file_url} target="_blank" rel="noopener noreferrer" className="text-xs text-[#3b82f6] hover:underline">Download</a>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
const CATEGORIES = [
'contract_labor','software_subscriptions','hosting_infrastructure',
'travel','office_supplies','legal_accounting','marketing','equipment','other',
];
function fmt(cents: number) { return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 }); }
export default function ExpensesPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [expenses, setExpenses] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [filterCat, setFilterCat] = useState('');
const [filterReconciled, setFilterReconciled] = useState('');
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ vendor: '', amount: '', category: 'other', description: '', expense_date: new Date().toISOString().split('T')[0], receipt_source: 'manual' });
const [errors, setErrors] = useState<Record<string, string>>({});
const [successMsg, setSuccessMsg] = useState('');
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
const load = useCallback(async () => {
setLoadingData(true);
const params = new URLSearchParams();
if (filterCat) params.set('category', filterCat);
if (filterReconciled) params.set('reconciled', filterReconciled);
const r = await fetch(`/api/accounting/expenses?${params}`);
const d = await r.json();
setExpenses(d.expenses ?? []);
setLoadingData(false);
}, [filterCat, filterReconciled]);
useEffect(() => { if (user) load(); }, [user, load]);
function handleChange(key: string, value: string) {
setForm(p => ({ ...p, [key]: value }));
if (errors[key]) setErrors(p => { const n = { ...p }; delete n[key]; return n; });
}
function validate() {
const errs: Record<string, string> = {};
if (!form.amount.trim()) {
errs.amount = 'Amount is required.';
} else if (isNaN(parseFloat(form.amount)) || parseFloat(form.amount) <= 0) {
errs.amount = 'Enter a valid positive amount.';
}
if (!form.expense_date) errs.expense_date = 'Date is required.';
return errs;
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length > 0) { setErrors(errs); return; }
setSaving(true);
try {
const body = { ...form, amount_cents: Math.round(parseFloat(form.amount) * 100) };
const res = await fetch('/api/accounting/expenses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('Failed to log expense');
const vendor = form.vendor.trim() || 'Expense';
setShowForm(false);
setErrors({});
setForm({ vendor: '', amount: '', category: 'other', description: '', expense_date: new Date().toISOString().split('T')[0], receipt_source: 'manual' });
setSuccessMsg(`${vendor} expense of $${parseFloat(form.amount).toFixed(2)} logged successfully.`);
setTimeout(() => setSuccessMsg(''), 4000);
load();
} catch {
setErrors({ _form: 'Something went wrong. Please try again.' });
} finally {
setSaving(false);
}
}
function handleCancel() {
setShowForm(false);
setErrors({});
setForm({ vendor: '', amount: '', category: 'other', description: '', expense_date: new Date().toISOString().split('T')[0], receipt_source: 'manual' });
}
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-6xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Expenses</h1>
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]"> Accounting</Link>
</div>
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Log Expense</button>
</div>
{/* Success Toast */}
{successMsg && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400 text-xs">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
{successMsg}
</div>
)}
<div className="flex gap-2 flex-wrap">
<select value={filterCat} onChange={e => setFilterCat(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
<option value="">All Categories</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c.replace(/_/g, ' ')}</option>)}
</select>
<select value={filterReconciled} onChange={e => setFilterReconciled(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
<option value="">All</option>
<option value="true">Reconciled</option>
<option value="false">Unreconciled</option>
</select>
</div>
{showForm && (
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Log Expense</h3>
{errors._form && (
<div className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-xs">
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{errors._form}
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<div>
<label className="text-xs text-[#888] block mb-1">Vendor</label>
<input type="text" value={form.vendor} onChange={e => handleChange('vendor', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Amount ($) <span className="text-red-400">*</span></label>
<input type="number" step="0.01" value={form.amount} onChange={e => handleChange('amount', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.amount ? 'border-red-500/60' : 'border-[#252525]'}`} />
{errors.amount && <p className="mt-1 text-xs text-red-400">{errors.amount}</p>}
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Date <span className="text-red-400">*</span></label>
<input type="date" value={form.expense_date} onChange={e => handleChange('expense_date', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.expense_date ? 'border-red-500/60' : 'border-[#252525]'}`} />
{errors.expense_date && <p className="mt-1 text-xs text-red-400">{errors.expense_date}</p>}
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Category</label>
<select value={form.category} onChange={e => handleChange('category', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
{CATEGORIES.map(c => <option key={c} value={c}>{c.replace(/_/g, ' ')}</option>)}
</select>
</div>
<div className="col-span-2">
<label className="text-xs text-[#888] block mb-1">Description</label>
<input value={form.description} onChange={e => handleChange('description', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Log Expense'}</button>
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
</div>
</form>
)}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Date','Vendor','Amount','Category','Source','Reconciled','Description'].map(h => (
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{loadingData ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">Loading</td></tr>
) : expenses.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No expenses found</td></tr>
) : expenses.map((ex: any) => (
<tr key={ex.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
<td className="px-3 py-2 text-[#888] text-xs">{ex.expense_date}</td>
<td className="px-3 py-2 text-white text-xs">{ex.vendor ?? '—'}</td>
<td className="px-3 py-2 text-red-400 text-xs">{ex.amount_cents ? fmt(ex.amount_cents) : '—'}</td>
<td className="px-3 py-2 text-[#888] text-xs">{ex.category?.replace(/_/g, ' ') ?? '—'}</td>
<td className="px-3 py-2 text-[#555] text-xs">{ex.receipt_source ?? 'manual'}</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${ex.reconciled ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{ex.reconciled ? 'Yes' : 'No'}</span>
</td>
<td className="px-3 py-2 text-[#555] text-xs max-w-xs truncate">{ex.description ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,202 @@
'use client';
import React, { useState, useEffect, useCallback, Suspense } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter, useSearchParams } from 'next/navigation';
function fmt(cents: number) { return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 }); }
const STATUS_COLORS: Record<string, string> = {
paid: 'bg-green-400/10 text-green-400',
pending: 'bg-yellow-400/10 text-yellow-400',
overdue: 'bg-red-400/10 text-red-400',
cancelled: 'bg-[#1a1a1a] text-[#555]',
};
function InvoicesContent() {
const { user, loading } = useAuth();
const router = useRouter();
const sp = useSearchParams();
const [invoices, setInvoices] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [filterStatus, setFilterStatus] = useState(sp.get('status') ?? '');
const [markPaidId, setMarkPaidId] = useState<string | null>(null);
const [payForm, setPayForm] = useState({ method: 'check', paid_date: new Date().toISOString().split('T')[0], wire_reference: '' });
const [saving, setSaving] = useState(false);
const [payErrors, setPayErrors] = useState<Record<string, string>>({});
const [successMsg, setSuccessMsg] = useState('');
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
const load = useCallback(async () => {
setLoadingData(true);
const params = new URLSearchParams();
if (filterStatus) params.set('status', filterStatus);
const r = await fetch(`/api/accounting/invoices?${params}`);
const d = await r.json();
setInvoices(d.invoices ?? []);
setLoadingData(false);
}, [filterStatus]);
useEffect(() => { if (user) load(); }, [user, load]);
function validatePayForm() {
const errs: Record<string, string> = {};
if (!payForm.paid_date) errs.paid_date = 'Paid date is required.';
if (payForm.method === 'wire' && !payForm.wire_reference.trim()) errs.wire_reference = 'Wire reference is required for wire payments.';
return errs;
}
function handlePayChange(key: string, value: string) {
setPayForm(p => ({ ...p, [key]: value }));
if (payErrors[key]) setPayErrors(p => { const n = { ...p }; delete n[key]; return n; });
}
async function handleMarkPaid(e: React.FormEvent) {
e.preventDefault();
if (!markPaidId) return;
const errs = validatePayForm();
if (Object.keys(errs).length > 0) { setPayErrors(errs); return; }
setSaving(true);
try {
const res = await fetch(`/api/accounting/invoices/${markPaidId}/mark-paid`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payForm),
});
if (!res.ok) throw new Error('Failed to mark invoice as paid');
const inv = invoices.find(i => i.id === markPaidId);
setMarkPaidId(null);
setPayErrors({});
setPayForm({ method: 'check', paid_date: new Date().toISOString().split('T')[0], wire_reference: '' });
setSuccessMsg(`Invoice ${inv?.invoice_number ?? ''} marked as paid.`);
setTimeout(() => setSuccessMsg(''), 4000);
load();
} catch {
setPayErrors({ _form: 'Something went wrong. Please try again.' });
} finally {
setSaving(false);
}
}
function handleCloseModal() {
setMarkPaidId(null);
setPayErrors({});
setPayForm({ method: 'check', paid_date: new Date().toISOString().split('T')[0], wire_reference: '' });
}
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-7xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Invoices</h1>
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]"> Accounting</Link>
</div>
<Link href="/admin/accounting/orders" className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ New Invoice (via Order)</Link>
</div>
{/* Success Toast */}
{successMsg && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400 text-xs">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
{successMsg}
</div>
)}
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{['','pending','paid','overdue','cancelled'].map(s => (
<button key={s} onClick={() => setFilterStatus(s)} className={`px-3 py-1 rounded text-xs capitalize ${filterStatus === s ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888] hover:text-white'}`}>{s || 'All'}</button>
))}
</div>
{/* Mark Paid Modal */}
{markPaidId && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<form onSubmit={handleMarkPaid} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-5 w-80 space-y-3">
<h3 className="text-sm font-semibold">Mark Invoice as Paid</h3>
{payErrors._form && (
<div className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-xs">
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{payErrors._form}
</div>
)}
<div>
<label className="text-xs text-[#888] block mb-1">Payment Method</label>
<select value={payForm.method} onChange={e => handlePayChange('method', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
{['stripe','wire','check','other'].map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Paid Date <span className="text-red-400">*</span></label>
<input type="date" value={payForm.paid_date} onChange={e => handlePayChange('paid_date', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none ${payErrors.paid_date ? 'border-red-500/60' : 'border-[#252525]'}`} />
{payErrors.paid_date && <p className="mt-1 text-xs text-red-400">{payErrors.paid_date}</p>}
</div>
{payForm.method === 'wire' && (
<div>
<label className="text-xs text-[#888] block mb-1">Wire Reference <span className="text-red-400">*</span></label>
<input value={payForm.wire_reference} onChange={e => handlePayChange('wire_reference', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none ${payErrors.wire_reference ? 'border-red-500/60' : 'border-[#252525]'}`} />
{payErrors.wire_reference && <p className="mt-1 text-xs text-red-400">{payErrors.wire_reference}</p>}
</div>
)}
<div className="flex gap-2">
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-green-600 rounded text-xs font-medium hover:bg-green-500 disabled:opacity-50">{saving ? 'Saving…' : 'Mark Paid'}</button>
<button type="button" onClick={handleCloseModal} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
</div>
</form>
</div>
)}
{/* Table */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Invoice#','Client','Site','Order#','Amount','Status','Due Date','Salesperson',''].map(h => (
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{loadingData ? (
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">Loading</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No invoices found</td></tr>
) : invoices.map((inv: any) => (
<tr key={inv.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
<td className="px-3 py-2 text-[#3b82f6] font-mono text-xs">{inv.invoice_number}</td>
<td className="px-3 py-2 text-white text-xs">{inv.rmp_clients?.company_name ?? '—'}</td>
<td className="px-3 py-2 text-[#888] text-xs capitalize">{inv.site}</td>
<td className="px-3 py-2 text-[#888] text-xs font-mono">{inv.rmp_orders?.internal_order_number ?? '—'}</td>
<td className="px-3 py-2 text-green-400 text-xs">{inv.amount_cents ? fmt(inv.amount_cents) : '—'}</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${STATUS_COLORS[inv.status] ?? 'bg-[#1a1a1a] text-[#555]'}`}>{inv.status}</span>
</td>
<td className="px-3 py-2 text-[#888] text-xs">{inv.due_date ?? '—'}</td>
<td className="px-3 py-2 text-[#ccc] text-xs">{inv.rmp_sales_staff?.full_name ?? '—'}</td>
<td className="px-3 py-2">
{inv.status !== 'paid' && inv.status !== 'cancelled' && (
<button onClick={() => setMarkPaidId(inv.id)} className="text-xs text-green-400 hover:underline mr-2">Mark Paid</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default function InvoicesPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>}>
<InvoicesContent />
</Suspense>
);
}

View File

@@ -0,0 +1,276 @@
'use client';
import React, { useState, useEffect, useCallback, Suspense } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter, useSearchParams } from 'next/navigation';
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
const STATUSES = ['active','completed','cancelled'];
const SCHEDULES = ['single','monthly','quarterly'];
function fmt(cents: number) { return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 }); }
function OrdersContent() {
const { user, loading } = useAuth();
const router = useRouter();
const sp = useSearchParams();
const [orders, setOrders] = useState<any[]>([]);
const [clients, setClients] = useState<any[]>([]);
const [staff, setStaff] = useState<any[]>([]);
const [products, setProducts] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [filterSite, setFilterSite] = useState(sp.get('site') ?? '');
const [filterStatus, setFilterStatus] = useState(sp.get('status') ?? '');
const [filterClient, setFilterClient] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [successMsg, setSuccessMsg] = useState('');
const [form, setForm] = useState({
client_id: '', site: '', product_id: '', ad_unit: '', description: '',
start_date: '', end_date: '', total_amount_cents: '', currency: 'USD',
invoicing_schedule: 'single', next_invoice_date: '', staff_id: '', po_number: '', notes: '',
});
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
const load = useCallback(async () => {
setLoadingData(true);
const params = new URLSearchParams();
if (filterSite) params.set('site', filterSite);
if (filterStatus) params.set('status', filterStatus);
if (filterClient) params.set('client', filterClient);
const [ordersRes, clientsRes, staffRes] = await Promise.all([
fetch(`/api/accounting/orders?${params}`),
fetch('/api/accounting/clients?status=all'),
fetch('/api/accounting/staff'),
]);
const [od, cd, sd] = await Promise.all([ordersRes.json(), clientsRes.json(), staffRes.json()]);
setOrders(od.orders ?? []);
setClients(cd.clients ?? []);
setStaff(sd.staff ?? []);
setLoadingData(false);
}, [filterSite, filterStatus, filterClient]);
useEffect(() => { if (user) load(); }, [user, load]);
useEffect(() => {
if (!form.site) return;
fetch(`/api/accounting/products?site=${form.site}`)
.then(r => r.json()).then(d => setProducts(d.products ?? []));
}, [form.site]);
function handleChange(key: string, value: string) {
setForm(p => ({ ...p, [key]: value }));
if (errors[key]) setErrors(p => { const n = { ...p }; delete n[key]; return n; });
}
function validate() {
const errs: Record<string, string> = {};
if (!form.client_id) errs.client_id = 'Client is required.';
if (!form.site) errs.site = 'Site is required.';
if (form.total_amount_cents && isNaN(parseFloat(form.total_amount_cents))) errs.total_amount_cents = 'Enter a valid amount.';
if (form.total_amount_cents && parseFloat(form.total_amount_cents) < 0) errs.total_amount_cents = 'Amount must be positive.';
if (form.start_date && form.end_date && form.end_date < form.start_date) errs.end_date = 'End date must be after start date.';
return errs;
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length > 0) { setErrors(errs); return; }
setSaving(true);
try {
const body = { ...form, total_amount_cents: form.total_amount_cents ? Math.round(parseFloat(form.total_amount_cents) * 100) : null };
const res = await fetch('/api/accounting/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('Failed to create order');
const clientName = clients.find((c: any) => c.id === form.client_id)?.company_name ?? 'Order';
setShowForm(false);
setErrors({});
setForm({ client_id: '', site: '', product_id: '', ad_unit: '', description: '', start_date: '', end_date: '', total_amount_cents: '', currency: 'USD', invoicing_schedule: 'single', next_invoice_date: '', staff_id: '', po_number: '', notes: '' });
setSuccessMsg(`Order for "${clientName}" created successfully.`);
setTimeout(() => setSuccessMsg(''), 4000);
load();
} catch {
setErrors({ _form: 'Something went wrong. Please try again.' });
} finally {
setSaving(false);
}
}
function handleCancel() {
setShowForm(false);
setErrors({});
setForm({ client_id: '', site: '', product_id: '', ad_unit: '', description: '', start_date: '', end_date: '', total_amount_cents: '', currency: 'USD', invoicing_schedule: 'single', next_invoice_date: '', staff_id: '', po_number: '', notes: '' });
}
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-7xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Orders</h1>
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]"> Accounting</Link>
</div>
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ New Order</button>
</div>
{/* Success Toast */}
{successMsg && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400 text-xs">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
{successMsg}
</div>
)}
{/* Filters */}
<div className="flex gap-2 flex-wrap items-center">
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
<option value="">All Sites</option>
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
<option value="">All Statuses</option>
{STATUSES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<input value={filterClient} onChange={e => setFilterClient(e.target.value)} placeholder="Filter by client…" className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#3b82f6]" />
</div>
{/* New Order Form */}
{showForm && (
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">New Order</h3>
{errors._form && (
<div className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-xs">
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{errors._form}
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<div>
<label className="text-xs text-[#888] block mb-1">Client <span className="text-red-400">*</span></label>
<select value={form.client_id} onChange={e => handleChange('client_id', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.client_id ? 'border-red-500/60' : 'border-[#252525]'}`}>
<option value="">Select client</option>
{clients.map((c: any) => <option key={c.id} value={c.id}>{c.company_name}</option>)}
</select>
{errors.client_id && <p className="mt-1 text-xs text-red-400">{errors.client_id}</p>}
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Site <span className="text-red-400">*</span></label>
<select value={form.site} onChange={e => handleChange('site', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.site ? 'border-red-500/60' : 'border-[#252525]'}`}>
<option value="">Select site</option>
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
{errors.site && <p className="mt-1 text-xs text-red-400">{errors.site}</p>}
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Product</label>
<select value={form.product_id} onChange={e => handleChange('product_id', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]">
<option value="">Select product</option>
{products.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Ad Unit</label>
<input value={form.ad_unit} onChange={e => handleChange('ad_unit', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Start Date</label>
<input type="date" value={form.start_date} onChange={e => handleChange('start_date', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
<div>
<label className="text-xs text-[#888] block mb-1">End Date</label>
<input type="date" value={form.end_date} onChange={e => handleChange('end_date', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.end_date ? 'border-red-500/60' : 'border-[#252525]'}`} />
{errors.end_date && <p className="mt-1 text-xs text-red-400">{errors.end_date}</p>}
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Total Amount ($)</label>
<input type="number" step="0.01" value={form.total_amount_cents} onChange={e => handleChange('total_amount_cents', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.total_amount_cents ? 'border-red-500/60' : 'border-[#252525]'}`} />
{errors.total_amount_cents && <p className="mt-1 text-xs text-red-400">{errors.total_amount_cents}</p>}
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Invoicing Schedule</label>
<select value={form.invoicing_schedule} onChange={e => handleChange('invoicing_schedule', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]">
{SCHEDULES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Next Invoice Date</label>
<input type="date" value={form.next_invoice_date} onChange={e => handleChange('next_invoice_date', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
<div>
<label className="text-xs text-[#888] block mb-1">Salesperson</label>
<select value={form.staff_id} onChange={e => handleChange('staff_id', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]">
<option value="">None</option>
{staff.map((s: any) => <option key={s.id} value={s.id}>{s.full_name}</option>)}
</select>
</div>
<div>
<label className="text-xs text-[#888] block mb-1">PO Number</label>
<input value={form.po_number} onChange={e => handleChange('po_number', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
<div className="col-span-2 md:col-span-3">
<label className="text-xs text-[#888] block mb-1">Notes</label>
<textarea value={form.notes} onChange={e => handleChange('notes', e.target.value)} rows={2} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create Order'}</button>
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
</div>
</form>
)}
{/* Table */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Client','PO#','Order#','Site','Product','Ad Unit','Dates','Amount','Status',''].map(h => (
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{loadingData ? (
<tr><td colSpan={10} className="px-4 py-8 text-center text-[#555] text-xs">Loading</td></tr>
) : orders.length === 0 ? (
<tr><td colSpan={10} className="px-4 py-8 text-center text-[#555] text-xs">No orders found</td></tr>
) : orders.map((o: any) => (
<tr key={o.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
<td className="px-3 py-2 text-white text-xs">{o.rmp_clients?.company_name ?? '—'}</td>
<td className="px-3 py-2 text-[#888] text-xs">{o.po_number ?? '—'}</td>
<td className="px-3 py-2 text-[#3b82f6] text-xs font-mono">{o.internal_order_number}</td>
<td className="px-3 py-2 text-[#888] text-xs capitalize">{o.site}</td>
<td className="px-3 py-2 text-[#ccc] text-xs">{o.rmp_products?.name ?? o.ad_unit ?? '—'}</td>
<td className="px-3 py-2 text-[#888] text-xs">{o.ad_unit ?? '—'}</td>
<td className="px-3 py-2 text-[#888] text-xs whitespace-nowrap">{o.start_date} {o.end_date ?? '∞'}</td>
<td className="px-3 py-2 text-green-400 text-xs">{o.total_amount_cents ? fmt(o.total_amount_cents) : '—'}</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${o.status === 'active' ? 'bg-green-400/10 text-green-400' : o.status === 'cancelled' ? 'bg-red-400/10 text-red-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{o.status}</span>
</td>
<td className="px-3 py-2">
<Link href={`/admin/accounting/orders/${o.id}`} className="text-xs text-[#3b82f6] hover:underline">View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default function OrdersPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>}>
<OrdersContent />
</Suspense>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
const STAT_CARDS = [
{ key: 'revenue_ytd', label: 'Revenue YTD', color: 'text-green-400', bg: 'bg-green-400/10', href: '/admin/accounting/invoices' },
{ key: 'expenses_ytd', label: 'Expenses YTD', color: 'text-red-400', bg: 'bg-red-400/10', href: '/admin/accounting/expenses' },
{ key: 'net_profit', label: 'Net Profit YTD', color: 'text-blue-400', bg: 'bg-blue-400/10', href: '/admin/accounting/reports' },
{ key: 'outstanding', label: 'Outstanding Invoices', color: 'text-yellow-400', bg: 'bg-yellow-400/10', href: '/admin/accounting/invoices?status=pending' },
{ key: 'unreconciled', label: 'Unreconciled Transactions', color: 'text-orange-400', bg: 'bg-orange-400/10', href: '/admin/accounting/reconciliation' },
{ key: 'commissions_outstanding', label: 'Commissions Outstanding', color: 'text-purple-400', bg: 'bg-purple-400/10', href: '/admin/accounting/commissions' },
];
function fmt(cents: number) {
return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function AccountingDashboard() {
const { user, loading } = useAuth();
const router = useRouter();
const [stats, setStats] = useState<Record<string, number>>({});
const [siteRevenue, setSiteRevenue] = useState<any[]>([]);
const [topClients, setTopClients] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
if (!loading && !user) router.push('/login');
}, [user, loading, router]);
useEffect(() => {
if (!user) return;
fetch('/api/accounting/dashboard')
.then(r => r.json())
.then(d => {
setStats(d.stats ?? {});
setSiteRevenue(d.siteRevenue ?? []);
setTopClients(d.topClients ?? []);
})
.catch(() => {})
.finally(() => setLoadingData(false));
}, [user]);
if (loading || loadingData) return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
);
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Accounting Dashboard</h1>
<p className="text-[#888] text-sm mt-1">Relevant Media Properties, LLC</p>
</div>
<div className="flex gap-2 flex-wrap">
<Link href="/admin/accounting/clients" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">+ New Client</Link>
<Link href="/admin/accounting/orders" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">+ New Order</Link>
<Link href="/admin/accounting/invoices" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">+ New Invoice</Link>
<Link href="/admin/accounting/expenses" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">+ Log Expense</Link>
<Link href="/admin/accounting/reconciliation" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">Upload Bank CSV</Link>
</div>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{STAT_CARDS.map(c => (
<Link key={c.key} href={c.href} className="bg-[#111] border border-[#252525] rounded-lg p-4 hover:border-[#333] transition-colors">
<p className={`text-lg font-bold ${c.color}`}>{fmt(stats[c.key] ?? 0)}</p>
<p className="text-xs text-[#888] mt-1">{c.label}</p>
</Link>
))}
</div>
{/* Site Revenue Table */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#252525]">
<h2 className="text-sm font-semibold text-white">Revenue by Site</h2>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Site','Revenue MTD','Revenue YTD','Outstanding','Active Orders'].map(h => (
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{siteRevenue.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-6 text-center text-[#555] text-xs">No data yet</td></tr>
) : siteRevenue.map((row: any) => (
<tr key={row.site} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
<td className="px-4 py-2 text-white font-medium capitalize">{row.site}</td>
<td className="px-4 py-2 text-green-400">{fmt(row.revenue_mtd ?? 0)}</td>
<td className="px-4 py-2 text-green-400">{fmt(row.revenue_ytd ?? 0)}</td>
<td className="px-4 py-2 text-yellow-400">{fmt(row.outstanding ?? 0)}</td>
<td className="px-4 py-2 text-[#ccc]">{row.active_orders ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Top Clients */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#252525]">
<h2 className="text-sm font-semibold text-white">Top 5 Clients YTD</h2>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Client','Revenue YTD','Active Orders'].map(h => (
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{topClients.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-6 text-center text-[#555] text-xs">No data yet</td></tr>
) : topClients.map((c: any) => (
<tr key={c.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
<td className="px-4 py-2 text-white">{c.company_name}</td>
<td className="px-4 py-2 text-green-400">{fmt(c.revenue_ytd ?? 0)}</td>
<td className="px-4 py-2 text-[#ccc]">{c.active_orders ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Sub-nav links */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{[
{ label: 'Clients', href: '/admin/accounting/clients' },
{ label: 'Orders', href: '/admin/accounting/orders' },
{ label: 'Invoices', href: '/admin/accounting/invoices' },
{ label: 'Staff & Commissions', href: '/admin/accounting/staff' },
{ label: 'Expenses', href: '/admin/accounting/expenses' },
{ label: 'Reconciliation', href: '/admin/accounting/reconciliation' },
{ label: 'Commissions', href: '/admin/accounting/commissions' },
{ label: 'Reports', href: '/admin/accounting/reports' },
{ label: 'Documents', href: '/admin/accounting/documents' },
].map(l => (
<Link key={l.href} href={l.href} className="bg-[#111] border border-[#252525] rounded-lg p-3 text-center text-sm text-[#888] hover:text-white hover:border-[#3b82f6] transition-colors">
{l.label}
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
function fmt(cents: number) { return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 }); }
export default function ReconciliationPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [transactions, setTransactions] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(false);
const [uploading, setUploading] = useState(false);
const [summary, setSummary] = useState<{ matched: number; unmatched: number } | null>(null);
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
const load = useCallback(async () => {
setLoadingData(true);
const r = await fetch('/api/accounting/reconciliation');
const d = await r.json();
setTransactions(d.transactions ?? []);
setSummary(d.summary ?? null);
setLoadingData(false);
}, []);
useEffect(() => { if (user) load(); }, [user, load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const text = await file.text();
const r = await fetch('/api/accounting/reconciliation', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csv: text }),
});
const d = await r.json();
setSummary(d.summary ?? null);
setUploading(false);
load();
}
async function handleManualMatch(txId: string, invoiceId: string) {
await fetch('/api/accounting/reconciliation/match', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction_id: txId, invoice_id: invoiceId }),
});
load();
}
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-6xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Bank Reconciliation</h1>
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]"> Accounting</Link>
</div>
<label className={`px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 cursor-pointer ${uploading ? 'opacity-50' : ''}`}>
{uploading ? 'Uploading…' : 'Upload Bank CSV'}
<input type="file" accept=".csv" className="hidden" onChange={handleUpload} disabled={uploading} />
</label>
</div>
{summary && (
<div className="grid grid-cols-2 gap-3">
<div className="bg-green-400/10 border border-green-400/20 rounded-lg p-4">
<p className="text-2xl font-bold text-green-400">{summary.matched}</p>
<p className="text-xs text-[#888] mt-1">Auto-matched transactions</p>
</div>
<div className="bg-yellow-400/10 border border-yellow-400/20 rounded-lg p-4">
<p className="text-2xl font-bold text-yellow-400">{summary.unmatched}</p>
<p className="text-xs text-[#888] mt-1">Need review</p>
</div>
</div>
)}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Date','Description','Amount','Type','Matched To','Reconciled','Action'].map(h => (
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{loadingData ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">Loading</td></tr>
) : transactions.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No transactions. Upload a CSV to begin.</td></tr>
) : transactions.map((tx: any) => (
<tr key={tx.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
<td className="px-3 py-2 text-[#888] text-xs">{tx.transaction_date}</td>
<td className="px-3 py-2 text-white text-xs max-w-xs truncate">{tx.description}</td>
<td className={`px-3 py-2 text-xs font-medium ${tx.type === 'credit' ? 'text-green-400' : 'text-red-400'}`}>{fmt(Math.abs(tx.amount_cents ?? 0))}</td>
<td className="px-3 py-2 text-[#888] text-xs capitalize">{tx.type}</td>
<td className="px-3 py-2 text-[#555] text-xs">{tx.matched_invoice_id ? `Invoice ${tx.matched_invoice_id.slice(0, 8)}` : tx.matched_expense_id ? `Expense` : '—'}</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${tx.reconciled ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{tx.reconciled ? 'Yes' : 'No'}</span>
</td>
<td className="px-3 py-2">
{!tx.reconciled && <span className="text-xs text-[#555]">Manual match</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
function fmt(cents: number) { return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 }); }
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
export default function ReportsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [report, setReport] = useState<any>(null);
const [loadingData, setLoadingData] = useState(false);
const [startDate, setStartDate] = useState(`${new Date().getFullYear()}-01-01`);
const [endDate, setEndDate] = useState(new Date().toISOString().split('T')[0]);
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
async function loadReport() {
setLoadingData(true);
const r = await fetch(`/api/accounting/reports?start=${startDate}&end=${endDate}`);
const d = await r.json();
setReport(d);
setLoadingData(false);
}
useEffect(() => { if (user) loadReport(); }, [user]);
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-6xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">P&L Reports</h1>
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]"> Accounting</Link>
</div>
<div className="flex gap-2 items-center">
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white focus:outline-none" />
<span className="text-[#555] text-xs">to</span>
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white focus:outline-none" />
<button onClick={loadReport} disabled={loadingData} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">Run Report</button>
</div>
</div>
{loadingData ? (
<div className="flex items-center justify-center py-20"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>
) : report ? (
<div className="space-y-4">
{/* P&L Summary */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-[#111] border border-[#252525] rounded-lg p-4">
<p className="text-xs text-[#555]">Total Revenue</p>
<p className="text-2xl font-bold text-green-400 mt-1">{fmt(report.total_revenue_cents ?? 0)}</p>
</div>
<div className="bg-[#111] border border-[#252525] rounded-lg p-4">
<p className="text-xs text-[#555]">Total Expenses</p>
<p className="text-2xl font-bold text-red-400 mt-1">{fmt(report.total_expenses_cents ?? 0)}</p>
</div>
<div className="bg-[#111] border border-[#252525] rounded-lg p-4">
<p className="text-xs text-[#555]">Net Profit</p>
<p className={`text-2xl font-bold mt-1 ${(report.net_profit_cents ?? 0) >= 0 ? 'text-blue-400' : 'text-red-400'}`}>{fmt(report.net_profit_cents ?? 0)}</p>
</div>
</div>
{/* Revenue by Site */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#252525]"><h2 className="text-sm font-semibold">Revenue by Site</h2></div>
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1a1a1a]">{['Site','Revenue'].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
<tbody>
{(report.by_site ?? []).map((r: any) => (
<tr key={r.site} className="border-b border-[#1a1a1a]">
<td className="px-4 py-2 text-white text-xs capitalize">{r.site}</td>
<td className="px-4 py-2 text-green-400 text-xs">{fmt(r.revenue_cents ?? 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Top Clients */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#252525]"><h2 className="text-sm font-semibold">Top 10 Clients</h2></div>
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1a1a1a]">{['Client','Revenue'].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
<tbody>
{(report.top_clients ?? []).map((c: any) => (
<tr key={c.id} className="border-b border-[#1a1a1a]">
<td className="px-4 py-2 text-white text-xs">{c.company_name}</td>
<td className="px-4 py-2 text-green-400 text-xs">{fmt(c.revenue_cents ?? 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Expenses by Category */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#252525]"><h2 className="text-sm font-semibold">Expenses by Category</h2></div>
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1a1a1a]">{['Category','Amount'].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
<tbody>
{(report.by_category ?? []).map((c: any) => (
<tr key={c.category} className="border-b border-[#1a1a1a]">
<td className="px-4 py-2 text-white text-xs">{c.category?.replace(/_/g, ' ')}</td>
<td className="px-4 py-2 text-red-400 text-xs">{fmt(c.amount_cents ?? 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
function fmt(cents: number) { return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 }); }
function validateEmail(email: string) {
return !email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export default function StaffPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [staff, setStaff] = useState<any[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ full_name: '', email: '', phone: '', notes: '' });
const [tiers, setTiers] = useState([{ tier_order: 1, threshold_cents: '', rate: '' }]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [successMsg, setSuccessMsg] = useState('');
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
const load = useCallback(async () => {
setLoadingData(true);
const r = await fetch('/api/accounting/staff');
const d = await r.json();
setStaff(d.staff ?? []);
setLoadingData(false);
}, []);
useEffect(() => { if (user) load(); }, [user, load]);
function handleChange(key: string, value: string) {
setForm(p => ({ ...p, [key]: value }));
if (errors[key]) setErrors(p => { const n = { ...p }; delete n[key]; return n; });
}
function validate() {
const errs: Record<string, string> = {};
if (!form.full_name.trim()) errs.full_name = 'Full name is required.';
if (!validateEmail(form.email)) errs.email = 'Enter a valid email address.';
tiers.forEach((t, i) => {
if (t.rate === '' || t.rate === undefined) {
errs[`tier_rate_${i}`] = 'Commission rate is required.';
} else if (isNaN(parseFloat(t.rate)) || parseFloat(t.rate) < 0 || parseFloat(t.rate) > 100) {
errs[`tier_rate_${i}`] = 'Rate must be between 0 and 100.';
}
});
return errs;
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length > 0) { setErrors(errs); return; }
setSaving(true);
try {
const body = {
...form,
tiers: tiers.map(t => ({
tier_order: t.tier_order,
threshold_cents: t.threshold_cents ? Math.round(parseFloat(t.threshold_cents) * 100) : null,
rate: t.rate ? parseFloat(t.rate) / 100 : 0,
effective_year: new Date().getFullYear(),
})),
};
const res = await fetch('/api/accounting/staff', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('Failed to create staff');
setShowForm(false);
setErrors({});
setForm({ full_name: '', email: '', phone: '', notes: '' });
setTiers([{ tier_order: 1, threshold_cents: '', rate: '' }]);
setSuccessMsg(`Salesperson "${form.full_name}" added successfully.`);
setTimeout(() => setSuccessMsg(''), 4000);
load();
} catch {
setErrors({ _form: 'Something went wrong. Please try again.' });
} finally {
setSaving(false);
}
}
function handleCancel() {
setShowForm(false);
setErrors({});
setForm({ full_name: '', email: '', phone: '', notes: '' });
setTiers([{ tier_order: 1, threshold_cents: '', rate: '' }]);
}
function handleTierChange(i: number, key: string, value: string) {
setTiers(t => t.map((x, j) => j === i ? { ...x, [key]: value } : x));
const errKey = `tier_rate_${i}`;
if (errors[errKey]) setErrors(p => { const n = { ...p }; delete n[errKey]; return n; });
}
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
if (!user) return null;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
<div className="max-w-5xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Sales Staff</h1>
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]"> Accounting</Link>
</div>
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Add Salesperson</button>
</div>
{/* Success Toast */}
{successMsg && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400 text-xs">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
{successMsg}
</div>
)}
{showForm && (
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">New Salesperson</h3>
{errors._form && (
<div className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-xs">
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{errors._form}
</div>
)}
<div className="grid grid-cols-2 gap-3">
{[{ key: 'full_name', label: 'Full Name', required: true }, { key: 'email', label: 'Email' }, { key: 'phone', label: 'Phone' }].map(f => (
<div key={f.key}>
<label className="text-xs text-[#888] block mb-1">
{f.label}{f.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
<input
type={f.key === 'email' ? 'email' : 'text'}
value={(form as any)[f.key]}
onChange={e => handleChange(f.key, e.target.value)}
className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors[f.key] ? 'border-red-500/60' : 'border-[#252525]'}`}
/>
{errors[f.key] && <p className="mt-1 text-xs text-red-400">{errors[f.key]}</p>}
</div>
))}
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-[#888]">Commission Tiers ({new Date().getFullYear()})</label>
<button type="button" onClick={() => setTiers(t => [...t, { tier_order: t.length + 1, threshold_cents: '', rate: '' }])} className="text-xs text-[#3b82f6] hover:underline">+ Add Tier</button>
</div>
{tiers.map((tier, i) => (
<div key={i} className="flex gap-2 mb-2 items-start">
<span className="text-xs text-[#555] w-12 pt-5">Tier {tier.tier_order}</span>
<div className="flex-1">
<label className="text-xs text-[#555] block mb-0.5">Threshold ($, blank = no cap)</label>
<input type="number" step="0.01" value={tier.threshold_cents} onChange={e => handleTierChange(i, 'threshold_cents', e.target.value)} placeholder="100000" className="w-full px-2 py-1 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none" />
</div>
<div className="flex-1">
<label className="text-xs text-[#555] block mb-0.5">Rate (%) <span className="text-red-400">*</span></label>
<input type="number" step="0.1" value={tier.rate} onChange={e => handleTierChange(i, 'rate', e.target.value)} placeholder="25" className={`w-full px-2 py-1 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none ${errors[`tier_rate_${i}`] ? 'border-red-500/60' : 'border-[#252525]'}`} />
{errors[`tier_rate_${i}`] && <p className="mt-1 text-xs text-red-400">{errors[`tier_rate_${i}`]}</p>}
</div>
</div>
))}
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create'}</button>
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
</div>
</form>
)}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1a1a1a]">
{['Name','Email','Status','Commission YTD','Outstanding',''].map(h => (
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{loadingData ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-[#555] text-xs">Loading</td></tr>
) : staff.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-[#555] text-xs">No staff found</td></tr>
) : staff.map((s: any) => (
<tr key={s.id} className={`border-b border-[#1a1a1a] hover:bg-[#161616] ${s.status === 'terminated' ? 'opacity-50' : ''}`}>
<td className="px-4 py-2 text-white">{s.full_name}</td>
<td className="px-4 py-2 text-[#888] text-xs">{s.email}</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${s.status === 'active' ? 'bg-green-400/10 text-green-400' : s.status === 'terminated' ? 'bg-red-400/10 text-red-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{s.status}</span>
</td>
<td className="px-4 py-2 text-green-400 text-xs">{fmt(s.commission_ytd ?? 0)}</td>
<td className="px-4 py-2 text-yellow-400 text-xs">{fmt(s.commission_outstanding ?? 0)}</td>
<td className="px-4 py-2">
<Link href={`/admin/accounting/staff/${s.id}`} className="text-xs text-[#3b82f6] hover:underline">View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}