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:
2026-03-06 18:05:01 +01:00
parent 7706c514c8
commit f19a6ccde8
34 changed files with 1940 additions and 14 deletions
+234
View File
@@ -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>
)
}