Files
avbeat-com/src/app/admin/accounting/expenses/page.tsx
Local Administrator 8042024c4a feat(brand): AV BEAT 2026 rebrand — blue/navy/white identity
- New AvBeatLogo inline-SVG component (rounded-square emblem with
  forward-leaning AV monogram + horizontal beam detail) at
  src/components/AvBeatLogo.tsx, three variants (full/wordmark/icon).
- Header.tsx now uses the responsive AV BEAT logo lockup in the
  top-left, links to /, full lockup on desktop (emblem+wordmark+tagline),
  emblem+wordmark on tablet, emblem-only on mobile.
- Removed DualSpeedTicker (the 'RECENT FORUM POSTS' ticker strip);
  the news ticker / glow chrome does not match the new aesthetic and the
  forum row was the explicit removal target.
- Tailwind theme + globals.css now expose the AV BEAT 2026 palette as
  semantic tokens: --brand-blue (#1D4ED8), --brand-blue-bright (#38BDF8),
  --brand-navy (#0F172A), --brand-bg (#F8FAFC), --brand-surface, --brand-border,
  --brand-text, --brand-text-muted. Legacy --color-* aliases re-point to the
  new tokens so any inline-styled component re-themes for free.
- Site-wide hex sweep migrates 2,769 hardcoded color literals across 144
  files from the old dark-broadcast palette to the new tokens (orange
  -> blue, dark-brown -> white surface / navy text, cream -> navy).
- Layout body class flipped from 'bb-neon' to 'bg-brand-bg text-brand-text'
  so the dark glow chrome no longer leaks through the new light theme.
2026-06-03 12:07:18 +00:00

199 lines
11 KiB
TypeScript

'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?next=' + encodeURIComponent(window.location.pathname)); }, [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-[#1D4ED8] 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-[#1D4ED8] 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-[#DCE6F2] 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-[#DCE6F2] 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-[#DCE6F2] 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-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
</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-[#1D4ED8] ${errors.amount ? 'border-red-500/60' : 'border-[#DCE6F2]'}`} />
{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-[#1D4ED8] ${errors.expense_date ? 'border-red-500/60' : 'border-[#DCE6F2]'}`} />
{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-[#DCE6F2] 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-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] 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-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
</div>
</form>
)}
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#FFFFFF]">
{['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-[#FFFFFF] hover:bg-[#FFFFFF]">
<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>
);
}