dev #21
@ -121,6 +121,16 @@ export default function FinanceManagementPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [pdfLoading, setPdfLoading] = useState<string | number | null>(null)
|
const [pdfLoading, setPdfLoading] = useState<string | number | null>(null)
|
||||||
|
const [uploadModalOpen, setUploadModalOpen] = useState(false)
|
||||||
|
const [uploadForm, setUploadForm] = useState({
|
||||||
|
buyer_name: '', buyer_email: '', buyer_street: '', buyer_postal_code: '',
|
||||||
|
buyer_city: '', buyer_country: '', currency: 'EUR',
|
||||||
|
total_gross: '', vat_rate: '20',
|
||||||
|
status: 'issued', issued_at: '', due_at: '',
|
||||||
|
})
|
||||||
|
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
|
||||||
const viewInvoicePdf = async (inv: AdminInvoice) => {
|
const viewInvoicePdf = async (inv: AdminInvoice) => {
|
||||||
setPdfLoading(inv.id)
|
setPdfLoading(inv.id)
|
||||||
@ -147,6 +157,52 @@ export default function FinanceManagementPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submitUploadInvoice = async () => {
|
||||||
|
if (!uploadForm.total_gross || isNaN(Number(uploadForm.total_gross))) {
|
||||||
|
setUploadError('Total gross (Bruttobetrag) is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploadError(null)
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
|
const gross = parseFloat(uploadForm.total_gross)
|
||||||
|
const rate = parseFloat(uploadForm.vat_rate) || 0
|
||||||
|
const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross
|
||||||
|
const tax = +(gross - net).toFixed(2)
|
||||||
|
const fd = new FormData()
|
||||||
|
const { total_gross, vat_rate, ...rest } = uploadForm
|
||||||
|
Object.entries(rest).forEach(([k, v]) => { if (v !== '') fd.append(k, v) })
|
||||||
|
fd.append('total_gross', gross.toFixed(2))
|
||||||
|
fd.append('total_net', net.toFixed(2))
|
||||||
|
fd.append('total_tax', tax.toFixed(2))
|
||||||
|
fd.append('vat_rate', String(rate))
|
||||||
|
if (uploadFile) fd.append('pdf', uploadFile)
|
||||||
|
const res = await fetch(`${base}/api/admin/invoices`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok || body?.success === false) throw new Error(body?.message || `Upload failed (${res.status})`)
|
||||||
|
setUploadModalOpen(false)
|
||||||
|
setUploadForm({
|
||||||
|
buyer_name: '', buyer_email: '', buyer_street: '', buyer_postal_code: '',
|
||||||
|
buyer_city: '', buyer_country: '', currency: 'EUR',
|
||||||
|
total_gross: '', vat_rate: '20',
|
||||||
|
status: 'issued', issued_at: '', due_at: '',
|
||||||
|
})
|
||||||
|
setUploadFile(null)
|
||||||
|
reload()
|
||||||
|
setReportMsg({ type: 'success', text: `Invoice ${body.data?.invoice_number ?? ''} created successfully.` })
|
||||||
|
} catch (e: any) {
|
||||||
|
setUploadError(e?.message || 'Upload failed.')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sendEmailReport = async () => {
|
const sendEmailReport = async () => {
|
||||||
if (!reportEmail.trim()) return
|
if (!reportEmail.trim()) return
|
||||||
setReportMsg(null)
|
setReportMsg(null)
|
||||||
@ -246,6 +302,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={() => { setUploadError(null); setUploadModalOpen(true) }} className="rounded-lg bg-[#1C2B4A] px-3 py-2 text-white font-medium hover:bg-[#1C2B4A]/90">Upload Invoice</button>
|
||||||
<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={() => { 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>
|
||||||
@ -444,6 +501,110 @@ export default function FinanceManagementPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Upload Invoice Modal */}
|
||||||
|
{uploadModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl overflow-y-auto max-h-[90vh]">
|
||||||
|
<h3 className="text-lg font-semibold text-[#1C2B4A] mb-4">Upload Invoice</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Customer Name</label>
|
||||||
|
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_name} onChange={e => setUploadForm(f => ({ ...f, buyer_name: e.target.value }))} placeholder="Max Mustermann" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Customer Email</label>
|
||||||
|
<input type="email" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_email} onChange={e => setUploadForm(f => ({ ...f, buyer_email: e.target.value }))} placeholder="kunde@example.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Street</label>
|
||||||
|
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_street} onChange={e => setUploadForm(f => ({ ...f, buyer_street: e.target.value }))} placeholder="Musterstraße 1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Postal Code</label>
|
||||||
|
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_postal_code} onChange={e => setUploadForm(f => ({ ...f, buyer_postal_code: e.target.value }))} placeholder="8010" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">City</label>
|
||||||
|
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_city} onChange={e => setUploadForm(f => ({ ...f, buyer_city: e.target.value }))} placeholder="Graz" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Country</label>
|
||||||
|
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_country} onChange={e => setUploadForm(f => ({ ...f, buyer_country: e.target.value }))} placeholder="Austria" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Total Gross / Brutto <span className="text-red-500">*</span></label>
|
||||||
|
<input type="number" step="0.01" min="0" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.total_gross} onChange={e => setUploadForm(f => ({ ...f, total_gross: e.target.value }))} placeholder="0.00" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">MwSt. / VAT Rate (%)</label>
|
||||||
|
<input type="number" step="0.01" min="0" max="100" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.vat_rate} onChange={e => setUploadForm(f => ({ ...f, vat_rate: e.target.value }))} placeholder="20" />
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const gross = parseFloat(uploadForm.total_gross) || 0
|
||||||
|
const rate = parseFloat(uploadForm.vat_rate) || 0
|
||||||
|
const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross
|
||||||
|
const tax = +(gross - net).toFixed(2)
|
||||||
|
return (
|
||||||
|
<div className="sm:col-span-2 grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-gray-50 border border-gray-100 px-3 py-2">
|
||||||
|
<div className="text-xs text-gray-500 mb-0.5">Netto (calculated)</div>
|
||||||
|
<div className="font-semibold text-gray-800">{uploadForm.currency} {net.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-gray-50 border border-gray-100 px-3 py-2">
|
||||||
|
<div className="text-xs text-gray-500 mb-0.5">MwSt. (calculated)</div>
|
||||||
|
<div className="font-semibold text-gray-800">{uploadForm.currency} {tax.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Currency</label>
|
||||||
|
<select className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.currency} onChange={e => setUploadForm(f => ({ ...f, currency: e.target.value }))}>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
<option value="CHF">CHF</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<select className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.status} onChange={e => setUploadForm(f => ({ ...f, status: e.target.value }))}>
|
||||||
|
<option value="issued">Issued</option>
|
||||||
|
<option value="paid">Paid</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="overdue">Overdue</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Issue Date</label>
|
||||||
|
<input type="date" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.issued_at} onChange={e => setUploadForm(f => ({ ...f, issued_at: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Due Date</label>
|
||||||
|
<input type="date" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.due_at} onChange={e => setUploadForm(f => ({ ...f, due_at: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">PDF File</label>
|
||||||
|
<input
|
||||||
|
type="file" accept="application/pdf"
|
||||||
|
className="w-full text-sm text-gray-700 file:mr-3 file:rounded-lg file:border-0 file:bg-blue-50 file:px-3 file:py-2 file:text-blue-900 file:font-medium hover:file:bg-blue-100"
|
||||||
|
onChange={e => setUploadFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{uploadFile && <p className="mt-1 text-xs text-gray-500">{uploadFile.name}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{uploadError && (
|
||||||
|
<div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{uploadError}</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
<button onClick={() => { setUploadModalOpen(false); setUploadError(null) }} disabled={uploading} 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={submitUploadInvoice} disabled={uploading} 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">
|
||||||
|
{uploading ? 'Uploading…' : 'Create Invoice'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Email Report Dialog */}
|
{/* Email Report Dialog */}
|
||||||
{emailDialogOpen && (
|
{emailDialogOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user