From 20c39fcd4ea26bd032d3452c501507f55b0e7126 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Mon, 15 Dec 2025 16:59:16 +0100 Subject: [PATCH] feat: invoice --- .../finance-management/hooks/getInvoices.ts | 93 ++++++++ src/app/admin/finance-management/page.tsx | 161 +++++++------ src/app/affiliate-links/page.tsx | 217 +++++++++--------- 3 files changed, 296 insertions(+), 175 deletions(-) create mode 100644 src/app/admin/finance-management/hooks/getInvoices.ts diff --git a/src/app/admin/finance-management/hooks/getInvoices.ts b/src/app/admin/finance-management/hooks/getInvoices.ts new file mode 100644 index 0000000..ccdc95a --- /dev/null +++ b/src/app/admin/finance-management/hooks/getInvoices.ts @@ -0,0 +1,93 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import useAuthStore from '../../../store/authStore'; + +export type AdminInvoice = { + id: string | number; + invoice_number?: string | null; + user_id?: string | number | null; + buyer_name?: string | null; + buyer_email?: string | null; + buyer_street?: string | null; + buyer_postal_code?: string | null; + buyer_city?: string | null; + buyer_country?: string | null; + currency?: string | null; + total_net?: number | null; + total_tax?: number | null; + total_gross?: number | null; + vat_rate?: number | null; + status?: string; + issued_at?: string | null; + due_at?: string | null; + pdf_storage_key?: string | null; + context?: any | null; + created_at?: string | null; + updated_at?: string | null; +}; + +export function useAdminInvoices(params?: { status?: string; limit?: number; offset?: number }) { + const accessToken = useAuthStore(s => s.accessToken); + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const inFlight = useRef(null); + + const fetchInvoices = useCallback(async () => { + setError(''); + // Abort previous + inFlight.current?.abort(); + const controller = new AbortController(); + inFlight.current = controller; + + try { + const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''; + const qp = new URLSearchParams(); + if (params?.status) qp.set('status', params.status); + qp.set('limit', String(params?.limit ?? 200)); + qp.set('offset', String(params?.offset ?? 0)); + const url = `${base}/api/admin/invoices${qp.toString() ? `?${qp.toString()}` : ''}`; + + setLoading(true); + const res = await fetch(url, { + method: 'GET', + credentials: 'include', + headers: { + 'Accept': 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + signal: controller.signal, + }); + + const body = await res.json().catch(() => ({})); + if (!res.ok || body?.success === false) { + setInvoices([]); + setError(body?.message || `Failed to load invoices (${res.status})`); + return; + } + const list: AdminInvoice[] = Array.isArray(body?.data) ? body.data : []; + // sort fallback (issued_at DESC then created_at DESC) + list.sort((a, b) => { + const ad = new Date(a.issued_at ?? a.created_at ?? 0).getTime(); + const bd = new Date(b.issued_at ?? b.created_at ?? 0).getTime(); + return bd - ad; + }); + setInvoices(list); + } catch (e: any) { + if (e?.name === 'AbortError') return; + setError(e?.message || 'Network error'); + setInvoices([]); + } finally { + setLoading(false); + if (inFlight.current === controller) inFlight.current = null; + } + }, [accessToken, params?.status, params?.limit, params?.offset]); + + useEffect(() => { + if (accessToken) fetchInvoices(); + return () => inFlight.current?.abort(); + }, [accessToken, fetchInvoices]); + + return { invoices, loading, error, reload: fetchInvoices }; +} diff --git a/src/app/admin/finance-management/page.tsx b/src/app/admin/finance-management/page.tsx index 97ccc1d..2d67869 100644 --- a/src/app/admin/finance-management/page.tsx +++ b/src/app/admin/finance-management/page.tsx @@ -3,23 +3,7 @@ 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' }, -] +import { useAdminInvoices } from './hooks/getInvoices' export default function FinanceManagementPage() { const router = useRouter() @@ -27,38 +11,60 @@ export default function FinanceManagementPage() { 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 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 + return true } - 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 + 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) }) - }, [billFilter]) + 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, bills: filteredBills }) - alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} bills`) + console.log('[export]', format, { filters: billFilter, invoices: filteredBills }) + alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`) } return ( @@ -129,12 +135,13 @@ export default function FinanceManagementPage() {
+
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" @@ -145,9 +152,11 @@ export default function FinanceManagementPage() { className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent" > + + - +
+ {invError && ( +
+ {invError} +
+ )} - + - {filteredBills.map(b => ( - - - - - - - - - ))} - {filteredBills.length === 0 && ( + {invLoading ? ( + <> + + + + ) : filteredBills.length === 0 ? ( + ) : ( + filteredBills.map(inv => ( + + + + + + + + + )) )}
Invoice CustomerDateIssued Amount Status Actions
{b.id}{b.customer}{new Date(b.date).toLocaleDateString()}€{b.amount.toFixed(2)} - - {b.status} - - - - -
Keine Rechnungen gefunden.
{inv.invoice_number ?? inv.id}{inv.buyer_name ?? '—'}{inv.issued_at ? new Date(inv.issued_at).toLocaleDateString() : '—'} + €{Number(inv.total_gross ?? 0).toFixed(2)}{' '} + {inv.currency ?? 'EUR'} + + + {inv.status} + + + + +
diff --git a/src/app/affiliate-links/page.tsx b/src/app/affiliate-links/page.tsx index 79a6c47..6887837 100644 --- a/src/app/affiliate-links/page.tsx +++ b/src/app/affiliate-links/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import PageLayout from '../components/PageLayout' type Affiliate = { @@ -20,6 +20,8 @@ export default function AffiliateLinksPage() { const [affiliates, setAffiliates] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + // NEW: selected category + const [selectedCategory, setSelectedCategory] = useState('all') useEffect(() => { async function fetchAffiliates() { @@ -63,130 +65,129 @@ export default function AffiliateLinksPage() { category: { title: affiliate.category, href: '#' }, commissionRate: affiliate.commissionRate })) + + // NEW: fixed categories from the provided image, merged with backend ones + const categories = useMemo(() => { + const fromImage = [ + 'Technology', + 'Energy', + 'Finance', + 'Healthcare', + 'Education', + 'Travel', + 'Retail', + 'Construction', + 'Food', + 'Automotive', + 'Fashion', + 'Pets', + ] + const set = new Set(fromImage) + affiliates.forEach(a => { if (a.category) set.add(a.category) }) + return ['all', ...Array.from(set)] + }, [affiliates]) + return ( -
- {/* Background Pattern */} - - - {/* Colored Blur Effect */} -