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>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Bell, RotateCcw } from 'lucide-react'
import { toast } from 'sonner'
import {
getNotificationConfigs,
updateNotificationConfig,
resetNotificationConfigs,
type NotificationConfig,
} from '../api/notifications'
const EVENT_LABELS: Record<string, string> = {
'order.submitted': 'Order submitted',
'order.completed': 'Order completed',
'render.completed': 'Render completed',
'render.failed': 'Render failed',
'excel.imported': 'Excel imported',
}
const ALL_EVENTS = Object.keys(EVENT_LABELS)
const CHANNELS: Array<{ key: 'in_app' | 'email'; label: string; comingSoon?: boolean }> = [
{ key: 'in_app', label: 'In-App' },
{ key: 'email', label: 'E-Mail', comingSoon: true },
]
function Toggle({
enabled,
disabled,
onChange,
title,
}: {
enabled: boolean
disabled?: boolean
onChange: (v: boolean) => void
title?: string
}) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={() => !disabled && onChange(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'
} ${enabled ? 'bg-blue-600' : 'bg-gray-200'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
)
}
export default function NotificationSettingsPage() {
const qc = useQueryClient()
const { data: configs = [], isLoading } = useQuery({
queryKey: ['notification-configs'],
queryFn: getNotificationConfigs,
})
const updateMutation = useMutation({
mutationFn: ({ eventType, channel, enabled }: { eventType: string; channel: string; enabled: boolean }) =>
updateNotificationConfig(eventType, channel, enabled),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-configs'] }),
onError: () => toast.error('Failed to update setting'),
})
const resetMutation = useMutation({
mutationFn: resetNotificationConfigs,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notification-configs'] })
toast.success('Settings reset to defaults')
},
onError: () => toast.error('Failed to reset settings'),
})
// Build lookup map: eventType+channel → enabled
const configMap = new Map<string, boolean>()
for (const c of configs) {
configMap.set(`${c.event_type}:${c.channel}`, c.enabled)
}
const isEnabled = (event: string, channel: string) =>
configMap.get(`${event}:${channel}`) ?? (channel === 'in_app')
return (
<div className="p-6 space-y-5 max-w-2xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-content flex items-center gap-2">
<Bell size={20} />
Notification Settings
</h1>
<p className="text-sm text-content-muted mt-0.5">
Configure which events trigger notifications for you
</p>
</div>
<button
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
className="flex items-center gap-2 text-sm px-3 py-2 border border-border-default rounded-lg hover:bg-surface-hover transition-colors text-content-secondary"
>
<RotateCcw size={14} />
Reset to defaults
</button>
</div>
{/* Matrix table */}
<div className="bg-surface border border-border-default rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-surface-alt border-b border-border-default">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-content-muted uppercase tracking-wide">
Event
</th>
{CHANNELS.map(ch => (
<th
key={ch.key}
className="px-4 py-3 text-center text-xs font-semibold text-content-muted uppercase tracking-wide w-32"
>
{ch.label}
{ch.comingSoon && (
<span className="ml-1 text-xs font-normal normal-case text-content-muted">(soon)</span>
)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border-default">
{isLoading ? (
<tr>
<td colSpan={CHANNELS.length + 1} className="px-4 py-8 text-center text-sm text-content-muted">
Loading...
</td>
</tr>
) : (
ALL_EVENTS.map(event => (
<tr key={event} className="hover:bg-surface-hover transition-colors">
<td className="px-4 py-3 text-sm text-content">
{EVENT_LABELS[event] || event}
</td>
{CHANNELS.map(ch => (
<td key={ch.key} className="px-4 py-3 text-center">
<Toggle
enabled={isEnabled(event, ch.key)}
disabled={ch.comingSoon || updateMutation.isPending}
onChange={enabled =>
updateMutation.mutate({ eventType: event, channel: ch.key, enabled })
}
title={ch.comingSoon ? 'Email notifications — coming soon' : undefined}
/>
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
<p className="text-xs text-content-muted">
In-App notifications appear in the bell icon in the sidebar.
Email notifications are currently deactivated.
</p>
</div>
)
}
+204 -2
View File
@@ -7,8 +7,8 @@ import {
Info, PackagePlus, PackageCheck, Ban, FileBox, ArrowRight, Copy,
} from 'lucide-react'
import { toast } from 'sonner'
import { uploadExcel, finalizeExcelImport } from '../api/uploads'
import type { ExcelPreviewResult, OutputTypeSelection } from '../api/uploads'
import { uploadExcel, finalizeExcelImport, getImportValidation } from '../api/uploads'
import type { ExcelPreviewResult, OutputTypeSelection, ImportValidation, ValidationIssue } from '../api/uploads'
import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
import api from '../api/client'
@@ -64,6 +64,27 @@ export default function UploadPage() {
},
})
const [validationId, setValidationId] = useState<string | null>(null)
const [showValidationDialog, setShowValidationDialog] = useState(false)
const { data: validationData } = useQuery({
queryKey: ['validation', validationId],
queryFn: () => getImportValidation(validationId!),
enabled: !!validationId && showValidationDialog,
refetchInterval: (query) => {
const d = query.state.data as ImportValidation | undefined
return d?.status === 'completed' || d?.status === 'failed' ? false : 2000
},
})
const saveAlias = useMutation({
mutationFn: async ({ alias, materialName }: { alias: string; materialName: string }) => {
await api.post('/materials/seed-aliases', { mappings: [{ display_name: alias, render_name: materialName }] })
},
onSuccess: () => toast.success('Alias saved'),
onError: () => toast.error('Failed to save alias'),
})
const uploadMut = useMutation({
mutationFn: uploadExcel,
onSuccess: (data) => {
@@ -80,6 +101,10 @@ export default function UploadPage() {
setIncludedRows(inc)
setRowOutputTypes(rot)
data.warnings.forEach((w) => toast.warning(w))
if (data.validation_id) {
setValidationId(data.validation_id)
setShowValidationDialog(true)
}
setStep(2)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'),
@@ -496,6 +521,15 @@ export default function UploadPage() {
</div>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── Step 3: Output Type Selection ───────────────────────────────── */}
{step === 3 && previewResult && (
<div className="space-y-4">
@@ -653,3 +687,171 @@ export default function UploadPage() {
</div>
)
}
// ── ValidationDialog ──────────────────────────────────────────────────────
function ValidationDialog({
validation,
onClose,
onSaveAlias,
}: {
validation: ImportValidation | undefined
onClose: () => void
onSaveAlias: (alias: string, suggestion: string) => void
}) {
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set())
const summary = validation?.summary
const isLoading =
!validation || validation.status === 'pending' || validation.status === 'running'
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-2xl max-h-[80vh] flex flex-col">
{/* Header */}
<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">Import Validation</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
>
×
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{isLoading ? (
<div className="flex items-center gap-3 text-gray-500">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span>Validating import...</span>
</div>
) : (
<>
{/* Traffic light summary */}
{summary && (
<div className="flex gap-4">
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-700">{summary.ok} OK</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-yellow-50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-sm font-medium text-yellow-700">
{summary.warnings} Warnings
</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-red-50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-sm font-medium text-red-700">
{summary.errors} Errors
</span>
</div>
</div>
)}
{/* Rows with issues */}
{validation.rows &&
validation.rows
.filter((r) => r.issues.length > 0)
.map((row) => (
<div
key={row.row_index}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<button
className={`w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition-colors ${
row.status === 'error'
? 'bg-red-50'
: row.status === 'warning'
? 'bg-yellow-50'
: 'bg-white'
}`}
onClick={() =>
setExpandedRows((prev) => {
const next = new Set(prev)
next.has(row.row_index)
? next.delete(row.row_index)
: next.add(row.row_index)
return next
})
}
>
<div className="flex items-center gap-3">
<div
className={`w-2.5 h-2.5 rounded-full ${
row.status === 'error'
? 'bg-red-500'
: row.status === 'warning'
? 'bg-yellow-500'
: 'bg-green-500'
}`}
/>
<span className="text-sm font-medium text-gray-700">
Row {row.row_index + 1}
{row.pim_id ? ` — ${row.pim_id}` : ''}
{row.produkt_baureihe ? ` (${row.produkt_baureihe})` : ''}
</span>
<span className="text-xs text-gray-400">
{row.issues.length} issue{row.issues.length !== 1 ? 's' : ''}
</span>
</div>
<span className="text-gray-400 text-xs">
{expandedRows.has(row.row_index) ? '' : ''}
</span>
</button>
{expandedRows.has(row.row_index) && (
<div className="border-t border-gray-100 divide-y divide-gray-50">
{row.issues.map((issue: ValidationIssue, i: number) => (
<div
key={i}
className="px-4 py-3 flex items-start justify-between gap-4"
>
<div>
<p className="text-sm text-gray-700">{issue.message}</p>
{issue.suggestion && (
<p className="text-xs text-gray-500 mt-0.5">
Suggestion: <em>{issue.suggestion}</em>
</p>
)}
</div>
{issue.type === 'material_suggestion' &&
issue.value &&
issue.suggestion && (
<button
onClick={() =>
onSaveAlias(issue.value!, issue.suggestion!)
}
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 whitespace-nowrap"
>
Save as alias
</button>
)}
</div>
))}
</div>
)}
</div>
))}
{validation.rows &&
validation.rows.filter((r) => r.issues.length > 0).length === 0 && (
<p className="text-sm text-green-600 font-medium">
All rows validated successfully.
</p>
)}
</>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
>
Close
</button>
</div>
</div>
</div>
)
}