Merge pull request 'bigTypeShii' (#20) from bigTypeShii into dev

Reviewed-on: #20
This commit is contained in:
Seazn 2026-04-07 15:05:51 +00:00
commit 152f01f0b0
2 changed files with 136 additions and 5 deletions

View File

@ -18,6 +18,10 @@ export default function FinanceManagementPage() {
const [diagData, setDiagData] = useState<any | null>(null) const [diagData, setDiagData] = useState<any | null>(null)
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null) const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
const [detailModalOpen, setDetailModalOpen] = useState(false) const [detailModalOpen, setDetailModalOpen] = useState(false)
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
const [reportEmail, setReportEmail] = useState('')
const [sendingReport, setSendingReport] = useState(false)
const [reportMsg, setReportMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// NEW: fetch invoices from backend // NEW: fetch invoices from backend
const { const {
@ -116,6 +120,66 @@ export default function FinanceManagementPage() {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
const [pdfLoading, setPdfLoading] = useState<string | number | null>(null)
const viewInvoicePdf = async (inv: AdminInvoice) => {
setPdfLoading(inv.id)
try {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
const res = await fetch(`${base}/api/invoices/${inv.id}/pdf`, {
method: 'GET',
credentials: 'include',
headers: {
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body?.message || `Failed to load PDF (${res.status})`)
}
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
window.open(blobUrl, '_blank', 'noopener,noreferrer')
} catch (e: any) {
setReportMsg({ type: 'error', text: e?.message || 'Failed to load invoice PDF.' })
} finally {
setPdfLoading(null)
}
}
const sendEmailReport = async () => {
if (!reportEmail.trim()) return
setReportMsg(null)
setSendingReport(true)
try {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
const res = await fetch(`${base}/api/admin/invoices/email-report`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({
email: reportEmail.trim(),
from: billFilter.from || undefined,
to: billFilter.to || undefined,
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok || body?.success === false) {
throw new Error(body?.message || `Request failed (${res.status})`)
}
setReportMsg({ type: 'success', text: `Report sent to ${reportEmail.trim()} (${body.data?.sentCount ?? 0} paid invoice(s)).` })
setEmailDialogOpen(false)
setReportEmail('')
} catch (e: any) {
setReportMsg({ type: 'error', text: e?.message || 'Failed to send email report.' })
} finally {
setSendingReport(false)
}
}
return ( return (
<PageLayout> <PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen"> <div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
@ -182,6 +246,7 @@ export default function FinanceManagementPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <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> <h2 className="text-lg font-semibold text-[#1C2B4A]">Invoices</h2>
<div className="flex flex-wrap gap-2 text-sm"> <div className="flex flex-wrap gap-2 text-sm">
<button onClick={() => { setReportMsg(null); setEmailDialogOpen(true) }} className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-blue-900 font-medium hover:bg-blue-100">Send Email Report</button>
<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('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> <button onClick={() => exportBills('pdf')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export PDF</button>
<button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button> <button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button>
@ -222,6 +287,11 @@ export default function FinanceManagementPage() {
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{reportMsg && (
<div className={`rounded-md border px-3 py-2 text-sm mb-3 ${reportMsg.type === 'success' ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
{reportMsg.text}
</div>
)}
{invError && ( {invError && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3"> <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3">
{invError} {invError}
@ -342,11 +412,18 @@ export default function FinanceManagementPage() {
</span> </span>
</td> </td>
<td className="px-3 py-2 space-x-2"> <td className="px-3 py-2 space-x-2">
<button
onClick={() => viewInvoicePdf(inv)}
disabled={pdfLoading === inv.id || !inv.pdf_storage_key}
className="text-xs rounded border px-2 py-1 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{pdfLoading === inv.id ? 'Loading…' : 'View PDF'}
</button>
<button <button
onClick={() => { setSelectedInvoice(inv); setDetailModalOpen(true) }} onClick={() => { setSelectedInvoice(inv); setDetailModalOpen(true) }}
className="text-xs rounded border px-2 py-1 hover:bg-gray-50" className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
> >
View Details
</button> </button>
</td> </td>
</tr> </tr>
@ -366,6 +443,47 @@ export default function FinanceManagementPage() {
onExport={(inv) => exportInvoice(inv)} onExport={(inv) => exportInvoice(inv)}
/> />
)} )}
{/* Email Report Dialog */}
{emailDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<h3 className="text-lg font-semibold text-[#1C2B4A] mb-1">Send Email Report</h3>
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">
Only <strong>paid</strong> invoices will be included in the report, regardless of the status filter.
{(billFilter.from || billFilter.to) && (
<span> The current date range filter ({billFilter.from || '…'} {billFilter.to || '…'}) will be applied.</span>
)}
</div>
<label className="block text-sm font-medium text-gray-700 mb-1">Recipient Email</label>
<input
type="email"
value={reportEmail}
onChange={e => setReportEmail(e.target.value)}
placeholder="email@example.com"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
autoFocus
onKeyDown={e => { if (e.key === 'Enter' && !sendingReport) sendEmailReport() }}
/>
<div className="mt-4 flex items-center justify-end gap-2">
<button
onClick={() => { setEmailDialogOpen(false); setReportEmail('') }}
disabled={sendingReport}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
Cancel
</button>
<button
onClick={sendEmailReport}
disabled={sendingReport || !reportEmail.trim()}
className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90 disabled:opacity-60 disabled:cursor-not-allowed"
>
{sendingReport ? 'Sending…' : 'Send Report'}
</button>
</div>
</div>
</div>
)}
</section> </section>
</div> </div>
</div> </div>

View File

@ -28,8 +28,10 @@ const isAbsUrl = (url: string) => /^https?:\/\//i.test(url)
const resolveInvoiceUrl = (invoice: AboInvoice) => { const resolveInvoiceUrl = (invoice: AboInvoice) => {
const raw = invoice.pdfUrl || invoice.downloadUrl || invoice.htmlUrl || invoice.fileUrl const raw = invoice.pdfUrl || invoice.downloadUrl || invoice.htmlUrl || invoice.fileUrl
if (!raw) return null if (raw) return isAbsUrl(raw) ? raw : `${BASE_URL}${raw.startsWith('/') ? '' : '/'}${raw}`
return isAbsUrl(raw) ? raw : `${BASE_URL}${raw.startsWith('/') ? '' : '/'}${raw}` // Fallback: use the backend PDF proxy endpoint if an id is available
if (invoice.id) return `${BASE_URL}/api/invoices/${invoice.id}/pdf`
return null
} }
type UiLifecycleStatus = 'issued' | 'ongoing' | 'finished' | 'pause' | 'cancelled' type UiLifecycleStatus = 'issued' | 'ongoing' | 'finished' | 'pause' | 'cancelled'
@ -80,14 +82,25 @@ export default function FinanceInvoices({ abonementId }: Props) {
const [busyId, setBusyId] = React.useState<string | number | null>(null) const [busyId, setBusyId] = React.useState<string | number | null>(null)
const [actionError, setActionError] = React.useState<string | null>(null) const [actionError, setActionError] = React.useState<string | null>(null)
const onView = (invoice: AboInvoice) => { const onView = async (invoice: AboInvoice) => {
setActionError(null) setActionError(null)
const url = resolveInvoiceUrl(invoice) const url = resolveInvoiceUrl(invoice)
if (!url) { if (!url) {
setActionError('No view URL is available for this invoice.') setActionError('No view URL is available for this invoice.')
return return
} }
window.open(url, '_blank', 'noopener,noreferrer') setBusyId(invoice.id)
try {
const res = await authFetch(url, { method: 'GET' })
if (!res.ok) throw new Error(`Failed to load PDF: ${res.status}`)
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
window.open(blobUrl, '_blank', 'noopener,noreferrer')
} catch (e: any) {
setActionError(e?.message || 'Failed to load invoice PDF.')
} finally {
setBusyId(null)
}
} }
const onDownload = async (invoice: AboInvoice) => { const onDownload = async (invoice: AboInvoice) => {