- 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.
228 lines
8.9 KiB
TypeScript
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>
|
|
)
|
|
}
|