feat: add financial manager with vat backend other still dummy
This commit is contained in:
parent
bd737e48b8
commit
aa5e3ed1c0
56
src/app/admin/finance-management/hooks/getTaxes.ts
Normal file
56
src/app/admin/finance-management/hooks/getTaxes.ts
Normal 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 }
|
||||
}
|
||||
219
src/app/admin/finance-management/page.tsx
Normal file
219
src/app/admin/finance-management/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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' }
|
||||
}
|
||||
}
|
||||
158
src/app/admin/finance-management/vat-edit/page.tsx
Normal file
158
src/app/admin/finance-management/vat-edit/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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]"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user