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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user