diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx
new file mode 100644
index 0000000..5afa55f
--- /dev/null
+++ b/frontend/src/components/ConfirmModal.tsx
@@ -0,0 +1,118 @@
+import { useEffect, useRef } from 'react'
+import { AlertTriangle } from 'lucide-react'
+
+interface ConfirmModalProps {
+ open: boolean
+ title: string
+ message: string
+ confirmLabel?: string
+ confirmVariant?: 'danger' | 'primary'
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+export default function ConfirmModal({
+ open,
+ title,
+ message,
+ confirmLabel = 'Delete',
+ confirmVariant = 'danger',
+ onConfirm,
+ onCancel,
+}: ConfirmModalProps) {
+ const cancelRef = useRef(null)
+ const dialogRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ // Focus cancel button when modal opens
+ cancelRef.current?.focus()
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onCancel()
+ return
+ }
+ // Trap focus inside the modal
+ if (e.key === 'Tab' && dialogRef.current) {
+ const focusable = dialogRef.current.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ )
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault()
+ last?.focus()
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault()
+ first?.focus()
+ }
+ }
+ }
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [open, onCancel])
+
+ if (!open) return null
+
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {message}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx
index f5e1231..0d28f26 100644
--- a/frontend/src/pages/Admin.tsx
+++ b/frontend/src/pages/Admin.tsx
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard } from 'lucide-react'
import { Link } from 'react-router-dom'
import api from '../api/client'
+import ConfirmModal from '../components/ConfirmModal'
import TemplateEditor from '../components/admin/TemplateEditor'
import PricingTierTable from '../components/admin/PricingTierTable'
import OutputTypeTable from '../components/admin/OutputTypeTable'
@@ -191,6 +192,7 @@ export default function AdminPage() {
const [smtpDraft, setSmtpDraft] = useState>({})
const smtp = { ...settings, ...smtpDraft } as Settings
+ const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const [showTenantDashboardModal, setShowTenantDashboardModal] = useState(false)
const { data: tenantDefaultWidgets } = useQuery({
queryKey: ['tenant-default-dashboard'],
@@ -276,7 +278,17 @@ export default function AdminPage() {
{user.is_active ? 'active' : 'inactive'}
@@ -1484,6 +1506,14 @@ function AssetLibraryPanel() {
})}
)}
+
+ setConfirmState((s) => ({ ...s, open: false }))}
+ />
)
}
diff --git a/frontend/src/pages/Billing.tsx b/frontend/src/pages/Billing.tsx
index 5ff7b85..b2a8320 100644
--- a/frontend/src/pages/Billing.tsx
+++ b/frontend/src/pages/Billing.tsx
@@ -6,6 +6,7 @@ import {
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
type Invoice, type InvoiceCreate,
} from '../api/billing'
+import ConfirmModal from '../components/ConfirmModal'
// ── Helpers ───────────────────────────────────────────────────────────────
@@ -37,36 +38,46 @@ function NewInvoiceModal({ onClose, onCreate }: { onClose: () => void; onCreate:
return (
-
-
-
New Invoice
-
+
)
}
diff --git a/frontend/src/pages/Materials.tsx b/frontend/src/pages/Materials.tsx
index ba0a539..09ee189 100644
--- a/frontend/src/pages/Materials.tsx
+++ b/frontend/src/pages/Materials.tsx
@@ -11,6 +11,7 @@ import {
} from '../api/materials'
import type { Material } from '../api/materials'
import MaterialWizard from '../components/MaterialWizard'
+import ConfirmModal from '../components/ConfirmModal'
const TYPE_GROUPS = [
{ code: '01', label: 'Metals', icon: Wrench, bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-700' },
@@ -48,6 +49,7 @@ export default function MaterialsPage() {
const [collapsed, setCollapsed] = useState
>(new Set())
const [expandedAliases, setExpandedAliases] = useState>(new Set())
const [aliasInput, setAliasInput] = useState>({})
+ const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: materials = [], isLoading } = useQuery({
queryKey: ['materials'],
@@ -213,8 +215,15 @@ export default function MaterialsPage() {
)}
+
setConfirmState((s) => ({ ...s, open: false }))}
+ />
+
{/* Wizard modal */}
setShowWizard(false)} />
diff --git a/frontend/src/pages/NewProductOrder.tsx b/frontend/src/pages/NewProductOrder.tsx
index 18a8df6..6deff29 100644
--- a/frontend/src/pages/NewProductOrder.tsx
+++ b/frontend/src/pages/NewProductOrder.tsx
@@ -13,6 +13,9 @@ import { estimatePrice } from '../api/pricing'
import type { Product, RenderPosition } from '../api/products'
import type { OutputType } from '../api/outputTypes'
+const formatCurrency = (amount: number) =>
+ new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount)
+
const CATEGORIES = [
{ key: 'TRB', label: 'TRB' },
{ key: 'Kugellager', label: 'Kugellager' },
@@ -620,7 +623,7 @@ export default function NewProductOrderPage() {
{selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} · {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
{priceEstimate && priceEstimate.total > 0 && (
- <> · Estimated: {priceEstimate.total.toFixed(2)}>
+ <> · Estimated: {formatCurrency(priceEstimate.total)}>
)}
@@ -695,7 +698,7 @@ export default function NewProductOrderPage() {
{(() => {
const price = getLinePrice(line.product.id, line.outputType.id)
return price != null ? (
- {price.toFixed(2)}
+ {formatCurrency(price)}
) : (
—
)
@@ -741,7 +744,7 @@ export default function NewProductOrderPage() {
{orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
{priceEstimate && priceEstimate.total > 0 && (
- <> · Estimated: {priceEstimate.total.toFixed(2)}>
+ <> · Estimated: {formatCurrency(priceEstimate.total)}>
)}
diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx
index bfd0359..6bb89ea 100644
--- a/frontend/src/pages/Orders.tsx
+++ b/frontend/src/pages/Orders.tsx
@@ -11,6 +11,7 @@ import { toast } from 'sonner'
import { listOrders, searchOrders, deleteOrder } from '../api/orders'
import { fetchThumbnailBlob } from '../api/cad'
import type { Order, OrderDetail, OrderItem } from '../api/orders'
+import ConfirmModal from '../components/ConfirmModal'
// ── Constants ────────────────────────────────────────────────────────────────
const STATUSES = ['draft', 'submitted', 'processing', 'completed', 'rejected'] as const
@@ -49,6 +50,7 @@ export default function OrdersPage() {
const [dateTo, setDateTo] = useState('')
const [showFilters, setShowFilters] = useState(false)
const [selected, setSelected] = useState>(new Set())
+ const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
// Debounce the search input (400 ms)
useEffect(() => {
@@ -161,8 +163,15 @@ export default function OrdersPage() {
const handleDeleteSelected = () => {
const ids = [...selected]
if (!ids.length) return
- if (!confirm(`Delete ${ids.length} order${ids.length > 1 ? 's' : ''}? This cannot be undone.`)) return
- deleteMut.mutate(ids)
+ setConfirmState({
+ open: true,
+ title: `Delete ${ids.length} order${ids.length > 1 ? 's' : ''}`,
+ message: 'This cannot be undone.',
+ onConfirm: () => {
+ deleteMut.mutate(ids)
+ setConfirmState((s) => ({ ...s, open: false }))
+ },
+ })
}
// ── Render ───────────────────────────────────────────────────────────────
@@ -351,6 +360,14 @@ export default function OrdersPage() {
/>
)}
+ setConfirmState((s) => ({ ...s, open: false }))}
+ />
+
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
{selected.size > 0 && (
diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx
index a87d7ec..ca3de23 100644
--- a/frontend/src/pages/Upload.tsx
+++ b/frontend/src/pages/Upload.tsx
@@ -707,13 +707,19 @@ function ValidationDialog({
return (
-
+
{/* Header */}
-
-
Import Validation
+
+
Import Validation
@@ -721,8 +727,8 @@ function ValidationDialog({
{isLoading ? (
-
-
+
) : (
@@ -756,16 +762,20 @@ function ValidationDialog({
.map((row) => (
-
+
{expandedRows.has(row.row_index) ? '▲' : '▼'}
{expandedRows.has(row.row_index) && (
-
+
{row.issues.map((issue: ValidationIssue, i: number) => (
-
{issue.message}
+
{issue.message}
{issue.suggestion && (
-
+
Suggestion: {issue.suggestion}
)}
@@ -821,7 +835,7 @@ function ValidationDialog({
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"
+ className="text-xs bg-accent text-white px-3 py-1 rounded whitespace-nowrap"
>
Save as alias
@@ -843,10 +857,13 @@ function ValidationDialog({
)}
-