initial commit
This commit is contained in:
753
src/app/(dashboard)/operations/revenue-recognition/page.tsx
Normal file
753
src/app/(dashboard)/operations/revenue-recognition/page.tsx
Normal file
@@ -0,0 +1,753 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { formatCompactCurrency, formatCurrency } from '@/lib/utils';
|
||||
import {
|
||||
TrendingUp,
|
||||
Loader2,
|
||||
DollarSign,
|
||||
Users,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
Sparkles,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
ComposedChart,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { ErrorState } from '@/components/ui/ErrorState';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────
|
||||
interface MonthlySched {
|
||||
monthIndex: number;
|
||||
month: string;
|
||||
year: number;
|
||||
scheduled: number;
|
||||
recognized: number;
|
||||
posted: number;
|
||||
deferred: number;
|
||||
lineCount: number;
|
||||
}
|
||||
|
||||
interface CustomerBreakdown {
|
||||
name: string;
|
||||
code: string;
|
||||
total: number;
|
||||
recognized: number;
|
||||
deferred: number;
|
||||
contractCount: number;
|
||||
recognitionRate: number;
|
||||
}
|
||||
|
||||
interface ContractRow {
|
||||
id: string;
|
||||
contractNumber: string;
|
||||
name: string;
|
||||
customer: string;
|
||||
totalValue: number;
|
||||
recognizedToDate: number;
|
||||
deferredBalance: number;
|
||||
completionPercent: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
recognitionMethod: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
totalContractValue: number;
|
||||
totalRecognized: number;
|
||||
totalDeferred: number;
|
||||
recognitionRate: number;
|
||||
activeContracts: number;
|
||||
totalContracts: number;
|
||||
totalCustomers: number;
|
||||
}
|
||||
|
||||
interface RevRecData {
|
||||
year: number;
|
||||
monthlySchedule: MonthlySched[];
|
||||
customerBreakdown: CustomerBreakdown[];
|
||||
contracts: ContractRow[];
|
||||
summary: Summary;
|
||||
}
|
||||
|
||||
interface CustomerOption {
|
||||
id: string;
|
||||
customerName: string;
|
||||
customerCode: string;
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────
|
||||
export default function RevenueRecognitionPage() {
|
||||
const [data, setData] = useState<RevRecData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'schedule' | 'customers' | 'contracts'>('schedule');
|
||||
const [contractSearch, setContractSearch] = useState('');
|
||||
|
||||
// Create panel state
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [customers, setCustomers] = useState<CustomerOption[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
const [posting, setPosting] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
customerId: '',
|
||||
totalValue: '',
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
endDate: new Date(new Date().setMonth(new Date().getMonth() + 12)).toISOString().slice(0, 10),
|
||||
recognitionMethod: 'STRAIGHT_LINE',
|
||||
deferredAccountCode: '2300',
|
||||
revenueAccountCode: '4000',
|
||||
});
|
||||
|
||||
// Post-period state
|
||||
const [postPeriod, setPostPeriod] = useState({
|
||||
year: new Date().getFullYear(),
|
||||
month: new Date().getMonth() + 1,
|
||||
});
|
||||
const [postResult, setPostResult] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
try {
|
||||
const res = await fetch('/api/revenue-recognition', {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.details || body?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as RevRecData;
|
||||
if (!json || !Array.isArray(json.contracts) || !json.summary) {
|
||||
throw new Error('Unexpected response shape');
|
||||
}
|
||||
setData(json);
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') setError(e?.message || 'Failed to load');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
// Lazy load customers when create panel opens
|
||||
if (showCreate && customers.length === 0) {
|
||||
fetch('/api/customers', { cache: 'no-store' })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCustomers(Array.isArray(d) ? d : d.customers || []))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [showCreate, customers.length]);
|
||||
|
||||
// ─── Mutations ──────────────────────────────────────────────────
|
||||
const handleCreate = async () => {
|
||||
if (!form.name || !form.totalValue || !form.startDate || !form.endDate) {
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/revenue-contracts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
totalValue: Number(form.totalValue),
|
||||
customerId: form.customerId || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || 'Failed to create contract');
|
||||
}
|
||||
setShowCreate(false);
|
||||
setForm({
|
||||
name: '',
|
||||
customerId: '',
|
||||
totalValue: '',
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
endDate: new Date(new Date().setMonth(new Date().getMonth() + 12)).toISOString().slice(0, 10),
|
||||
recognitionMethod: 'STRAIGHT_LINE',
|
||||
deferredAccountCode: '2300',
|
||||
revenueAccountCode: '4000',
|
||||
});
|
||||
await fetchData();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Failed to create contract');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeed = async () => {
|
||||
setSeeding(true);
|
||||
try {
|
||||
const res = await fetch('/api/revenue-contracts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seed: true }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || 'Failed to seed');
|
||||
}
|
||||
await fetchData();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Failed to seed');
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePost = async () => {
|
||||
setPosting(true);
|
||||
setPostResult(null);
|
||||
try {
|
||||
const res = await fetch('/api/revenue-recognition/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(postPeriod),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.error || 'Failed to post');
|
||||
const lines = `Recognized ${json.recognizedCount} lines, ${formatCurrency(json.totalAmount)}`;
|
||||
const je = json.journalEntryId ? ' — JE created' : '';
|
||||
const warn = json.warning ? ` (${json.warning})` : '';
|
||||
setPostResult(lines + je + warn);
|
||||
await fetchData();
|
||||
} catch (e: any) {
|
||||
setPostResult(`Error: ${e?.message || 'Failed to post'}`);
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Derived ────────────────────────────────────────────────────
|
||||
const filteredContracts = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const q = contractSearch.toLowerCase().trim();
|
||||
if (!q) return data.contracts;
|
||||
return data.contracts.filter(
|
||||
(c) =>
|
||||
c.contractNumber.toLowerCase().includes(q) ||
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.customer.toLowerCase().includes(q)
|
||||
);
|
||||
}, [data, contractSearch]);
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 size={32} className="text-blue-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorState message={error} title="Couldn't load revenue recognition" onRetry={fetchData} />;
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const isEmpty = data.contracts.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#F0F0F3] flex items-center gap-2">
|
||||
<TrendingUp size={24} className="text-emerald-500" />
|
||||
Revenue Recognition
|
||||
</h1>
|
||||
<p className="text-sm text-[#8B8B9E] mt-0.5">
|
||||
ASC 606 contract management · {data.summary.activeContracts} active contracts
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEmpty && (
|
||||
<button
|
||||
onClick={handleSeed}
|
||||
disabled={seeding}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-purple-600 hover:bg-purple-500 text-white font-medium flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
{seeding ? 'Seeding…' : 'Seed Demo Contracts'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-medium flex items-center gap-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Contract
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create panel */}
|
||||
{showCreate && (
|
||||
<div className="bg-[#1A1A1F] rounded-xl border border-[#2A2A32] p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-[#F0F0F3]">New Revenue Contract</h3>
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="text-[#5A5A6E] hover:text-[#F0F0F3]"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="text-[10px] text-[#8B8B9E] uppercase">Contract Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Enterprise SaaS Subscription - Annual"
|
||||
className="w-full mt-1 bg-[#111114] border border-[#2A2A32] rounded-lg px-3 py-2 text-sm text-[#F0F0F3] focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-[#8B8B9E] uppercase">Customer</label>
|
||||
<select
|
||||
value={form.customerId}
|
||||
onChange={(e) => setForm({ ...form, customerId: e.target.value })}
|
||||
className="w-full mt-1 bg-[#111114] border border-[#2A2A32] rounded-lg px-3 py-2 text-sm text-[#F0F0F3] focus:outline-none focus:border-emerald-500"
|
||||
>
|
||||
<option value="">— Select customer —</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.customerName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-[#8B8B9E] uppercase">Total Contract Value</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.totalValue}
|
||||
onChange={(e) => setForm({ ...form, totalValue: e.target.value })}
|
||||
placeholder="120000"
|
||||
className="w-full mt-1 bg-[#111114] border border-[#2A2A32] rounded-lg px-3 py-2 text-sm text-[#F0F0F3] focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-[#8B8B9E] uppercase">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.startDate}
|
||||
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
|
||||
className="w-full mt-1 bg-[#111114] border border-[#2A2A32] rounded-lg px-3 py-2 text-sm text-[#F0F0F3] focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-[#8B8B9E] uppercase">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.endDate}
|
||||
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
|
||||
className="w-full mt-1 bg-[#111114] border border-[#2A2A32] rounded-lg px-3 py-2 text-sm text-[#F0F0F3] focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-[#8B8B9E] uppercase">Recognition Method</label>
|
||||
<select
|
||||
value={form.recognitionMethod}
|
||||
onChange={(e) => setForm({ ...form, recognitionMethod: e.target.value })}
|
||||
className="w-full mt-1 bg-[#111114] border border-[#2A2A32] rounded-lg px-3 py-2 text-sm text-[#F0F0F3] focus:outline-none focus:border-emerald-500"
|
||||
>
|
||||
<option value="STRAIGHT_LINE">Straight-Line (ratable)</option>
|
||||
<option value="POINT_IN_TIME">Point-in-Time</option>
|
||||
<option value="MILESTONE">Milestone</option>
|
||||
<option value="USAGE_BASED">Usage-Based</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-[#8B8B9E] uppercase">Deferred Acct Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.deferredAccountCode}
|
||||
onChange={(e) => setForm({ ...form, deferredAccountCode: e.target.value })}
|
||||
className="w-full mt-1 bg-[#111114] border border-[#2A2A32] rounded-lg px-3 py-2 text-sm text-[#F0F0F3] focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-[#8B8B9E] uppercase">Revenue Acct Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.revenueAccountCode}
|
||||
onChange={(e) => setForm({ ...form, revenueAccountCode: e.target.value })}
|
||||
className="w-full mt-1 bg-[#111114] border border-[#2A2A32] rounded-lg px-3 py-2 text-sm text-[#F0F0F3] focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-[#111114] border border-[#2A2A32] text-[#8B8B9E] hover:text-[#F0F0F3]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !form.name || !form.totalValue}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create Contract & Schedule'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<KpiCard
|
||||
icon={<DollarSign size={16} className="text-emerald-400" />}
|
||||
label="Total Contract Value"
|
||||
value={formatCompactCurrency(data.summary.totalContractValue)}
|
||||
sublabel={`${data.summary.totalContracts} contracts`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CheckCircle2 size={16} className="text-blue-400" />}
|
||||
label="Recognized to Date"
|
||||
value={formatCompactCurrency(data.summary.totalRecognized)}
|
||||
sublabel={`${data.summary.recognitionRate}% of total`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Clock size={16} className="text-amber-400" />}
|
||||
label="Deferred Balance"
|
||||
value={formatCompactCurrency(data.summary.totalDeferred)}
|
||||
sublabel="Yet to recognize"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Users size={16} className="text-purple-400" />}
|
||||
label="Customers"
|
||||
value={String(data.summary.totalCustomers)}
|
||||
sublabel={`${data.summary.activeContracts} active contracts`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Post period card */}
|
||||
<div className="bg-[#1A1A1F] rounded-xl border border-[#2A2A32] p-5">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#F0F0F3] flex items-center gap-2">
|
||||
<Send size={14} className="text-blue-400" />
|
||||
Post Recognition for Period
|
||||
</h3>
|
||||
<p className="text-xs text-[#8B8B9E] mt-0.5">
|
||||
Recognizes all SCHEDULED lines and posts a balanced JE (debits deferred, credits revenue).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={postPeriod.year}
|
||||
onChange={(e) => setPostPeriod({ ...postPeriod, year: parseInt(e.target.value) })}
|
||||
className="bg-[#111114] border border-[#2A2A32] rounded-lg px-2 py-1.5 text-xs text-[#F0F0F3]"
|
||||
>
|
||||
{[data.year - 1, data.year, data.year + 1].map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={postPeriod.month}
|
||||
onChange={(e) => setPostPeriod({ ...postPeriod, month: parseInt(e.target.value) })}
|
||||
className="bg-[#111114] border border-[#2A2A32] rounded-lg px-2 py-1.5 text-xs text-[#F0F0F3]"
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{new Date(2000, m - 1, 1).toLocaleString('en-US', { month: 'long' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handlePost}
|
||||
disabled={posting || isEmpty}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{posting ? 'Posting…' : 'Post Period'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{postResult && (
|
||||
<div
|
||||
className={`mt-3 px-3 py-2 rounded-lg text-xs ${
|
||||
postResult.startsWith('Error')
|
||||
? 'bg-red-900/20 text-red-400 border border-red-900/40'
|
||||
: 'bg-emerald-900/20 text-emerald-400 border border-emerald-900/40'
|
||||
}`}
|
||||
>
|
||||
{postResult}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-[#1A1A1F] rounded-xl border border-[#2A2A32] overflow-hidden">
|
||||
<div className="flex border-b border-[#2A2A32]">
|
||||
{[
|
||||
{ key: 'schedule', label: 'Monthly Schedule', icon: <TrendingUp size={14} /> },
|
||||
{ key: 'customers', label: 'Customers', icon: <Users size={14} /> },
|
||||
{ key: 'contracts', label: 'Contracts', icon: <FileText size={14} /> },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key as any)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'text-emerald-400 border-b-2 border-emerald-500 bg-[#111114]/50'
|
||||
: 'text-[#8B8B9E] hover:text-[#F0F0F3]'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Schedule tab */}
|
||||
{activeTab === 'schedule' && (
|
||||
<div className="p-5">
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data.monthlySchedule}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A32" />
|
||||
<XAxis dataKey="month" stroke="#8B8B9E" tick={{ fontSize: 11 }} />
|
||||
<YAxis
|
||||
stroke="#8B8B9E"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => formatCompactCurrency(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: '#111114',
|
||||
border: '1px solid #2A2A32',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
formatter={(v: any) => formatCurrency(Number(v))}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="scheduled" name="Scheduled" fill="#4A9EFF" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="recognized" name="Recognized" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="deferred"
|
||||
name="Deferred"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-[#5A5A6E] border-b border-[#2A2A32]">
|
||||
<th className="py-2 pr-4">Month</th>
|
||||
<th className="py-2 pr-4 text-right">Lines</th>
|
||||
<th className="py-2 pr-4 text-right">Scheduled</th>
|
||||
<th className="py-2 pr-4 text-right">Recognized</th>
|
||||
<th className="py-2 pr-4 text-right">Posted</th>
|
||||
<th className="py-2 pr-4 text-right">Deferred</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.monthlySchedule.map((m) => (
|
||||
<tr key={m.monthIndex} className="border-b border-[#2A2A32]/50">
|
||||
<td className="py-2 pr-4 text-[#F0F0F3]">{m.month}</td>
|
||||
<td className="py-2 pr-4 text-right text-[#8B8B9E]">{m.lineCount}</td>
|
||||
<td className="py-2 pr-4 text-right text-[#F0F0F3]">{formatCurrency(m.scheduled)}</td>
|
||||
<td className="py-2 pr-4 text-right text-emerald-400">{formatCurrency(m.recognized)}</td>
|
||||
<td className="py-2 pr-4 text-right text-blue-400">{formatCurrency(m.posted)}</td>
|
||||
<td className="py-2 pr-4 text-right text-amber-400">{formatCurrency(m.deferred)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customers tab */}
|
||||
{activeTab === 'customers' && (
|
||||
<div className="p-5 overflow-x-auto">
|
||||
{data.customerBreakdown.length === 0 ? (
|
||||
<div className="text-center py-8 text-[#5A5A6E] text-sm">No customers yet</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-[#5A5A6E] border-b border-[#2A2A32]">
|
||||
<th className="py-2 pr-4">Customer</th>
|
||||
<th className="py-2 pr-4 text-right">Contracts</th>
|
||||
<th className="py-2 pr-4 text-right">Total Value</th>
|
||||
<th className="py-2 pr-4 text-right">Recognized</th>
|
||||
<th className="py-2 pr-4 text-right">Deferred</th>
|
||||
<th className="py-2 pr-4 text-right">% Complete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.customerBreakdown.map((c) => (
|
||||
<tr key={c.code} className="border-b border-[#2A2A32]/50">
|
||||
<td className="py-2 pr-4 text-[#F0F0F3]">{c.name}</td>
|
||||
<td className="py-2 pr-4 text-right text-[#8B8B9E]">{c.contractCount}</td>
|
||||
<td className="py-2 pr-4 text-right text-[#F0F0F3]">{formatCurrency(c.total)}</td>
|
||||
<td className="py-2 pr-4 text-right text-emerald-400">{formatCurrency(c.recognized)}</td>
|
||||
<td className="py-2 pr-4 text-right text-amber-400">{formatCurrency(c.deferred)}</td>
|
||||
<td className="py-2 pr-4 text-right text-[#8B8B9E]">{c.recognitionRate}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contracts tab */}
|
||||
{activeTab === 'contracts' && (
|
||||
<div className="p-5">
|
||||
<div className="mb-3 relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[#5A5A6E]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={contractSearch}
|
||||
onChange={(e) => setContractSearch(e.target.value)}
|
||||
placeholder="Search contracts…"
|
||||
className="w-full pl-9 pr-3 py-2 bg-[#111114] border border-[#2A2A32] rounded-lg text-xs text-[#F0F0F3] focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
{filteredContracts.length === 0 ? (
|
||||
<div className="text-center py-8 text-[#5A5A6E] text-sm">
|
||||
{isEmpty
|
||||
? 'No contracts yet. Create one above or seed demo data.'
|
||||
: 'No contracts match your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-[#5A5A6E] border-b border-[#2A2A32]">
|
||||
<th className="py-2 pr-3">Contract</th>
|
||||
<th className="py-2 pr-3">Customer</th>
|
||||
<th className="py-2 pr-3">Method</th>
|
||||
<th className="py-2 pr-3 text-right">Total</th>
|
||||
<th className="py-2 pr-3 text-right">Recognized</th>
|
||||
<th className="py-2 pr-3 text-right">Deferred</th>
|
||||
<th className="py-2 pr-3 text-right">Complete</th>
|
||||
<th className="py-2 pr-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredContracts.map((c) => (
|
||||
<tr key={c.id} className="border-b border-[#2A2A32]/50 hover:bg-[#111114]/50">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium text-[#F0F0F3]">{c.contractNumber}</div>
|
||||
<div className="text-[10px] text-[#5A5A6E]">{c.name}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-[#8B8B9E]">{c.customer}</td>
|
||||
<td className="py-2 pr-3 text-[#8B8B9E]">
|
||||
{c.recognitionMethod.replace('_', ' ')}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-[#F0F0F3]">
|
||||
{formatCurrency(c.totalValue)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-emerald-400">
|
||||
{formatCurrency(c.recognizedToDate)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-amber-400">
|
||||
{formatCurrency(c.deferredBalance)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-12 h-1.5 bg-[#2A2A32] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${c.completionPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#8B8B9E] w-8 text-right">
|
||||
{c.completionPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded font-medium ${
|
||||
c.status === 'ACTIVE'
|
||||
? 'bg-emerald-900/25 text-emerald-400'
|
||||
: c.status === 'COMPLETED'
|
||||
? 'bg-blue-900/25 text-blue-400'
|
||||
: 'bg-[#2A2A32] text-[#8B8B9E]'
|
||||
}`}
|
||||
>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── KpiCard ──────────────────────────────────────────────────────
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sublabel,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
sublabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-[#1A1A1F] rounded-xl border border-[#2A2A32] p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{icon}
|
||||
<p className="text-[10px] font-medium text-[#8B8B9E] uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-[#F0F0F3]">{value}</p>
|
||||
{sublabel && <p className="text-[10px] text-[#5A5A6E] mt-0.5">{sublabel}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user