Files
HartOMat/frontend/src/pages/Billing.tsx
T
Hartmut f5ca91ee02 feat: layout hamburger, media browser filters+previews, billing fixes
- Layout: mobile hamburger menu + overlay backdrop + close button; content area always full-width
- Media browser: filter chips (default still+turntable); advanced toggle for GLB/STL; thumbnail_url previews for non-image types; video hover-play for turntable
- Backend: asset_types multi-filter, thumbnail_url in MediaAssetOut, download proxy endpoint for MinIO/local files
- Admin: "Import Existing Media" button → POST /api/admin/import-media-assets
- Billing: fix invoice create 500 (MissingGreenlet — use selectinload after commit); PDF download uses axios blob instead of bare <a href> (auth header missing); fix storage.upload() accepting str|Path
- SSE task logs: task_logs.py core + router, LiveRenderLog component
- CadPreview: fix infinite loop when no gltf_geometry assets; loading screen before ThreeDViewer render
- render-worker: add trimesh layer to Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:09:27 +01:00

233 lines
10 KiB
TypeScript

import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Receipt, Download, Trash2, Plus, X } from 'lucide-react'
import { toast } from 'sonner'
import {
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
type Invoice, type InvoiceCreate,
} from '../api/billing'
// ── Helpers ───────────────────────────────────────────────────────────────
const formatCurrency = (amount: number | null, currency = 'EUR') => {
if (amount == null) return '—'
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(amount)
}
const formatDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleDateString('de-DE') : '—'
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700',
sent: 'bg-blue-100 text-blue-700',
paid: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
}
// ── New Invoice Modal ─────────────────────────────────────────────────────
function NewInvoiceModal({ onClose, onCreate }: { onClose: () => void; onCreate: (data: InvoiceCreate) => void }) {
const [notes, setNotes] = useState('')
const [issuedAt, setIssuedAt] = useState(new Date().toISOString().split('T')[0])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onCreate({ order_line_ids: [], notes: notes || undefined, issued_at: issuedAt || undefined })
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">New Invoice</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Issue Date</label>
<input
type="date"
value={issuedAt}
onChange={e => setIssuedAt(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={3}
placeholder="Optional notes..."
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-500 resize-none"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Cancel
</button>
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Create Invoice
</button>
</div>
</form>
</div>
</div>
)
}
// ── Page ──────────────────────────────────────────────────────────────────
export default function BillingPage() {
const qc = useQueryClient()
const [showModal, setShowModal] = useState(false)
const { data: invoices = [], isLoading } = useQuery({
queryKey: ['invoices'],
queryFn: () => getInvoices(),
})
const createMutation = useMutation({
mutationFn: createInvoice,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoices'] })
setShowModal(false)
toast.success('Invoice created')
},
onError: () => toast.error('Failed to create invoice'),
})
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => updateInvoiceStatus(id, status),
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
onError: () => toast.error('Failed to update status'),
})
const deleteMutation = useMutation({
mutationFn: deleteInvoice,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoices'] })
toast.success('Invoice deleted')
},
onError: () => toast.error('Failed to delete invoice'),
})
// KPI aggregates
const now = new Date()
const currentMonth = now.getMonth()
const currentYear = now.getFullYear()
const monthRevenue = invoices
.filter(inv => inv.status === 'paid' && inv.issued_at &&
new Date(inv.issued_at).getMonth() === currentMonth &&
new Date(inv.issued_at).getFullYear() === currentYear)
.reduce((sum, inv) => sum + (inv.total_net ?? 0), 0)
const openCount = invoices.filter(inv => inv.status === 'sent').length
const paidCount = invoices.filter(inv => inv.status === 'paid').length
return (
<div className="p-6 space-y-5 max-w-screen-xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-content flex items-center gap-2">
<Receipt size={20} />
Billing
</h1>
<p className="text-sm text-content-muted mt-0.5">Manage invoices and billing</p>
</div>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus size={16} />
New Invoice
</button>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-surface border border-border-default rounded-lg p-4">
<p className="text-sm text-content-muted">Revenue This Month</p>
<p className="text-2xl font-semibold text-content mt-1">{formatCurrency(monthRevenue)}</p>
</div>
<div className="bg-surface border border-border-default rounded-lg p-4">
<p className="text-sm text-content-muted">Open Invoices</p>
<p className="text-2xl font-semibold text-content mt-1">{openCount}</p>
</div>
<div className="bg-surface border border-border-default rounded-lg p-4">
<p className="text-sm text-content-muted">Paid Invoices</p>
<p className="text-2xl font-semibold text-content mt-1">{paidCount}</p>
</div>
</div>
{/* Invoice Table */}
<div className="bg-surface border border-border-default rounded-lg overflow-hidden">
<table className="w-full text-left">
<thead className="bg-surface-alt border-b border-border-default">
<tr>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Number</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Status</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Issued</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Due</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Net</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-content-muted">Loading...</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-content-muted">No invoices yet</td></tr>
) : invoices.map(inv => (
<tr key={inv.id} className="border-b border-border-default hover:bg-surface-hover transition-colors">
<td className="px-4 py-3 text-sm font-mono text-content">{inv.invoice_number}</td>
<td className="px-4 py-3">
<select
value={inv.status}
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'bg-gray-100 text-gray-700'}`}
>
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</td>
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.issued_at)}</td>
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.due_at)}</td>
<td className="px-4 py-3 text-sm text-content">{formatCurrency(inv.total_net, inv.currency)}</td>
<td className="px-4 py-3 flex items-center gap-1">
<button
onClick={() => downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))}
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="Download PDF"
>
<Download size={15} />
</button>
{inv.status === 'draft' && (
<button
onClick={() => {
if (confirm('Delete this draft invoice?')) deleteMutation.mutate(inv.id)
}}
className="p-1.5 rounded hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors"
title="Delete draft"
>
<Trash2 size={15} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<NewInvoiceModal
onClose={() => setShowModal(false)}
onCreate={data => createMutation.mutate(data)}
/>
)}
</div>
)
}