Files
avbeat-com/src/app/admin/accounting/staff/page.tsx
Ryan Salazar (via Claude) 52fe7bceb6 slice 4: accent palette refinement — blue → teal
Replace the four accent-family hex codes site-wide with the teal palette
(keeps the dark theme; only swaps the accent family, not the dark base):

  #3b82f6 (accent primary CTA)   → #5B7C8D  (teal)        [839×]
  #2563eb (accent-dark / hover)  → #4A6473  (darker teal) [44×]
  #60a5fa (info link, lighter)   → #8FB0C3  (mid teal)    [68×]
  #1e3a5f (accent-muted bg)      → #2F4F5F  (navy)        [44×]

tailwind.config.js tokens updated to match (accent/accent-dark/accent-muted).
Sweep also covers tailwind.css. 108 files touched. The dark base (#0d0d0d,
#111, #161616, #1a1a1a etc.) is intentionally untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:33:04 +00:00

211 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';
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-[#5B7C8D] 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-[#5B7C8D] 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-[#5B7C8D] ${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-[#5B7C8D] 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-[#5B7C8D] 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-[#5B7C8D] hover:underline">View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}