Skip to content

Commit 71d4a2f

Browse files
committed
feat: Phase 43 — Supplier Statements report
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f6cb966 commit 71d4a2f

5 files changed

Lines changed: 454 additions & 0 deletions

File tree

erp/app/Modules/Finance/Http/Controllers/ReportController.php

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,136 @@ public function exportCashFlowForecast(Request $request): \Symfony\Component\Htt
804804
);
805805
}
806806

807+
public function supplierStatement(Request $request): Response
808+
{
809+
$this->authorize('viewAny', Bill::class);
810+
811+
$contactId = $request->get('contact_id');
812+
$from = $request->get('from', now()->startOfMonth()->toDateString());
813+
$to = $request->get('to', now()->toDateString());
814+
815+
$contacts = Contact::vendors()->orderBy('name')->get(['id', 'name', 'email']);
816+
817+
if (!$contactId) {
818+
return Inertia::render('Finance/Reports/SupplierStatement', [
819+
'contacts' => $contacts,
820+
'contact' => null,
821+
'lines' => [],
822+
'summary' => null,
823+
'from' => $from,
824+
'to' => $to,
825+
]);
826+
}
827+
828+
$contact = Contact::findOrFail($contactId);
829+
830+
// Opening balance: unpaid bill amounts before $from
831+
$openingBalance = (float) Bill::with(['items', 'payments'])
832+
->where('contact_id', $contactId)
833+
->where('issue_date', '<', $from)
834+
->whereNotIn('status', ['draft', 'cancelled'])
835+
->get()
836+
->sum(fn ($b) => $b->total - $b->amount_paid);
837+
838+
$bills = Bill::with(['items', 'payments'])
839+
->where('contact_id', $contactId)
840+
->whereBetween('issue_date', [$from, $to])
841+
->whereNotIn('status', ['draft', 'cancelled'])
842+
->orderBy('issue_date')
843+
->get();
844+
845+
$lines = [];
846+
$balance = $openingBalance;
847+
848+
foreach ($bills as $bill) {
849+
$balance += $bill->total;
850+
$lines[] = [
851+
'date' => $bill->issue_date instanceof \Carbon\Carbon ? $bill->issue_date->toDateString() : (string) $bill->issue_date,
852+
'type' => 'Bill',
853+
'reference' => $bill->number,
854+
'debit' => $bill->total,
855+
'credit' => 0,
856+
'balance' => round($balance, 2),
857+
'status' => $bill->status,
858+
];
859+
860+
if ($bill->amount_paid > 0) {
861+
$balance -= $bill->amount_paid;
862+
$lines[] = [
863+
'date' => $bill->issue_date instanceof \Carbon\Carbon ? $bill->issue_date->toDateString() : (string) $bill->issue_date,
864+
'type' => 'Payment',
865+
'reference' => 'PMT-' . $bill->number,
866+
'debit' => 0,
867+
'credit' => $bill->amount_paid,
868+
'balance' => round($balance, 2),
869+
'status' => '',
870+
];
871+
}
872+
}
873+
874+
$summary = [
875+
'opening_balance' => round($openingBalance, 2),
876+
'total_billed' => round($bills->sum('total'), 2),
877+
'total_paid' => round($bills->sum('amount_paid'), 2),
878+
'closing_balance' => round($balance, 2),
879+
];
880+
881+
return Inertia::render('Finance/Reports/SupplierStatement', [
882+
'contacts' => $contacts,
883+
'contact' => $contact,
884+
'lines' => $lines,
885+
'summary' => $summary,
886+
'from' => $from,
887+
'to' => $to,
888+
]);
889+
}
890+
891+
public function exportSupplierStatement(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
892+
{
893+
$this->authorize('viewAny', Bill::class);
894+
895+
$contactId = $request->get('contact_id');
896+
$from = $request->get('from', now()->startOfMonth()->toDateString());
897+
$to = $request->get('to', now()->toDateString());
898+
899+
abort_unless($contactId, 422, 'contact_id is required.');
900+
$contact = Contact::findOrFail($contactId);
901+
902+
$openingBalance = (float) Bill::with(['items', 'payments'])
903+
->where('contact_id', $contactId)
904+
->where('issue_date', '<', $from)
905+
->whereNotIn('status', ['draft', 'cancelled'])
906+
->get()
907+
->sum(fn ($b) => $b->total - $b->amount_paid);
908+
909+
$bills = Bill::with(['items', 'payments'])
910+
->where('contact_id', $contactId)
911+
->whereBetween('issue_date', [$from, $to])
912+
->whereNotIn('status', ['draft', 'cancelled'])
913+
->orderBy('issue_date')
914+
->get();
915+
916+
$balance = $openingBalance;
917+
$rows = [['Opening Balance', '', '', '', '', round($balance, 2)]];
918+
919+
foreach ($bills as $bill) {
920+
$balance += $bill->total;
921+
$rows[] = [(string)$bill->issue_date, 'Bill', $bill->number, $bill->total, 0, round($balance, 2)];
922+
if ($bill->amount_paid > 0) {
923+
$balance -= $bill->amount_paid;
924+
$rows[] = [(string)$bill->issue_date, 'Payment', 'PMT-' . $bill->number, 0, $bill->amount_paid, round($balance, 2)];
925+
}
926+
}
927+
928+
$filename = "supplier-statement-{$contact->name}-{$from}-{$to}.csv";
929+
930+
return $this->streamCsv(
931+
$filename,
932+
['Date', 'Type', 'Reference', 'Debit', 'Credit', 'Balance'],
933+
$rows
934+
);
935+
}
936+
807937
// ─── CSV Export Methods ───────────────────────────────────────────────────
808938

809939
public function exportProfitLoss(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse

erp/app/Modules/Finance/routes/finance.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@
114114
Route::get('reports/customer-statement', [ReportController::class, 'customerStatementIndex'])->name('reports.customer-statement.index');
115115
Route::get('reports/customer-statement/export', [ReportController::class, 'exportCustomerStatement'])->name('reports.customer-statement.export');
116116
Route::get('reports/customer-statement/{contact}', [ReportController::class, 'customerStatement'])->name('reports.customer-statement');
117+
Route::get('reports/supplier-statement', [ReportController::class, 'supplierStatement'])->name('reports.supplier-statement');
118+
Route::get('reports/supplier-statement/export', [ReportController::class, 'exportSupplierStatement'])->name('reports.supplier-statement.export');
117119
Route::get('reports/vat-report', [ReportController::class, 'vatReport'])->name('reports.vat-report');
118120
Route::get('reports/cash-flow-forecast', [ReportController::class, 'cashFlowForecast'])->name('reports.cash-flow-forecast');
119121

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ const navItems: NavItem[] = [
8383
{ label: 'Aged Payables', href: '/finance/reports/aged-payables', icon: <span /> },
8484
{ label: 'Account Ledger', href: '/finance/reports/account-ledger', icon: <span /> },
8585
{ label: 'Customer Statement', href: '/finance/reports/customer-statement', icon: <span /> },
86+
{ label: 'Supplier Statement', href: '/finance/reports/supplier-statement', icon: <span /> },
8687
{ label: 'VAT Report', href: '/finance/reports/vat-report', icon: <span /> },
8788
{ label: 'Cash Flow', href: '/finance/reports/cash-flow-forecast', icon: <span /> },
8889
{ label: 'Exchange Rates', href: '/finance/exchange-rates', icon: <span /> },
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { Head, router } from '@inertiajs/react';
2+
import { useState } from 'react';
3+
import AppLayout from '@/Layouts/AppLayout';
4+
import type { PageProps } from '@/types';
5+
6+
interface ContactInfo { id: number; name: string; email?: string | null; }
7+
8+
interface StatementLine {
9+
date: string;
10+
type: string;
11+
reference: string | null;
12+
debit: number;
13+
credit: number;
14+
balance: number;
15+
status: string;
16+
}
17+
18+
interface Summary {
19+
opening_balance: number;
20+
total_billed: number;
21+
total_paid: number;
22+
closing_balance: number;
23+
}
24+
25+
interface Props extends PageProps {
26+
contacts: ContactInfo[];
27+
contact: ContactInfo | null;
28+
lines: StatementLine[];
29+
summary: Summary | null;
30+
from: string;
31+
to: string;
32+
}
33+
34+
function fmt(n: number) {
35+
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
36+
}
37+
38+
export default function SupplierStatement({
39+
contacts, contact, lines, summary, from, to,
40+
}: Props) {
41+
const [contactId, setContactId] = useState(contact?.id?.toString() ?? '');
42+
const [fromDate, setFromDate] = useState(from);
43+
const [toDate, setToDate] = useState(to);
44+
45+
function load() {
46+
if (!contactId) return;
47+
router.get('/finance/reports/supplier-statement', {
48+
contact_id: contactId,
49+
from: fromDate,
50+
to: toDate,
51+
}, { preserveState: true });
52+
}
53+
54+
return (
55+
<AppLayout>
56+
<Head title="Supplier Statement" />
57+
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
58+
<div className="flex items-center justify-between flex-wrap gap-4">
59+
<h1 className="text-2xl font-bold text-slate-800">Supplier Statement</h1>
60+
{contact && summary && (
61+
<a
62+
href={`/finance/reports/supplier-statement/export?contact_id=${contactId}&from=${fromDate}&to=${toDate}`}
63+
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
64+
>
65+
Export CSV
66+
</a>
67+
)}
68+
</div>
69+
70+
{/* Filters */}
71+
<div className="flex flex-wrap gap-3 items-end bg-white border border-slate-200 rounded-lg p-4">
72+
<div>
73+
<label className="block text-xs text-slate-500 mb-1">Supplier</label>
74+
<select
75+
value={contactId}
76+
onChange={e => setContactId(e.target.value)}
77+
className="rounded border border-slate-300 px-3 py-1.5 text-sm min-w-48"
78+
>
79+
<option value="">— Select —</option>
80+
{contacts.map(c => (
81+
<option key={c.id} value={c.id}>{c.name}</option>
82+
))}
83+
</select>
84+
</div>
85+
<div>
86+
<label className="block text-xs text-slate-500 mb-1">From</label>
87+
<input
88+
type="date"
89+
value={fromDate}
90+
onChange={e => setFromDate(e.target.value)}
91+
className="rounded border border-slate-300 px-3 py-1.5 text-sm"
92+
/>
93+
</div>
94+
<div>
95+
<label className="block text-xs text-slate-500 mb-1">To</label>
96+
<input
97+
type="date"
98+
value={toDate}
99+
onChange={e => setToDate(e.target.value)}
100+
className="rounded border border-slate-300 px-3 py-1.5 text-sm"
101+
/>
102+
</div>
103+
<button
104+
onClick={load}
105+
className="rounded bg-indigo-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
106+
>
107+
View
108+
</button>
109+
</div>
110+
111+
{contact && summary && (
112+
<>
113+
{/* Contact header */}
114+
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
115+
<p className="font-semibold text-slate-800">{contact.name}</p>
116+
{contact.email && <p className="text-sm text-slate-500">{contact.email}</p>}
117+
<p className="text-xs text-slate-400 mt-1">{fromDate}{toDate}</p>
118+
</div>
119+
120+
{/* Summary cards */}
121+
<div className="grid grid-cols-4 gap-4">
122+
{[
123+
{ label: 'Opening Balance', value: summary.opening_balance },
124+
{ label: 'Total Billed', value: summary.total_billed, green: true },
125+
{ label: 'Total Paid', value: summary.total_paid, blue: true },
126+
{ label: 'Closing Balance', value: summary.closing_balance, bold: true },
127+
].map(({ label, value, green, blue, bold }) => (
128+
<div key={label} className="bg-white border border-slate-200 rounded-lg p-4">
129+
<p className="text-xs text-slate-500">{label}</p>
130+
<p className={`text-xl font-${bold ? 'bold' : 'semibold'} mt-1 ${green ? 'text-green-700' : blue ? 'text-blue-700' : 'text-slate-800'}`}>
131+
{fmt(value)}
132+
</p>
133+
</div>
134+
))}
135+
</div>
136+
137+
{/* Statement lines */}
138+
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
139+
<table className="w-full text-sm">
140+
<thead className="bg-slate-50">
141+
<tr>
142+
{['Date', 'Type', 'Reference', 'Debit', 'Credit', 'Balance'].map(h => (
143+
<th key={h} className="px-4 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wide">{h}</th>
144+
))}
145+
</tr>
146+
</thead>
147+
<tbody className="divide-y divide-slate-100">
148+
{lines.length === 0 && (
149+
<tr>
150+
<td colSpan={6} className="px-4 py-8 text-center text-slate-400">
151+
No transactions in this period.
152+
</td>
153+
</tr>
154+
)}
155+
{lines.map((line, i) => (
156+
<tr key={i} className={`hover:bg-slate-50 ${line.type === 'Payment' ? 'bg-green-50/30' : ''}`}>
157+
<td className="px-4 py-2 text-slate-600">{line.date}</td>
158+
<td className="px-4 py-2">
159+
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${line.type === 'Bill' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
160+
{line.type}
161+
</span>
162+
</td>
163+
<td className="px-4 py-2 text-slate-700 font-medium">{line.reference}</td>
164+
<td className="px-4 py-2 text-right text-red-700">{line.debit > 0 ? fmt(line.debit) : ''}</td>
165+
<td className="px-4 py-2 text-right text-green-700">{line.credit > 0 ? fmt(line.credit) : ''}</td>
166+
<td className="px-4 py-2 text-right font-semibold text-slate-800">{fmt(line.balance)}</td>
167+
</tr>
168+
))}
169+
</tbody>
170+
</table>
171+
</div>
172+
</>
173+
)}
174+
175+
{!contact && (
176+
<div className="bg-slate-50 border border-slate-200 rounded-lg p-8 text-center text-slate-400">
177+
Select a supplier to view their statement.
178+
</div>
179+
)}
180+
</div>
181+
</AppLayout>
182+
);
183+
}

0 commit comments

Comments
 (0)