feat(F-G-H-I): STL cache, invoices, import validation, notification settings
Phase F — STL Hash Cache:
- Migration 041: step_file_hash column on cad_files
- cache_service.py: SHA256 hash + MinIO-backed STL cache (check/store)
- render_step_thumbnail: compute+persist hash before render
- generate_stl_cache: check MinIO cache before cadquery conversion, store after
Phase G — Invoices:
- Migration 042: invoices + invoice_lines tables with RLS
- Invoice/InvoiceLine models + schemas
- billing service: generate_invoice_number (INV-YYYY-NNNN), create/list/get/delete/PDF
- WeasyPrint PDF generation; backend Dockerfile + pyproject.toml deps
- invoice_router with 6 endpoints; registered in main.py
- frontend: Billing.tsx page + api/billing.ts; route + nav link
Phase H — Import Sanity Check:
- Migration 043: import_validations table
- ImportValidation model + schemas
- run_sanity_check: material fuzzy-match (cutoff=0.8), STEP availability, duplicate detection
- validate_excel_import Celery task (queue: step_processing)
- uploads.py: create ImportValidation on /excel, fire task, expose GET /validations/{id}
- frontend: Upload.tsx polling ValidationDialog with Ampel status indicators
Phase I — Notification Settings:
- Migration 044: notification_configs table (user×event×channel toggles)
- NotificationConfig model + seeds (in_app=true, email=false)
- get/upsert/reset config endpoints on /notifications/config
- frontend: NotificationSettings.tsx page + api/notifications.ts extensions
Infrastructure:
- docker-compose.yml: add worker-thumbnail service (concurrency=1, Q=thumbnail_rendering)
- Fix Dockerfile: libgdk-pixbuf-2.0-0 (correct Debian bookworm package name)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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, getInvoicePdfUrl,
|
||||
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">
|
||||
<a
|
||||
href={getInvoicePdfUrl(inv.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="Download PDF"
|
||||
>
|
||||
<Download size={15} />
|
||||
</a>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user