- 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.
211 lines
10 KiB
TypeScript
211 lines
10 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';
|
|
|
|
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?next=' + encodeURIComponent(window.location.pathname)); }, [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-[#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-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-[#1D4ED8] 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-[#1D4ED8] text-white' : 'bg-[#111] border border-[#DCE6F2] 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-[#DCE6F2] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#1D4ED8]" />
|
|
</div>
|
|
|
|
{/* New Client Form */}
|
|
{showForm && (
|
|
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#DCE6F2] 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-[#1D4ED8] ${errors[f.key] ? 'border-red-500/60' : 'border-[#DCE6F2]'}`}
|
|
/>
|
|
{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-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
|
</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-[#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…' : 'Create Client'}</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>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-[#FFFFFF]">
|
|
{['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-[#FFFFFF] hover:bg-[#FFFFFF]">
|
|
<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-[#FFFFFF] text-[#555]'}`}>{c.status}</span>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<Link href={`/admin/accounting/clients/${c.id}`} className="text-xs text-[#1D4ED8] hover:underline">View</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|