399 lines
17 KiB
TypeScript
399 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { formatCurrency, formatCompactCurrency } from '@/lib/utils';
|
|
import {
|
|
ArrowLeft,
|
|
GitMerge,
|
|
Loader2,
|
|
RefreshCcw,
|
|
ArrowRight,
|
|
Building2,
|
|
FileText,
|
|
Search,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
|
|
|
|
interface Entity {
|
|
id: string;
|
|
name: string;
|
|
code: string;
|
|
type: string;
|
|
defaultCurrency: string;
|
|
status: string;
|
|
}
|
|
|
|
interface ICTransaction {
|
|
id: string;
|
|
entryNumber: string;
|
|
entryDate: string;
|
|
description: string;
|
|
status: string;
|
|
totalAmount: number;
|
|
entities: Array<{ id: string; name: string; code: string }>;
|
|
lineCount: number;
|
|
lines: Array<{
|
|
id: string;
|
|
entityId: string | null;
|
|
entityName: string | null;
|
|
entityCode: string | null;
|
|
accountCode: string;
|
|
accountName: string;
|
|
debit: number;
|
|
credit: number;
|
|
description: string | null;
|
|
}>;
|
|
}
|
|
|
|
interface ICPosition {
|
|
fromEntity: { id: string; name: string; code: string };
|
|
toEntity: { id: string; name: string; code: string };
|
|
amount: number;
|
|
}
|
|
|
|
interface Summary {
|
|
totalEntities: number;
|
|
totalIntercompanyTransactions: number;
|
|
totalIntercompanyValue: number;
|
|
outstandingPositions: number;
|
|
totalOutstandingAmount: number;
|
|
}
|
|
|
|
export default function IntercompanyPage() {
|
|
const [entities, setEntities] = useState<Entity[]>([]);
|
|
const [transactions, setTransactions] = useState<ICTransaction[]>([]);
|
|
const [positions, setPositions] = useState<ICPosition[]>([]);
|
|
const [summary, setSummary] = useState<Summary | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<'positions' | 'transactions'>('positions');
|
|
const [search, setSearch] = useState('');
|
|
const [expandedTxn, setExpandedTxn] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/api/intercompany');
|
|
const data = await res.json();
|
|
setEntities(data.entities || []);
|
|
setTransactions(data.transactions || []);
|
|
setPositions(data.positions || []);
|
|
setSummary(data.summary || null);
|
|
} catch (err) {
|
|
console.error('Failed to fetch intercompany data:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const statusColors: Record<string, string> = {
|
|
DRAFT: 'bg-[#1A1A1F] text-[#8B8B9E]',
|
|
PENDING: 'bg-amber-900/25 text-amber-700',
|
|
POSTED: 'bg-emerald-900/25 text-emerald-700',
|
|
APPROVED: 'bg-blue-900/25 text-blue-700',
|
|
REVERSED: 'bg-red-900/25 text-red-600',
|
|
};
|
|
|
|
const filteredTransactions = transactions.filter((txn) => {
|
|
if (!search.trim()) return true;
|
|
const q = search.toLowerCase();
|
|
return (
|
|
txn.entryNumber.toLowerCase().includes(q) ||
|
|
txn.description?.toLowerCase().includes(q) ||
|
|
txn.entities.some((e) => e.name.toLowerCase().includes(q) || e.code.toLowerCase().includes(q))
|
|
);
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<Loader2 size={32} className="text-blue-500 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/finance/consolidation" className="p-2 hover:bg-[#222228] rounded-lg">
|
|
<ArrowLeft size={20} className="text-[#8B8B9E]" />
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-[#F0F0F3] flex items-center gap-2">
|
|
<GitMerge size={24} className="text-purple-600" />
|
|
Intercompany Transactions
|
|
</h1>
|
|
<p className="text-sm text-[#5A5A6E] mt-0.5">
|
|
Track and manage transactions between entities for elimination
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={fetchData}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm border border-[#2A2A32] rounded-lg hover:bg-[#222228]"
|
|
>
|
|
<RefreshCcw size={14} />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
{summary && (
|
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
|
<div className="bg-[#1A1A1F] rounded-xl p-4 border border-[#2A2A32]">
|
|
<p className="text-xs font-medium text-[#5A5A6E]">Entities</p>
|
|
<p className="text-2xl font-bold text-[#F0F0F3] mt-1">{summary.totalEntities}</p>
|
|
</div>
|
|
<div className="bg-purple-900/20 rounded-xl p-4 border border-purple-800/40">
|
|
<p className="text-xs font-medium text-purple-400">IC Transactions</p>
|
|
<p className="text-2xl font-bold text-purple-300 mt-1">{summary.totalIntercompanyTransactions}</p>
|
|
</div>
|
|
<div className="bg-blue-900/20 rounded-xl p-4 border border-blue-800/40" title={formatCurrency(summary.totalIntercompanyValue)}>
|
|
<p className="text-xs font-medium text-blue-400">Total IC Value</p>
|
|
<p className="text-2xl font-bold text-blue-300 mt-1">{formatCompactCurrency(summary.totalIntercompanyValue)}</p>
|
|
</div>
|
|
<div className="bg-amber-900/20 rounded-xl p-4 border border-amber-800/40">
|
|
<p className="text-xs font-medium text-amber-400">Open Positions</p>
|
|
<p className="text-2xl font-bold text-amber-300 mt-1">{summary.outstandingPositions}</p>
|
|
</div>
|
|
<div className="bg-red-900/20 rounded-xl p-4 border border-red-800/40" title={formatCurrency(summary.totalOutstandingAmount)}>
|
|
<p className="text-xs font-medium text-red-400">Outstanding Amount</p>
|
|
<p className="text-2xl font-bold text-red-300 mt-1">{formatCompactCurrency(summary.totalOutstandingAmount)}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-[#1A1A1F] rounded-xl border border-[#2A2A32] overflow-hidden">
|
|
<div className="flex items-center justify-between border-b border-[#2A2A32] px-4">
|
|
<div className="flex">
|
|
{[
|
|
{ id: 'positions' as const, label: 'IC Positions', icon: <Building2 size={14} /> },
|
|
{ id: 'transactions' as const, label: 'IC Journal Entries', icon: <FileText size={14} /> },
|
|
].map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === tab.id
|
|
? 'border-purple-600 text-purple-400'
|
|
: 'border-transparent text-[#5A5A6E] hover:text-[#F0F0F3]'
|
|
}`}
|
|
>
|
|
{tab.icon} {tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{activeTab === 'transactions' && (
|
|
<div className="flex items-center gap-2 bg-[#222228] rounded-lg px-3 py-1.5">
|
|
<Search size={14} className="text-[#5A5A6E]" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search transactions..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="bg-transparent text-sm outline-none w-48"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Positions Tab */}
|
|
{activeTab === 'positions' && (
|
|
<div className="p-4">
|
|
{positions.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<AlertCircle size={32} className="text-[#5A5A6E] mx-auto mb-2" />
|
|
<p className="text-sm text-[#5A5A6E]">No intercompany positions found</p>
|
|
<p className="text-xs text-[#5A5A6E] mt-1">
|
|
Positions appear when journal entries span multiple entities
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{positions.map((pos, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex items-center gap-4 p-4 rounded-lg border border-[#2A2A32] hover:bg-[#222228] transition-colors"
|
|
>
|
|
{/* From Entity */}
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-lg bg-blue-900/25 flex items-center justify-center">
|
|
<Building2 size={16} className="text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold text-[#F0F0F3]">{pos.fromEntity.name}</p>
|
|
<p className="text-xs text-[#5A5A6E] font-mono">{pos.fromEntity.code}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Arrow + Amount */}
|
|
<div className="flex items-center gap-3 px-4">
|
|
<div className="text-right">
|
|
<p className="text-lg font-bold text-purple-400">{formatCurrency(pos.amount)}</p>
|
|
<p className="text-[10px] text-[#5A5A6E] uppercase tracking-wider">Owes</p>
|
|
</div>
|
|
<ArrowRight size={20} className="text-purple-400" />
|
|
</div>
|
|
|
|
{/* To Entity */}
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-lg bg-emerald-900/25 flex items-center justify-center">
|
|
<Building2 size={16} className="text-emerald-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold text-[#F0F0F3]">{pos.toEntity.name}</p>
|
|
<p className="text-xs text-[#5A5A6E] font-mono">{pos.toEntity.code}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Transactions Tab */}
|
|
{activeTab === 'transactions' && (
|
|
<div>
|
|
{filteredTransactions.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<FileText size={32} className="text-[#5A5A6E] mx-auto mb-2" />
|
|
<p className="text-sm text-[#5A5A6E]">No intercompany journal entries found</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-[#2A2A32]">
|
|
{filteredTransactions.map((txn) => (
|
|
<div key={txn.id}>
|
|
<button
|
|
onClick={() => setExpandedTxn(expandedTxn === txn.id ? null : txn.id)}
|
|
className="w-full flex items-center gap-4 px-5 py-4 text-left hover:bg-[#222228] transition-colors"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
href={`/finance/general-ledger/entry/${txn.id}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-sm font-semibold text-blue-600 hover:underline"
|
|
>
|
|
{txn.entryNumber}
|
|
</Link>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusColors[txn.status] || 'bg-[#1A1A1F] text-[#8B8B9E]'}`}>
|
|
{txn.status}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-[#8B8B9E] truncate mt-0.5">{txn.description}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{txn.entities.map((e) => (
|
|
<span
|
|
key={e.id}
|
|
className="text-xs px-2 py-1 bg-purple-900/15 text-purple-700 rounded-md font-medium"
|
|
>
|
|
{e.code}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="text-right flex-shrink-0 w-32">
|
|
<p className="text-sm font-semibold text-[#F0F0F3]">{formatCurrency(txn.totalAmount)}</p>
|
|
<p className="text-xs text-[#5A5A6E]">
|
|
{new Date(txn.entryDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Expanded Lines */}
|
|
{expandedTxn === txn.id && (
|
|
<div className="px-5 pb-4">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="bg-[#111114]">
|
|
<th className="text-left px-3 py-2 font-semibold text-[#5A5A6E]">Entity</th>
|
|
<th className="text-left px-3 py-2 font-semibold text-[#5A5A6E]">Account</th>
|
|
<th className="text-left px-3 py-2 font-semibold text-[#5A5A6E]">Description</th>
|
|
<th className="text-right px-3 py-2 font-semibold text-[#5A5A6E]">Debit</th>
|
|
<th className="text-right px-3 py-2 font-semibold text-[#5A5A6E]">Credit</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[#2A2A32]">
|
|
{txn.lines.map((line) => (
|
|
<tr key={line.id} className="hover:bg-[#222228]">
|
|
<td className="px-3 py-2">
|
|
<span className="px-1.5 py-0.5 bg-purple-900/15 text-purple-700 rounded font-medium">
|
|
{line.entityCode || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-[#8B8B9E]">
|
|
<span className="font-mono">{line.accountCode}</span> {line.accountName}
|
|
</td>
|
|
<td className="px-3 py-2 text-[#5A5A6E]">{line.description || '—'}</td>
|
|
<td className="px-3 py-2 text-right font-semibold text-[#F0F0F3]">
|
|
{line.debit > 0 ? formatCurrency(line.debit) : ''}
|
|
</td>
|
|
<td className="px-3 py-2 text-right font-semibold text-[#F0F0F3]">
|
|
{line.credit > 0 ? formatCurrency(line.credit) : ''}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Entity Overview */}
|
|
<div className="bg-[#1A1A1F] rounded-xl border border-[#2A2A32] overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-[#2A2A32]">
|
|
<h2 className="text-lg font-semibold text-[#F0F0F3]">Entity Structure</h2>
|
|
<p className="text-xs text-[#5A5A6E] mt-0.5">{entities.length} entities in organization</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
|
|
{entities.map((entity) => (
|
|
<div key={entity.id} className="p-4 rounded-lg border border-[#2A2A32] hover:border-purple-600 transition-colors">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
|
entity.type === 'PARENT' ? 'bg-[#222228]' : 'bg-[#222228]'
|
|
}`}>
|
|
<Building2 size={18} className={entity.type === 'PARENT' ? 'text-purple-600' : 'text-blue-600'} />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold text-[#F0F0F3]">{entity.name}</p>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<span className="text-xs text-[#5A5A6E] font-mono">{entity.code}</span>
|
|
<span className="text-xs px-1.5 py-0.5 bg-[#222228] text-[#5A5A6E] rounded">
|
|
{entity.type}
|
|
</span>
|
|
<span className="text-xs text-[#5A5A6E]">{entity.defaultCurrency}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{entities.length === 0 && (
|
|
<div className="col-span-3 text-center py-8">
|
|
<p className="text-sm text-[#5A5A6E]">No entities configured</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|