247 lines
12 KiB
TypeScript
247 lines
12 KiB
TypeScript
'use client'
|
|
import React, { useMemo, useState } from 'react'
|
|
import PageLayout from '../../components/PageLayout'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useVatRates } from './hooks/getTaxes'
|
|
import { useAdminInvoices } from './hooks/getInvoices'
|
|
|
|
export default function FinanceManagementPage() {
|
|
const router = useRouter()
|
|
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
|
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
|
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' })
|
|
|
|
// NEW: fetch invoices from backend
|
|
const {
|
|
invoices,
|
|
loading: invLoading,
|
|
error: invError,
|
|
reload,
|
|
} = useAdminInvoices({
|
|
status: billFilter.status !== 'all' ? billFilter.status : undefined,
|
|
limit: 200,
|
|
offset: 0,
|
|
})
|
|
|
|
// NEW: totals from backend invoices
|
|
const totals = useMemo(() => {
|
|
const now = new Date()
|
|
const inRange = (d: Date) => {
|
|
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
|
if (timeframe === '7d') return diff <= 7
|
|
if (timeframe === '30d') return diff <= 30
|
|
if (timeframe === '90d') return diff <= 90
|
|
return true
|
|
}
|
|
const range = invoices.filter(inv => {
|
|
const dStr = inv.issued_at ?? inv.created_at
|
|
if (!dStr) return false
|
|
const d = new Date(dStr)
|
|
return inRange(d)
|
|
})
|
|
const totalAll = invoices.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
|
const totalRange = range.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
|
return { totalAll, totalRange }
|
|
}, [invoices, timeframe])
|
|
|
|
// NEW: filtered rows for table
|
|
const filteredBills = useMemo(() => {
|
|
const q = billFilter.query.trim().toLowerCase()
|
|
const from = billFilter.from ? new Date(billFilter.from) : null
|
|
const to = billFilter.to ? new Date(billFilter.to) : null
|
|
|
|
return invoices.filter(inv => {
|
|
const byQuery =
|
|
!q ||
|
|
String(inv.invoice_number ?? inv.id).toLowerCase().includes(q) ||
|
|
String(inv.buyer_name ?? '').toLowerCase().includes(q)
|
|
const issued = inv.issued_at ? new Date(inv.issued_at) : (inv.created_at ? new Date(inv.created_at) : null)
|
|
const byFrom = from ? (issued ? issued >= from : false) : true
|
|
const byTo = to ? (issued ? issued <= to : false) : true
|
|
return byQuery && byFrom && byTo
|
|
})
|
|
}, [invoices, billFilter])
|
|
|
|
const exportBills = (format: 'csv' | 'pdf') => {
|
|
console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
|
|
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
|
|
}
|
|
|
|
return (
|
|
<PageLayout>
|
|
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
|
<header className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex flex-col gap-2">
|
|
<h1 className="text-3xl font-extrabold text-blue-900">Finance Management</h1>
|
|
<p className="text-sm text-blue-700">Overview of taxes, revenue, and invoices.</p>
|
|
</header>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
|
<div className="text-xs text-gray-500 mb-1">Total revenue (all time)</div>
|
|
<div className="text-2xl font-semibold text-[#1C2B4A]">€{totals.totalAll.toFixed(2)}</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
|
<div className="text-xs text-gray-500 mb-1">Revenue (range)</div>
|
|
<div className="text-2xl font-semibold text-[#1C2B4A]">€{totals.totalRange.toFixed(2)}</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
|
<div className="text-xs text-gray-500 mb-1">Invoices (range)</div>
|
|
<div className="text-2xl font-semibold text-[#1C2B4A]">{filteredBills.length}</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
|
<div className="text-xs text-gray-500 mb-1">Timeframe</div>
|
|
<select
|
|
value={timeframe}
|
|
onChange={e => setTimeframe(e.target.value as any)}
|
|
className="mt-2 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
|
>
|
|
<option value="7d">Last 7 days</option>
|
|
<option value="30d">Last 30 days</option>
|
|
<option value="90d">Last 90 days</option>
|
|
<option value="ytd">YTD</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* VAT summary */}
|
|
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#1C2B4A]">Manage VAT rates</h2>
|
|
<p className="text-xs text-gray-600">Live data from backend; edit on a separate page.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => router.push('/admin/finance-management/vat-edit')}
|
|
className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90"
|
|
>
|
|
Edit VAT
|
|
</button>
|
|
</div>
|
|
<div className="text-sm text-gray-700">
|
|
{vatLoading && 'Loading VAT rates...'}
|
|
{vatError && <span className="text-red-600">{vatError}</span>}
|
|
{!vatLoading && !vatError && (
|
|
<>Active countries: {rates.length} • Examples: {rates.slice(0, 5).map(r => r.country_code).join(', ')}</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Bills list & filters */}
|
|
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<h2 className="text-lg font-semibold text-[#1C2B4A]">Invoices</h2>
|
|
<div className="flex flex-wrap gap-2 text-sm">
|
|
<button onClick={() => exportBills('csv')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export CSV</button>
|
|
<button onClick={() => exportBills('pdf')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export PDF</button>
|
|
<button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
|
<input
|
|
placeholder="Search (invoice no., customer)"
|
|
value={billFilter.query}
|
|
onChange={e => setBillFilter(f => ({ ...f, query: e.target.value }))}
|
|
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
|
/>
|
|
<select
|
|
value={billFilter.status}
|
|
onChange={e => setBillFilter(f => ({ ...f, status: e.target.value }))}
|
|
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
|
>
|
|
<option value="all">Status: All</option>
|
|
<option value="draft">Draft</option>
|
|
<option value="issued">Issued</option>
|
|
<option value="paid">Paid</option>
|
|
<option value="overdue">Overdue</option>
|
|
<option value="canceled">Canceled</option>
|
|
</select>
|
|
<input
|
|
type="date"
|
|
value={billFilter.from}
|
|
onChange={e => setBillFilter(f => ({ ...f, from: e.target.value }))}
|
|
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
|
/>
|
|
<input
|
|
type="date"
|
|
value={billFilter.to}
|
|
onChange={e => setBillFilter(f => ({ ...f, to: e.target.value }))}
|
|
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
{invError && (
|
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3">
|
|
{invError}
|
|
</div>
|
|
)}
|
|
<table className="min-w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-blue-50 text-left text-blue-900">
|
|
<th className="px-3 py-2 font-semibold">Invoice</th>
|
|
<th className="px-3 py-2 font-semibold">Customer</th>
|
|
<th className="px-3 py-2 font-semibold">Issued</th>
|
|
<th className="px-3 py-2 font-semibold">Amount</th>
|
|
<th className="px-3 py-2 font-semibold">Status</th>
|
|
<th className="px-3 py-2 font-semibold">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{invLoading ? (
|
|
<>
|
|
<tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
|
|
<tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
|
|
</>
|
|
) : filteredBills.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-3 py-4 text-center text-gray-500">
|
|
Keine Rechnungen gefunden.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredBills.map(inv => (
|
|
<tr key={inv.id} className="border-b last:border-0">
|
|
<td className="px-3 py-2">{inv.invoice_number ?? inv.id}</td>
|
|
<td className="px-3 py-2">{inv.buyer_name ?? '—'}</td>
|
|
<td className="px-3 py-2">{inv.issued_at ? new Date(inv.issued_at).toLocaleDateString() : '—'}</td>
|
|
<td className="px-3 py-2">
|
|
€{Number(inv.total_gross ?? 0).toFixed(2)}{' '}
|
|
<span className="text-xs text-gray-500">{inv.currency ?? 'EUR'}</span>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<span
|
|
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
|
inv.status === 'paid'
|
|
? 'bg-green-100 text-green-700'
|
|
: inv.status === 'issued'
|
|
? 'bg-indigo-100 text-indigo-700'
|
|
: inv.status === 'draft'
|
|
? 'bg-gray-100 text-gray-700'
|
|
: inv.status === 'overdue'
|
|
? 'bg-red-100 text-red-700'
|
|
: 'bg-yellow-100 text-yellow-700'
|
|
}`}
|
|
>
|
|
{inv.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 space-x-2">
|
|
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">View</button>
|
|
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</PageLayout>
|
|
)
|
|
}
|