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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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