From aa5e3ed1c0c6d446e04abbe4709ca009e7b7bffc Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Sat, 6 Dec 2025 11:13:40 +0100 Subject: [PATCH] feat: add financial manager with vat backend other still dummy --- .../finance-management/hooks/getTaxes.ts | 56 +++++ src/app/admin/finance-management/page.tsx | 219 ++++++++++++++++++ .../vat-edit/hooks/TaxExporter.ts | 94 ++++++++ .../vat-edit/hooks/TaxImporter.ts | 45 ++++ .../finance-management/vat-edit/page.tsx | 158 +++++++++++++ src/app/components/nav/Header.tsx | 7 + 6 files changed, 579 insertions(+) create mode 100644 src/app/admin/finance-management/hooks/getTaxes.ts create mode 100644 src/app/admin/finance-management/page.tsx create mode 100644 src/app/admin/finance-management/vat-edit/hooks/TaxExporter.ts create mode 100644 src/app/admin/finance-management/vat-edit/hooks/TaxImporter.ts create mode 100644 src/app/admin/finance-management/vat-edit/page.tsx diff --git a/src/app/admin/finance-management/hooks/getTaxes.ts b/src/app/admin/finance-management/hooks/getTaxes.ts new file mode 100644 index 0000000..64e6a8a --- /dev/null +++ b/src/app/admin/finance-management/hooks/getTaxes.ts @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 } +} diff --git a/src/app/admin/finance-management/page.tsx b/src/app/admin/finance-management/page.tsx new file mode 100644 index 0000000..97ccc1d --- /dev/null +++ b/src/app/admin/finance-management/page.tsx @@ -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 ( + +
+
+
+

Finance Management

+

Overview of taxes, revenue, and invoices.

+
+ + {/* Stats */} +
+
+
Total revenue (all time)
+
€{totals.totalAll.toFixed(2)}
+
+
+
Revenue (range)
+
€{totals.totalRange.toFixed(2)}
+
+
+
Invoices (range)
+
{filteredBills.length}
+
+
+
Timeframe
+ +
+
+ + {/* VAT summary */} +
+
+
+

Manage VAT rates

+

Live data from backend; edit on a separate page.

+
+ +
+
+ {vatLoading && 'Loading VAT rates...'} + {vatError && {vatError}} + {!vatLoading && !vatError && ( + <>Active countries: {rates.length} • Examples: {rates.slice(0, 5).map(r => r.country_code).join(', ')} + )} +
+
+ + {/* Bills list & filters */} +
+
+

Invoices

+
+ + +
+
+ +
+ 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" + /> + + 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" + /> + 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" + /> +
+ +
+ + + + + + + + + + + + + {filteredBills.map(b => ( + + + + + + + + + ))} + {filteredBills.length === 0 && ( + + + + )} + +
InvoiceCustomerDateAmountStatusActions
{b.id}{b.customer}{new Date(b.date).toLocaleDateString()}€{b.amount.toFixed(2)} + + {b.status} + + + + +
+ Keine Rechnungen gefunden. +
+
+
+
+
+
+ ) +} diff --git a/src/app/admin/finance-management/vat-edit/hooks/TaxExporter.ts b/src/app/admin/finance-management/vat-edit/hooks/TaxExporter.ts new file mode 100644 index 0000000..eb3fb68 --- /dev/null +++ b/src/app/admin/finance-management/vat-edit/hooks/TaxExporter.ts @@ -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) +} diff --git a/src/app/admin/finance-management/vat-edit/hooks/TaxImporter.ts b/src/app/admin/finance-management/vat-edit/hooks/TaxImporter.ts new file mode 100644 index 0000000..74d3582 --- /dev/null +++ b/src/app/admin/finance-management/vat-edit/hooks/TaxImporter.ts @@ -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' } + } +} diff --git a/src/app/admin/finance-management/vat-edit/page.tsx b/src/app/admin/finance-management/vat-edit/page.tsx new file mode 100644 index 0000000..bdba6ef --- /dev/null +++ b/src/app/admin/finance-management/vat-edit/page.tsx @@ -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(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 ( + +
+
+
+
+

Edit VAT rates

+

Import, export, and review (dummy data).

+
+ +
+ +
+
+ + + + {importResult && {importResult}} +
+ +
+ {error &&
{error}
} + { 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" + /> +
+ + + + + + + + + + + + + {loading && ( + + )} + {!loading && pageData.map(v => ( + + + + + + + + + ))} + {!loading && !error && pageData.length === 0 && ( + + )} + +
CountryCodeStandardReducedSuper reducedParking
Loading VAT rates…
{v.country_name}{v.country_code}{v.standard_rate ?? '—'}{v.reduced_rate_1 ?? '—'}{v.super_reduced_rate ?? '—'}{v.parking_rate ?? '—'}
No entries found.
+
+
+
+ Rows per page: + +
+
+ + Page {page} / {totalPages} + +
+
+
+
+
+
+
+ ) +} diff --git a/src/app/components/nav/Header.tsx b/src/app/components/nav/Header.tsx index 621b0d1..812c38e 100644 --- a/src/app/components/nav/Header.tsx +++ b/src/app/components/nav/Header.tsx @@ -495,6 +495,13 @@ export default function Header() { > Coffee Subscription Management +