feat: add financial manager with vat backend other still dummy

This commit is contained in:
DeathKaioken 2025-12-06 11:13:40 +01:00
parent bd737e48b8
commit aa5e3ed1c0
6 changed files with 579 additions and 0 deletions

View File

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { authFetch } from '../../../utils/authFetch'
import useAuthStore from '../../../store/authStore'
export type VatRate = {
country_code: string
country_name: string
standard_rate?: number | null
reduced_rate_1?: number | null
reduced_rate_2?: number | null
super_reduced_rate?: number | null
parking_rate?: number | null
effective_year?: number | null
}
export function useVatRates() {
const [rates, setRates] = useState<VatRate[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const reload = () => setRefreshKey(k => k + 1)
useEffect(() => {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/tax/vat-rates`
const token = useAuthStore.getState().accessToken
setLoading(true)
setError(null)
authFetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
})
.then(async (res) => {
const ct = res.headers.get('content-type') || ''
if (!res.ok || !ct.includes('application/json')) {
const txt = await res.text().catch(() => '')
throw new Error(`Request failed: ${res.status} ${txt.slice(0, 160)}`)
}
const json = await res.json()
const arr: VatRate[] = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []
setRates(arr)
})
.catch((e: any) => {
setError(e?.message || 'Failed to load VAT rates')
setRates([])
})
.finally(() => setLoading(false))
}, [refreshKey])
return { rates, loading, error, reload }
}

View File

@ -0,0 +1,219 @@
'use client'
import React, { useMemo, useState } from 'react'
import PageLayout from '../../components/PageLayout'
import { useRouter } from 'next/navigation'
import { useVatRates } from './hooks/getTaxes'
type VatRate = { country: string; code: string; rate: number }
type Bill = {
id: string
customer: string
amount: number
currency: string
date: string
status: 'paid' | 'open' | 'overdue'
}
const dummyBills: Bill[] = [
{ id: 'INV-1001', customer: 'Acme GmbH', amount: 1200, currency: 'EUR', date: '2025-12-01', status: 'paid' },
{ id: 'INV-1002', customer: 'Beta SARL', amount: 860, currency: 'EUR', date: '2025-11-20', status: 'open' },
{ id: 'INV-1003', customer: 'Charlie SpA', amount: 540, currency: 'EUR', date: '2025-11-15', status: 'overdue' },
{ id: 'INV-1004', customer: 'Delta BV', amount: 2300, currency: 'EUR', date: '2025-10-02', status: 'paid' },
]
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: '' })
const totals = useMemo(() => {
const now = new Date()
const filterDate = (d: string) => new Date(d)
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 // ytd or default
}
const filtered = dummyBills.filter(b => inRange(filterDate(b.date)))
const total = dummyBills.reduce((s, b) => s + b.amount, 0)
const totalRange = filtered.reduce((s, b) => s + b.amount, 0)
return { totalAll: total, totalRange }
}, [timeframe])
const filteredBills = useMemo(() => {
return dummyBills.filter(b => {
const matchesQuery =
billFilter.query === '' ||
b.id.toLowerCase().includes(billFilter.query.toLowerCase()) ||
b.customer.toLowerCase().includes(billFilter.query.toLowerCase())
const matchesStatus = billFilter.status === 'all' || b.status === billFilter.status
const fromOk = billFilter.from ? new Date(b.date) >= new Date(billFilter.from) : true
const toOk = billFilter.to ? new Date(b.date) <= new Date(billFilter.to) : true
return matchesQuery && matchesStatus && fromOk && toOk
})
}, [billFilter])
const exportBills = (format: 'csv' | 'pdf') => {
console.log('[export]', format, { filters: billFilter, bills: filteredBills })
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} bills`)
}
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>
</div>
</div>
<div className="grid gap-3 md:grid-cols-4 text-sm">
<input
placeholder="Search (ID, 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="paid">Paid</option>
<option value="open">Open</option>
<option value="overdue">Overdue</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">
<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">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">
{filteredBills.map(b => (
<tr key={b.id} className="border-b last:border-0">
<td className="px-3 py-2">{b.id}</td>
<td className="px-3 py-2">{b.customer}</td>
<td className="px-3 py-2">{new Date(b.date).toLocaleDateString()}</td>
<td className="px-3 py-2">{b.amount.toFixed(2)}</td>
<td className="px-3 py-2">
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
b.status === 'paid'
? 'bg-green-100 text-green-700'
: b.status === 'open'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}
>
{b.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>
))}
{filteredBills.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-gray-500">
Keine Rechnungen gefunden.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</div>
</div>
</PageLayout>
)
}

View File

@ -0,0 +1,94 @@
import { VatRate } from '../../hooks/getTaxes'
const toCsvValue = (v: unknown) => {
if (v === null || v === undefined) return '""'
const s = String(v).replace(/"/g, '""')
return `"${s}"`
}
const fmt = (v?: number | null) =>
v === null || v === undefined || Number.isNaN(Number(v)) ? 'NULL' : Number(v).toFixed(3)
// Header format: Country,"Super-Reduced Rate (%)","Reduced Rate (%)","Parking Rate (%)","Standard Rate (%)"
export function exportVatCsv(rates: VatRate[]) {
const headers = [
'Country',
'Super-Reduced Rate (%)',
'Reduced Rate (%)',
'Parking Rate (%)',
'Standard Rate (%)',
]
const rows = rates.map(r => [
r.country_name,
r.super_reduced_rate ?? '',
r.reduced_rate_1 ?? '',
r.parking_rate ?? '',
r.standard_rate ?? '',
].map(toCsvValue).join(','))
const csv = [headers.map(toCsvValue).join(','), ...rows].join('\r\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vat-rates_${new Date().toISOString().slice(0,10)}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
export function exportVatPdf(rates: VatRate[]) {
const lines = [
'VAT Rates',
`Generated: ${new Date().toLocaleString()}`,
'',
'Country | Super-Reduced | Reduced | Parking | Standard',
'-----------------------------------------------------',
...rates.map(r =>
`${r.country_name} (${r.country_code}) SR:${fmt(r.super_reduced_rate)} R:${fmt(r.reduced_rate_1)} P:${fmt(r.parking_rate)} Std:${fmt(r.standard_rate)}`
),
]
const textContent = lines.join('\n')
const pdfText = textContent.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
const contentStream = `BT /F1 10 Tf 50 780 Td (${pdfText.replace(/\n/g, ') Tj\n0 -14 Td (')}) Tj ET`
const encoder = new TextEncoder()
const streamBytes = encoder.encode(contentStream)
const len = streamBytes.length
const header = [
'%PDF-1.4',
'1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj',
'2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj',
'3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj',
`4 0 obj << /Length ${len} >> stream`,
].join('\n')
const footer = [
'endstream endobj',
'5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj',
'xref',
'0 6',
'0000000000 65535 f ',
'0000000010 00000 n ',
'0000000060 00000 n ',
'0000000115 00000 n ',
'0000000256 00000 n ',
'0000000400 00000 n ',
'trailer << /Size 6 /Root 1 0 R >>',
'startxref',
'480',
'%%EOF',
].join('\n')
const blob = new Blob([header, '\n', streamBytes, '\n', footer], { type: 'application/pdf' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vat-rates_${new Date().toISOString().slice(0,10)}.pdf`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}

View File

@ -0,0 +1,45 @@
import { authFetch } from '../../../../utils/authFetch'
import useAuthStore from '../../../../store/authStore'
export type ImportSummary = {
created?: number
updated?: number
skipped?: number
message?: string
}
export async function importVatCsv(file: File): Promise<{ ok: boolean; summary?: ImportSummary; message?: string }> {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/tax/vat-rates/import`
const form = new FormData()
form.append('file', file)
const token = useAuthStore.getState().accessToken
const user = useAuthStore.getState().user
const userId =
(user as any)?.id ??
(user as any)?._id ??
(user as any)?.userId ??
(user as any)?.uid
if (userId != null) {
form.append('userId', String(userId))
}
try {
const res = await authFetch(url, {
method: 'POST',
body: form,
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const ct = res.headers.get('content-type') || ''
if (!res.ok || !ct.includes('application/json')) {
const txt = await res.text().catch(() => '')
throw new Error(`Import failed: ${res.status} ${txt.slice(0, 160)}`)
}
const json = await res.json()
return { ok: true, summary: json?.data || json, message: json?.message }
} catch (e: any) {
return { ok: false, message: e?.message || 'Import failed' }
}
}

View File

@ -0,0 +1,158 @@
'use client'
import React, { useState } from 'react'
import PageLayout from '../../../components/PageLayout'
import { useRouter } from 'next/navigation'
import { useVatRates } from '../hooks/getTaxes'
import { importVatCsv } from './hooks/TaxImporter'
import { exportVatCsv, exportVatPdf } from './hooks/TaxExporter'
export default function VatEditPage() {
const router = useRouter()
const { rates, loading, error, reload } = useVatRates()
const [filter, setFilter] = useState('')
const [importResult, setImportResult] = useState<string | null>(null)
const [importing, setImporting] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const onImport = async (file?: File | null) => {
if (!file) return
setImportResult(null)
setImporting(true)
const res = await importVatCsv(file)
if (res.ok) {
setImportResult(res.summary ? JSON.stringify(res.summary) : res.message || 'Import successful')
await reload()
} else {
setImportResult(res.message || 'Import failed')
}
setImporting(false)
}
const filtered = rates.filter(v =>
v.country_name.toLowerCase().includes(filter.toLowerCase()) ||
v.country_code.toLowerCase().includes(filter.toLowerCase())
)
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
const pageData = filtered.slice((page - 1) * pageSize, page * pageSize)
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-6">
<div className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-blue-900">Edit VAT rates</h1>
<p className="text-sm text-blue-700">Import, export, and review (dummy data).</p>
</div>
<button
onClick={() => router.push('/admin/finance-management')}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-blue-900 hover:bg-gray-50"
>
Back
</button>
</div>
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 space-y-3">
<div className="flex flex-wrap gap-2 text-sm items-center">
<label className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50 cursor-pointer">
<input
type="file"
accept=".csv"
className="hidden"
onChange={e => onImport(e.target.files?.[0] || null)}
disabled={importing}
/>
{importing ? 'Importing...' : 'Import CSV'}
</label>
<button
onClick={() => exportVatCsv(rates)}
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
>
Export CSV
</button>
<button
onClick={() => exportVatPdf(rates)}
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
>
Export PDF
</button>
{importResult && <span className="text-xs text-blue-900 break-all">{importResult}</span>}
</div>
<div className="text-sm">
{error && <div className="mb-3 text-red-600">{error}</div>}
<input
value={filter}
onChange={e => { setFilter(e.target.value); setPage(1); }}
placeholder="Filter by country or code"
className="w-full rounded-lg border border-gray-200 px-3 py-2 mb-3 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
<div className="overflow-x-auto">
<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">Country</th>
<th className="px-3 py-2 font-semibold">Code</th>
<th className="px-3 py-2 font-semibold">Standard</th>
<th className="px-3 py-2 font-semibold">Reduced</th>
<th className="px-3 py-2 font-semibold">Super reduced</th>
<th className="px-3 py-2 font-semibold">Parking</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading && (
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">Loading VAT rates</td></tr>
)}
{!loading && pageData.map(v => (
<tr key={v.country_code} className="border-b last:border-0">
<td className="px-3 py-2">{v.country_name}</td>
<td className="px-3 py-2">{v.country_code}</td>
<td className="px-3 py-2">{v.standard_rate ?? '—'}</td>
<td className="px-3 py-2">{v.reduced_rate_1 ?? '—'}</td>
<td className="px-3 py-2">{v.super_reduced_rate ?? '—'}</td>
<td className="px-3 py-2">{v.parking_rate ?? '—'}</td>
</tr>
))}
{!loading && !error && pageData.length === 0 && (
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">No entries found.</td></tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mt-4 text-sm text-gray-700">
<div className="flex items-center gap-2">
<span>Rows per page:</span>
<select
value={pageSize}
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
className="rounded border border-gray-300 px-2 py-1 text-sm"
>
{[10, 20, 50, 100].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 rounded border border-gray-300 bg-white disabled:opacity-50"
>
Prev
</button>
<span>Page {page} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1 rounded border border-gray-300 bg-white disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</PageLayout>
)
}

View File

@ -495,6 +495,13 @@ export default function Header() {
>
Coffee Subscription Management
</button>
<button
onClick={() => { router.push('/admin/finance-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
role="menuitem"
>
Finance Management
</button>
<button
onClick={() => { router.push('/admin/pool-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"