commit
13ee2c0f15
@ -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>
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user