profit-planet-frontend/src/app/admin/finance-management/page.tsx

493 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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)
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
const [reportEmail, setReportEmail] = useState('')
const [sendingReport, setSendingReport] = useState(false)
const [reportMsg, setReportMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// 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)
}
const [pdfLoading, setPdfLoading] = useState<string | number | null>(null)
const viewInvoicePdf = async (inv: AdminInvoice) => {
setPdfLoading(inv.id)
try {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
const res = await fetch(`${base}/api/invoices/${inv.id}/pdf`, {
method: 'GET',
credentials: 'include',
headers: {
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body?.message || `Failed to load PDF (${res.status})`)
}
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
window.open(blobUrl, '_blank', 'noopener,noreferrer')
} catch (e: any) {
setReportMsg({ type: 'error', text: e?.message || 'Failed to load invoice PDF.' })
} finally {
setPdfLoading(null)
}
}
const sendEmailReport = async () => {
if (!reportEmail.trim()) return
setReportMsg(null)
setSendingReport(true)
try {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
const res = await fetch(`${base}/api/admin/invoices/email-report`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({
email: reportEmail.trim(),
from: billFilter.from || undefined,
to: billFilter.to || undefined,
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok || body?.success === false) {
throw new Error(body?.message || `Request failed (${res.status})`)
}
setReportMsg({ type: 'success', text: `Report sent to ${reportEmail.trim()} (${body.data?.sentCount ?? 0} paid invoice(s)).` })
setEmailDialogOpen(false)
setReportEmail('')
} catch (e: any) {
setReportMsg({ type: 'error', text: e?.message || 'Failed to send email report.' })
} finally {
setSendingReport(false)
}
}
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={() => { setReportMsg(null); setEmailDialogOpen(true) }} className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-blue-900 font-medium hover:bg-blue-100">Send Email Report</button>
<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">
{reportMsg && (
<div className={`rounded-md border px-3 py-2 text-sm mb-3 ${reportMsg.type === 'success' ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
{reportMsg.text}
</div>
)}
{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">Due Date</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={7} className="px-3 py-3"><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
<tr><td colSpan={7} 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={7} 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">
{(() => {
if (!inv.due_at) return <span className="text-gray-400"></span>
const due = new Date(inv.due_at)
const now = new Date()
const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
let cls = 'bg-green-100 text-green-700' // plenty of time
if (inv.status === 'paid') cls = 'bg-green-100 text-green-700'
else if (diffDays < 0) cls = 'bg-red-100 text-red-700'
else if (diffDays <= 3) cls = 'bg-red-100 text-red-700'
else if (diffDays <= 7) cls = 'bg-amber-100 text-amber-700'
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cls}`}>
{due.toLocaleDateString()}
</span>
)
})()}
</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={() => viewInvoicePdf(inv)}
disabled={pdfLoading === inv.id || !inv.pdf_storage_key}
className="text-xs rounded border px-2 py-1 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{pdfLoading === inv.id ? 'Loading…' : 'View PDF'}
</button>
<button
onClick={() => { setSelectedInvoice(inv); setDetailModalOpen(true) }}
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
>
Details
</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)}
/>
)}
{/* Email Report Dialog */}
{emailDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<h3 className="text-lg font-semibold text-[#1C2B4A] mb-1">Send Email Report</h3>
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">
Only <strong>paid</strong> invoices will be included in the report, regardless of the status filter.
{(billFilter.from || billFilter.to) && (
<span> The current date range filter ({billFilter.from || '…'} {billFilter.to || '…'}) will be applied.</span>
)}
</div>
<label className="block text-sm font-medium text-gray-700 mb-1">Recipient Email</label>
<input
type="email"
value={reportEmail}
onChange={e => setReportEmail(e.target.value)}
placeholder="email@example.com"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
autoFocus
onKeyDown={e => { if (e.key === 'Enter' && !sendingReport) sendEmailReport() }}
/>
<div className="mt-4 flex items-center justify-end gap-2">
<button
onClick={() => { setEmailDialogOpen(false); setReportEmail('') }}
disabled={sendingReport}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
Cancel
</button>
<button
onClick={sendEmailReport}
disabled={sendingReport || !reportEmail.trim()}
className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90 disabled:opacity-60 disabled:cursor-not-allowed"
>
{sendingReport ? 'Sending…' : 'Send Report'}
</button>
</div>
</div>
</div>
)}
</section>
</div>
</div>
</PageLayout>
)
}