initial commit: rocket.new export of broadcastbeat
This commit is contained in:
210
src/app/admin/accounting/clients/page.tsx
Normal file
210
src/app/admin/accounting/clients/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/app/admin/accounting/commissions/page.tsx
Normal file
133
src/app/admin/accounting/commissions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/app/admin/accounting/documents/page.tsx
Normal file
149
src/app/admin/accounting/documents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
src/app/admin/accounting/expenses/page.tsx
Normal file
198
src/app/admin/accounting/expenses/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
src/app/admin/accounting/invoices/page.tsx
Normal file
202
src/app/admin/accounting/invoices/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
276
src/app/admin/accounting/orders/page.tsx
Normal file
276
src/app/admin/accounting/orders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/app/admin/accounting/page.tsx
Normal file
157
src/app/admin/accounting/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/app/admin/accounting/reconciliation/page.tsx
Normal file
116
src/app/admin/accounting/reconciliation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/app/admin/accounting/reports/page.tsx
Normal file
122
src/app/admin/accounting/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
src/app/admin/accounting/staff/page.tsx
Normal file
210
src/app/admin/accounting/staff/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user