profit-planet-frontend/src/app/profile/components/financeInvoices.tsx
seaznCode 7526e5c2e5 feat: enhance user detail modal with confirmation for document moves, add subscriptions navigation, and improve invoice status handling
- Added ConfirmActionModal to UserDetailModal for document move confirmation.
- Introduced My Subscriptions button in Header for easier navigation.
- Enhanced FinanceInvoices component to display normalized invoice statuses with badges.
- Created editAbo hook for managing subscription content updates.
- Updated getAbo hook to include more subscription statuses and improved mapping logic.
- Refactored ProfilePage to link to subscriptions page and removed unused state.
- Implemented ProfileSubscriptionsPage for managing subscriptions with detailed views and actions.
- Replaced custom modal in DeactivateReferralLinkModal with ConfirmActionModal for consistency.
2026-02-20 21:45:54 +01:00

228 lines
8.9 KiB
TypeScript

import React from 'react'
import { authFetch } from '../../utils/authFetch'
import { AboInvoice, useAboInvoices } from '../hooks/getAboInvoices'
type Props = {
abonementId?: string | number | null
}
const BASE_URL = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const formatDate = (value?: string | null) => {
if (!value) return '—'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString('de-DE')
}
const formatMoney = (value?: string | number | null, currency?: string | null) => {
if (value == null || value === '') return '—'
const n = typeof value === 'string' ? Number(value) : value
if (!Number.isFinite(Number(n))) return String(value)
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currency || 'EUR',
}).format(Number(n))
}
const isAbsUrl = (url: string) => /^https?:\/\//i.test(url)
const resolveInvoiceUrl = (invoice: AboInvoice) => {
const raw = invoice.pdfUrl || invoice.downloadUrl || invoice.htmlUrl || invoice.fileUrl
if (!raw) return null
return isAbsUrl(raw) ? raw : `${BASE_URL}${raw.startsWith('/') ? '' : '/'}${raw}`
}
type UiLifecycleStatus = 'issued' | 'ongoing' | 'finished' | 'pause' | 'cancelled'
const normalizeInvoiceStatus = (rawStatus?: string | null): UiLifecycleStatus => {
const status = (rawStatus || '').toLowerCase()
if (!status) return 'issued'
if (status === 'cancelled' || status === 'canceled' || status === 'void') return 'cancelled'
if (status === 'pause' || status === 'paused') return 'pause'
if (status === 'finished' || status === 'paid' || status === 'closed' || status === 'settled') return 'finished'
if (status === 'ongoing' || status === 'active' || status === 'processing') return 'ongoing'
if (status === 'issued' || status === 'draft' || status === 'pending') return 'issued'
return 'issued'
}
const statusBadgeClass = (status: UiLifecycleStatus) => {
if (status === 'ongoing') return 'bg-green-100 text-green-800'
if (status === 'pause') return 'bg-amber-100 text-amber-800'
if (status === 'cancelled') return 'bg-red-100 text-red-700'
if (status === 'finished') return 'bg-gray-200 text-gray-700'
return 'bg-blue-100 text-blue-800'
}
const displayStatus = (status: UiLifecycleStatus) =>
status === 'pause'
? 'Pause'
: status === 'cancelled'
? 'Cancelled'
: status === 'ongoing'
? 'Ongoing'
: status === 'finished'
? 'Finished'
: 'Issued'
function downloadBlob(content: Blob, fileName: string) {
const url = URL.createObjectURL(content)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
export default function FinanceInvoices({ abonementId }: Props) {
const { data: invoices, loading, error } = useAboInvoices(abonementId)
const [busyId, setBusyId] = React.useState<string | number | null>(null)
const [actionError, setActionError] = React.useState<string | null>(null)
const onView = (invoice: AboInvoice) => {
setActionError(null)
const url = resolveInvoiceUrl(invoice)
if (!url) {
setActionError('No view URL is available for this invoice.')
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
const onDownload = async (invoice: AboInvoice) => {
setActionError(null)
setBusyId(invoice.id)
try {
const url = resolveInvoiceUrl(invoice)
if (url) {
const res = await authFetch(url, { method: 'GET' })
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
const blob = await res.blob()
const invoiceNo = invoice.invoiceNumber || String(invoice.id)
const ext = invoice.pdfUrl ? 'pdf' : 'html'
downloadBlob(blob, `invoice-${invoiceNo}.${ext}`)
} else {
const blob = new Blob([JSON.stringify(invoice.raw, null, 2)], { type: 'application/json' })
downloadBlob(blob, `invoice-${invoice.invoiceNumber || invoice.id}.json`)
}
} catch (e: any) {
setActionError(e?.message || 'Failed to download invoice.')
} finally {
setBusyId(null)
}
}
const onExportAll = () => {
setActionError(null)
if (!invoices.length) {
setActionError('No invoices available to export.')
return
}
const exportPayload = {
exportedAt: new Date().toISOString(),
abonementId: abonementId ?? null,
count: invoices.length,
invoices: invoices.map((inv) => ({
id: inv.id,
invoiceNumber: inv.invoiceNumber,
issuedAt: inv.issuedAt,
createdAt: inv.createdAt,
totalNet: inv.totalNet,
totalTax: inv.totalTax,
totalGross: inv.totalGross,
currency: inv.currency,
status: inv.status,
})),
}
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], { type: 'application/json' })
downloadBlob(blob, `invoices-export-${new Date().toISOString().slice(0, 10)}.json`)
}
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-gray-900">Finance & Invoices</h2>
<button
onClick={onExportAll}
disabled={!invoices.length || loading}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
Export all invoices
</button>
</div>
{!abonementId ? (
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
No subscription selected. Invoices will appear once you have an active subscription.
</div>
) : loading ? (
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
Loading invoices
</div>
) : error ? (
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
{error}
</div>
) : invoices.length === 0 ? (
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
No invoices found for this subscription.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-white/60 bg-white/70 backdrop-blur-md shadow-lg">
<table className="min-w-full text-sm">
<thead className="bg-white/80">
<tr className="text-left text-gray-700">
<th className="px-4 py-3 font-semibold">Date</th>
<th className="px-4 py-3 font-semibold">Invoice #</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="px-4 py-3 font-semibold">Total</th>
<th className="px-4 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id} className="border-t border-gray-200/70">
<td className="px-4 py-3 text-gray-800">{formatDate(invoice.issuedAt || invoice.createdAt)}</td>
<td className="px-4 py-3 text-gray-800">{invoice.invoiceNumber || `#${invoice.id}`}</td>
<td className="px-4 py-3 text-gray-700">
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadgeClass(normalizeInvoiceStatus(invoice.status))}`}
>
{displayStatus(normalizeInvoiceStatus(invoice.status))}
</span>
</td>
<td className="px-4 py-3 text-gray-900 font-medium">{formatMoney(invoice.totalGross, invoice.currency)}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<button
onClick={() => onView(invoice)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
>
View
</button>
<button
onClick={() => onDownload(invoice)}
disabled={busyId === invoice.id}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
>
{busyId === invoice.id ? 'Downloading…' : 'Download'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{actionError && (
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-3 text-xs text-red-700">
{actionError}
</div>
)}
</section>
)
}