277 lines
17 KiB
TypeScript
277 lines
17 KiB
TypeScript
'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>
|
||
);
|
||
}
|