f5ca91ee02
- 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>
233 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|