- Added InvoiceDetailModal to display invoice details in a modal on the Finance Management page. - Updated invoice amount display to show gross amount instead of net amount. - Refactored invoice selection logic to open the detail modal. - Removed unused subscription handling in Pool Management page. - Simplified pool management UI by removing the create pool modal and related state management. - Enhanced pool display with visual indicators for core pools and improved styling. - Updated member display to show share instead of contributed amount in Pool Management.
356 lines
17 KiB
TypeScript
356 lines
17 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, type AdminInvoice } from './hooks/getInvoices'
|
|
import useAuthStore from '../../store/authStore'
|
|
import InvoiceDetailModal from './components/InvoiceDetailModal'
|
|
|
|
export default function FinanceManagementPage() {
|
|
const router = useRouter()
|
|
const accessToken = useAuthStore(s => s.accessToken)
|
|
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: '' })
|
|
const [diagLoading, setDiagLoading] = useState(false)
|
|
const [diagError, setDiagError] = useState('')
|
|
const [diagData, setDiagData] = useState<any | null>(null)
|
|
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
|
|
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
|
|
|
// 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`)
|
|
}
|
|
|
|
const runPoolCheck = async (invoiceId: string | number) => {
|
|
setDiagLoading(true)
|
|
setDiagError('')
|
|
setDiagData(null)
|
|
try {
|
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
const url = `${base}/api/admin/pools/inflow-diagnostics?invoiceId=${encodeURIComponent(String(invoiceId))}`
|
|
const res = await fetch(url, {
|
|
method: 'GET',
|
|
credentials: 'include',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
},
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
if (!res.ok || body?.success === false) {
|
|
setDiagError(body?.message || `Check failed (${res.status})`)
|
|
return
|
|
}
|
|
setDiagData(body?.data || null)
|
|
} catch (e: any) {
|
|
setDiagError(e?.message || 'Network error')
|
|
} finally {
|
|
setDiagLoading(false)
|
|
}
|
|
}
|
|
|
|
const exportInvoice = (inv: AdminInvoice) => {
|
|
const pretty = JSON.stringify(inv, null, 2)
|
|
const blob = new Blob([pretty], { type: 'application/json;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `invoice-${inv.invoice_number || inv.id}.json`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
a.remove()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
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>
|
|
)}
|
|
{(diagLoading || diagError || diagData) && (
|
|
<div className="rounded-md border border-blue-100 bg-blue-50/60 px-3 py-3 text-sm mb-3">
|
|
{diagLoading && <div className="text-blue-800">Checking pool inflow...</div>}
|
|
{!diagLoading && diagError && <div className="text-red-700">{diagError}</div>}
|
|
{!diagLoading && !diagError && diagData && (
|
|
<div className="space-y-2">
|
|
<div className="text-blue-900 font-semibold">Pool inflow diagnostic for invoice #{diagData.invoice_id ?? '—'}</div>
|
|
<div className="text-gray-700">
|
|
Status: <span className="font-medium">{diagData.ok ? 'OK' : 'Blocked'}</span> • Reason: <span className="font-mono">{diagData.reason}</span>
|
|
</div>
|
|
{diagData.ok && (
|
|
<div className="text-gray-700">
|
|
Abonement: <span className="font-medium">{diagData.abonement_id}</span> • Will book: <span className="font-medium">{diagData.will_book_count}</span> • Already booked: <span className="font-medium">{diagData.already_booked_count}</span>
|
|
</div>
|
|
)}
|
|
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-blue-900">
|
|
<th className="pr-3 py-1">Pool</th>
|
|
<th className="pr-3 py-1">Coffee</th>
|
|
<th className="pr-3 py-1">Capsules</th>
|
|
<th className="pr-3 py-1">Amount (gross)</th>
|
|
<th className="pr-3 py-1">Booked</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{diagData.candidates.map((c: any) => (
|
|
<tr key={`${c.pool_id}-${c.coffee_table_id}`}>
|
|
<td className="pr-3 py-1">{c.pool_name}</td>
|
|
<td className="pr-3 py-1">#{c.coffee_table_id}</td>
|
|
<td className="pr-3 py-1">{c.capsules_count}</td>
|
|
<td className="pr-3 py-1">€{Number(c.amount_gross ?? c.amount_net ?? 0).toFixed(2)}</td>
|
|
<td className="pr-3 py-1">{c.already_booked ? 'yes' : 'no'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</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
|
|
onClick={() => { setSelectedInvoice(inv); setDetailModalOpen(true) }}
|
|
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
|
>
|
|
View
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{selectedInvoice && (
|
|
<InvoiceDetailModal
|
|
invoice={selectedInvoice}
|
|
open={detailModalOpen}
|
|
onClose={() => { setDetailModalOpen(false); setTimeout(() => setSelectedInvoice(null), 200) }}
|
|
onStatusChanged={reload}
|
|
onRunPoolCheck={(id) => { setDetailModalOpen(false); runPoolCheck(id) }}
|
|
onExport={(inv) => exportInvoice(inv)}
|
|
/>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</PageLayout>
|
|
)
|
|
}
|