fix(ux): replace confirm() with ConfirmModal, fix dark-mode colors, add currency format

- Add reusable ConfirmModal component (themed, Escape key, focus trap)
- Replace all native confirm() calls in Orders, ProductLibrary, Materials, Admin, Billing
- Fix ValidationDialog (Upload.tsx) hardcoded bg-white/text-gray-* → semantic tokens
- Fix NewInvoiceModal (Billing.tsx) hardcoded colors → semantic tokens
- Add formatCurrency (Intl.NumberFormat de-DE/EUR) to NewProductOrder wizard
- Resolves audit issues C1, C3, M3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 19:59:13 +01:00
parent 9f54bc3ab1
commit 915abe9d74
8 changed files with 307 additions and 45 deletions
+118
View File
@@ -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<HTMLButtonElement>(null)
const dialogRef = useRef<HTMLDivElement>(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<HTMLElement>(
'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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
className="rounded-xl shadow-2xl w-full max-w-sm mx-4 border"
style={{
backgroundColor: 'var(--color-bg-surface)',
borderColor: 'var(--color-border-default)',
}}
>
<div className="px-6 py-5">
<div className="flex items-start gap-3 mb-4">
<div className="shrink-0 w-9 h-9 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle size={18} className="text-red-600" />
</div>
<div>
<h2
id="confirm-modal-title"
className="text-base font-semibold text-content"
>
{title}
</h2>
<p className="text-sm text-content mt-1" style={{ opacity: 0.7 }}>
{message}
</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
ref={cancelRef}
onClick={onCancel}
className="px-4 py-2 text-sm rounded-lg border transition-colors text-content-secondary hover:bg-surface-hover"
style={{ borderColor: 'var(--color-border-default)' }}
>
Cancel
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
confirmVariant === 'danger'
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-accent hover:bg-accent text-white'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
</div>
)
}
+32 -2
View File
@@ -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 { 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 { Link } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
import TemplateEditor from '../components/admin/TemplateEditor' import TemplateEditor from '../components/admin/TemplateEditor'
import PricingTierTable from '../components/admin/PricingTierTable' import PricingTierTable from '../components/admin/PricingTierTable'
import OutputTypeTable from '../components/admin/OutputTypeTable' import OutputTypeTable from '../components/admin/OutputTypeTable'
@@ -191,6 +192,7 @@ export default function AdminPage() {
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({}) const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
const smtp = { ...settings, ...smtpDraft } as Settings 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 [showTenantDashboardModal, setShowTenantDashboardModal] = useState(false)
const { data: tenantDefaultWidgets } = useQuery<WidgetConfig[]>({ const { data: tenantDefaultWidgets } = useQuery<WidgetConfig[]>({
queryKey: ['tenant-default-dashboard'], queryKey: ['tenant-default-dashboard'],
@@ -276,7 +278,17 @@ export default function AdminPage() {
{user.is_active ? 'active' : 'inactive'} {user.is_active ? 'active' : 'inactive'}
</span> </span>
<button <button
onClick={() => { if (confirm('Delete user?')) deleteUserMut.mutate(user.id) }} onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${user.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(user.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors" className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user" title="Delete user"
> >
@@ -1440,7 +1452,17 @@ function AssetLibraryPanel() {
</button> </button>
<button <button
className="btn-danger text-xs" className="btn-danger text-xs"
onClick={() => { if (confirm(`Delete "${lib.name}"?`)) deleteMut.mutate(lib.id) }} onClick={() => {
setConfirmState({
open: true,
title: 'Delete Asset Library',
message: `Delete "${lib.name}"?`,
onConfirm: () => {
deleteMut.mutate(lib.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
@@ -1484,6 +1506,14 @@ function AssetLibraryPanel() {
})} })}
</div> </div>
)} )}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
</div> </div>
) )
} }
+40 -12
View File
@@ -6,6 +6,7 @@ import {
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf, getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
type Invoice, type InvoiceCreate, type Invoice, type InvoiceCreate,
} from '../api/billing' } from '../api/billing'
import ConfirmModal from '../components/ConfirmModal'
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
@@ -37,36 +38,46 @@ function NewInvoiceModal({ onClose, onCreate }: { onClose: () => void; onCreate:
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between"> className="rounded-xl shadow-2xl w-full max-w-md border"
<h2 className="text-lg font-semibold text-gray-900">New Invoice</h2> style={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border-default)' }}
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button> >
<div
className="px-6 py-4 border-b flex items-center justify-between"
style={{ borderColor: 'var(--color-border-default)' }}
>
<h2 className="text-lg font-semibold text-content">New Invoice</h2>
<button onClick={onClose} className="text-content-muted hover:text-content"><X size={18} /></button>
</div> </div>
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4"> <form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Issue Date</label> <label className="block text-sm font-medium text-content mb-1">Issue Date</label>
<input <input
type="date" type="date"
value={issuedAt} value={issuedAt}
onChange={e => setIssuedAt(e.target.value)} 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" className="w-full px-3 py-2 text-sm border border-border-default rounded-lg focus:outline-none focus:ring-1 focus:ring-accent"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label> <label className="block text-sm font-medium text-content mb-1">Notes</label>
<textarea <textarea
value={notes} value={notes}
onChange={e => setNotes(e.target.value)} onChange={e => setNotes(e.target.value)}
rows={3} rows={3}
placeholder="Optional notes..." 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" className="w-full px-3 py-2 text-sm border border-border-default rounded-lg focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/> />
</div> </div>
<div className="flex justify-end gap-3 pt-2"> <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"> <button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-content-secondary rounded-lg border border-border-default hover:bg-surface-hover transition-colors"
>
Cancel Cancel
</button> </button>
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"> <button type="submit" className="px-4 py-2 text-sm bg-accent text-white rounded-lg transition-colors">
Create Invoice Create Invoice
</button> </button>
</div> </div>
@@ -81,6 +92,7 @@ function NewInvoiceModal({ onClose, onCreate }: { onClose: () => void; onCreate:
export default function BillingPage() { export default function BillingPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: invoices = [], isLoading } = useQuery({ const { data: invoices = [], isLoading } = useQuery({
queryKey: ['invoices'], queryKey: ['invoices'],
@@ -206,9 +218,17 @@ export default function BillingPage() {
{inv.status === 'draft' && ( {inv.status === 'draft' && (
<button <button
onClick={() => { onClick={() => {
if (confirm('Delete this draft invoice?')) deleteMutation.mutate(inv.id) setConfirmState({
open: true,
title: 'Delete Invoice',
message: `Delete draft invoice ${inv.invoice_number}?`,
onConfirm: () => {
deleteMutation.mutate(inv.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}} }}
className="p-1.5 rounded hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors" className="p-1.5 rounded hover:bg-red-100 text-content-muted hover:text-red-600 transition-colors"
title="Delete draft" title="Delete draft"
> >
<Trash2 size={15} /> <Trash2 size={15} />
@@ -227,6 +247,14 @@ export default function BillingPage() {
onCreate={data => createMutation.mutate(data)} onCreate={data => createMutation.mutate(data)}
/> />
)} )}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
</div> </div>
) )
} }
+37 -5
View File
@@ -11,6 +11,7 @@ import {
} from '../api/materials' } from '../api/materials'
import type { Material } from '../api/materials' import type { Material } from '../api/materials'
import MaterialWizard from '../components/MaterialWizard' import MaterialWizard from '../components/MaterialWizard'
import ConfirmModal from '../components/ConfirmModal'
const TYPE_GROUPS = [ const TYPE_GROUPS = [
{ code: '01', label: 'Metals', icon: Wrench, bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-700' }, { 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<Set<string | null>>(new Set()) const [collapsed, setCollapsed] = useState<Set<string | null>>(new Set())
const [expandedAliases, setExpandedAliases] = useState<Set<string>>(new Set()) const [expandedAliases, setExpandedAliases] = useState<Set<string>>(new Set())
const [aliasInput, setAliasInput] = useState<Record<string, string>>({}) const [aliasInput, setAliasInput] = useState<Record<string, string>>({})
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: materials = [], isLoading } = useQuery({ const { data: materials = [], isLoading } = useQuery({
queryKey: ['materials'], queryKey: ['materials'],
@@ -213,8 +215,15 @@ export default function MaterialsPage() {
</div> </div>
<button <button
onClick={() => { onClick={() => {
if (confirm('Import 35 Schaeffler standard materials? Existing entries will be skipped.')) setConfirmState({
seedMut.mutate() open: true,
title: 'Import Standard Materials',
message: 'Import 35 Schaeffler standard materials? Existing entries will be skipped.',
onConfirm: () => {
seedMut.mutate()
setConfirmState((s) => ({ ...s, open: false }))
},
})
}} }}
disabled={seedMut.isPending} disabled={seedMut.isPending}
className="btn-secondary text-sm flex items-center gap-1.5" className="btn-secondary text-sm flex items-center gap-1.5"
@@ -224,8 +233,15 @@ export default function MaterialsPage() {
</button> </button>
<button <button
onClick={() => { onClick={() => {
if (confirm('Seed material aliases from naming scheme mappings? Existing aliases will be skipped.')) setConfirmState({
seedAliasMut.mutate() open: true,
title: 'Seed Material Aliases',
message: 'Seed material aliases from naming scheme mappings? Existing aliases will be skipped.',
onConfirm: () => {
seedAliasMut.mutate()
setConfirmState((s) => ({ ...s, open: false }))
},
})
}} }}
disabled={seedAliasMut.isPending} disabled={seedAliasMut.isPending}
className="btn-secondary text-sm flex items-center gap-1.5" className="btn-secondary text-sm flex items-center gap-1.5"
@@ -401,7 +417,15 @@ export default function MaterialsPage() {
</button> </button>
<button <button
onClick={() => { onClick={() => {
if (confirm(`Delete material "${mat.name}"?`)) deleteMut.mutate(mat.id) setConfirmState({
open: true,
title: 'Delete Material',
message: `Delete material "${mat.name}"?`,
onConfirm: () => {
deleteMut.mutate(mat.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}} }}
className="text-content-muted hover:text-red-500" className="text-content-muted hover:text-red-500"
title="Delete" title="Delete"
@@ -471,6 +495,14 @@ export default function MaterialsPage() {
</div> </div>
)} )}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
{/* Wizard modal */} {/* Wizard modal */}
<MaterialWizard open={showWizard} onClose={() => setShowWizard(false)} /> <MaterialWizard open={showWizard} onClose={() => setShowWizard(false)} />
</div> </div>
+7 -4
View File
@@ -13,6 +13,9 @@ import { estimatePrice } from '../api/pricing'
import type { Product, RenderPosition } from '../api/products' import type { Product, RenderPosition } from '../api/products'
import type { OutputType } from '../api/outputTypes' import type { OutputType } from '../api/outputTypes'
const formatCurrency = (amount: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount)
const CATEGORIES = [ const CATEGORIES = [
{ key: 'TRB', label: 'TRB' }, { key: 'TRB', label: 'TRB' },
{ key: 'Kugellager', label: 'Kugellager' }, { key: 'Kugellager', label: 'Kugellager' },
@@ -620,7 +623,7 @@ export default function NewProductOrderPage() {
<span className="text-sm text-content-muted"> <span className="text-sm text-content-muted">
{selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} &middot; {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''} {selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} &middot; {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
{priceEstimate && priceEstimate.total > 0 && ( {priceEstimate && priceEstimate.total > 0 && (
<> &middot; Estimated: <span className="font-semibold text-content-secondary">{priceEstimate.total.toFixed(2)}</span></> <> &middot; Estimated: <span className="font-semibold text-content-secondary">{formatCurrency(priceEstimate.total)}</span></>
)} )}
</span> </span>
</div> </div>
@@ -695,7 +698,7 @@ export default function NewProductOrderPage() {
{(() => { {(() => {
const price = getLinePrice(line.product.id, line.outputType.id) const price = getLinePrice(line.product.id, line.outputType.id)
return price != null ? ( return price != null ? (
<span className="font-medium text-content-secondary">{price.toFixed(2)}</span> <span className="font-medium text-content-secondary">{formatCurrency(price)}</span>
) : ( ) : (
<span className="text-content-muted"></span> <span className="text-content-muted"></span>
) )
@@ -741,7 +744,7 @@ export default function NewProductOrderPage() {
<span className="text-sm text-content-muted"> <span className="text-sm text-content-muted">
{orderLines.length} render job{orderLines.length !== 1 ? 's' : ''} {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
{priceEstimate && priceEstimate.total > 0 && ( {priceEstimate && priceEstimate.total > 0 && (
<> &middot; Estimated: <span className="font-semibold text-content-secondary">{priceEstimate.total.toFixed(2)}</span></> <> &middot; Estimated: <span className="font-semibold text-content-secondary">{formatCurrency(priceEstimate.total)}</span></>
)} )}
</span> </span>
<button <button
@@ -839,7 +842,7 @@ function ProductOutputRow({
<p className="text-xs text-content-muted"> <p className="text-xs text-content-muted">
{ot.renderer} &middot; {ot.output_format.toUpperCase()} {ot.renderer} &middot; {ot.output_format.toUpperCase()}
{ot.price_per_item != null && ( {ot.price_per_item != null && (
<> &middot; <span className="text-emerald-600 font-medium">{ot.price_per_item.toFixed(2)}</span></> <> &middot; <span className="text-emerald-600 font-medium">{formatCurrency(ot.price_per_item)}</span></>
)} )}
</p> </p>
</div> </div>
+19 -2
View File
@@ -11,6 +11,7 @@ import { toast } from 'sonner'
import { listOrders, searchOrders, deleteOrder } from '../api/orders' import { listOrders, searchOrders, deleteOrder } from '../api/orders'
import { fetchThumbnailBlob } from '../api/cad' import { fetchThumbnailBlob } from '../api/cad'
import type { Order, OrderDetail, OrderItem } from '../api/orders' import type { Order, OrderDetail, OrderItem } from '../api/orders'
import ConfirmModal from '../components/ConfirmModal'
// ── Constants ──────────────────────────────────────────────────────────────── // ── Constants ────────────────────────────────────────────────────────────────
const STATUSES = ['draft', 'submitted', 'processing', 'completed', 'rejected'] as const const STATUSES = ['draft', 'submitted', 'processing', 'completed', 'rejected'] as const
@@ -49,6 +50,7 @@ export default function OrdersPage() {
const [dateTo, setDateTo] = useState('') const [dateTo, setDateTo] = useState('')
const [showFilters, setShowFilters] = useState(false) const [showFilters, setShowFilters] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(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) // Debounce the search input (400 ms)
useEffect(() => { useEffect(() => {
@@ -161,8 +163,15 @@ export default function OrdersPage() {
const handleDeleteSelected = () => { const handleDeleteSelected = () => {
const ids = [...selected] const ids = [...selected]
if (!ids.length) return if (!ids.length) return
if (!confirm(`Delete ${ids.length} order${ids.length > 1 ? 's' : ''}? This cannot be undone.`)) return setConfirmState({
deleteMut.mutate(ids) 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 ─────────────────────────────────────────────────────────────── // ── Render ───────────────────────────────────────────────────────────────
@@ -351,6 +360,14 @@ export default function OrdersPage() {
/> />
)} )}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
{/* ── Bulk delete bar ───────────────────────────────────────────────── */} {/* ── Bulk delete bar ───────────────────────────────────────────────── */}
{selected.size > 0 && ( {selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] z-50 <div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] z-50
+19 -2
View File
@@ -8,6 +8,7 @@ import {
import { toast } from 'sonner' import { toast } from 'sonner'
import { listProducts, deleteProduct } from '../api/products' import { listProducts, deleteProduct } from '../api/products'
import type { Product } from '../api/products' import type { Product } from '../api/products'
import ConfirmModal from '../components/ConfirmModal'
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
TRB: 'TRB', TRB: 'TRB',
@@ -119,6 +120,7 @@ export default function ProductLibraryPage() {
const [materialsFilter, setMaterialsFilter] = useState('') const [materialsFilter, setMaterialsFilter] = useState('')
const [view, setView] = useState<'grid' | 'table'>('grid') const [view, setView] = useState<'grid' | 'table'>('grid')
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: products, isLoading } = useQuery({ const { data: products, isLoading } = useQuery({
queryKey: ['products', { search, categoryFilter, hasCadFilter, materialsFilter }], queryKey: ['products', { search, categoryFilter, hasCadFilter, materialsFilter }],
@@ -164,8 +166,15 @@ export default function ProductLibraryPage() {
const handleDeleteSelected = () => { const handleDeleteSelected = () => {
const ids = [...selected] const ids = [...selected]
if (!ids.length) return if (!ids.length) return
if (!confirm(`Delete ${ids.length} product${ids.length > 1 ? 's' : ''}? This cannot be undone.`)) return setConfirmState({
deleteMut.mutate(ids) open: true,
title: `Delete ${ids.length} product${ids.length > 1 ? 's' : ''}`,
message: 'This cannot be undone.',
onConfirm: () => {
deleteMut.mutate(ids)
setConfirmState((s) => ({ ...s, open: false }))
},
})
} }
return ( return (
@@ -360,6 +369,14 @@ export default function ProductLibraryPage() {
</div> </div>
)} )}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
{/* ── Floating action bar ───────────────────────────────────────── */} {/* ── Floating action bar ───────────────────────────────────────── */}
{selected.size > 0 && ( {selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] bg-gray-900 text-white rounded-lg shadow-xl px-5 py-3 flex items-center gap-4 z-50"> <div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] bg-gray-900 text-white rounded-lg shadow-xl px-5 py-3 flex items-center gap-4 z-50">
+35 -18
View File
@@ -707,13 +707,19 @@ function ValidationDialog({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <div
className="rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col border"
style={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border-default)' }}
>
{/* Header */} {/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between"> <div
<h2 className="text-lg font-semibold text-gray-900">Import Validation</h2> className="px-6 py-4 border-b flex items-center justify-between"
style={{ borderColor: 'var(--color-border-default)' }}
>
<h2 className="text-lg font-semibold text-content">Import Validation</h2>
<button <button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl leading-none" className="text-content-muted hover:text-content text-xl leading-none"
> >
× ×
</button> </button>
@@ -721,8 +727,8 @@ function ValidationDialog({
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4"> <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-3 text-gray-500"> <div className="flex items-center gap-3 text-content-muted">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" /> <div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
<span>Validating import...</span> <span>Validating import...</span>
</div> </div>
) : ( ) : (
@@ -756,16 +762,20 @@ function ValidationDialog({
.map((row) => ( .map((row) => (
<div <div
key={row.row_index} key={row.row_index}
className="border border-gray-200 rounded-lg overflow-hidden" className="rounded-lg overflow-hidden border"
style={{ borderColor: 'var(--color-border-default)' }}
> >
<button <button
className={`w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition-colors ${ className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${
row.status === 'error' row.status === 'error'
? 'bg-red-50' ? 'bg-red-50'
: row.status === 'warning' : row.status === 'warning'
? 'bg-yellow-50' ? 'bg-yellow-50'
: 'bg-white' : ''
}`} }`}
style={row.status !== 'error' && row.status !== 'warning'
? { backgroundColor: 'var(--color-bg-surface-alt)' }
: undefined}
onClick={() => onClick={() =>
setExpandedRows((prev) => { setExpandedRows((prev) => {
const next = new Set(prev) const next = new Set(prev)
@@ -786,30 +796,34 @@ function ValidationDialog({
: 'bg-green-500' : 'bg-green-500'
}`} }`}
/> />
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-content">
Row {row.row_index + 1} Row {row.row_index + 1}
{row.pim_id ? ` — ${row.pim_id}` : ''} {row.pim_id ? ` — ${row.pim_id}` : ''}
{row.produkt_baureihe ? ` (${row.produkt_baureihe})` : ''} {row.produkt_baureihe ? ` (${row.produkt_baureihe})` : ''}
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-xs text-content-muted">
{row.issues.length} issue{row.issues.length !== 1 ? 's' : ''} {row.issues.length} issue{row.issues.length !== 1 ? 's' : ''}
</span> </span>
</div> </div>
<span className="text-gray-400 text-xs"> <span className="text-content-muted text-xs">
{expandedRows.has(row.row_index) ? '' : ''} {expandedRows.has(row.row_index) ? '' : ''}
</span> </span>
</button> </button>
{expandedRows.has(row.row_index) && ( {expandedRows.has(row.row_index) && (
<div className="border-t border-gray-100 divide-y divide-gray-50"> <div
className="border-t divide-y"
style={{ borderColor: 'var(--color-border-default)' }}
>
{row.issues.map((issue: ValidationIssue, i: number) => ( {row.issues.map((issue: ValidationIssue, i: number) => (
<div <div
key={i} key={i}
className="px-4 py-3 flex items-start justify-between gap-4" className="px-4 py-3 flex items-start justify-between gap-4"
style={{ borderColor: 'var(--color-border-default)' }}
> >
<div> <div>
<p className="text-sm text-gray-700">{issue.message}</p> <p className="text-sm text-content">{issue.message}</p>
{issue.suggestion && ( {issue.suggestion && (
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-content-muted mt-0.5">
Suggestion: <em>{issue.suggestion}</em> Suggestion: <em>{issue.suggestion}</em>
</p> </p>
)} )}
@@ -821,7 +835,7 @@ function ValidationDialog({
onClick={() => onClick={() =>
onSaveAlias(issue.value!, issue.suggestion!) 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 Save as alias
</button> </button>
@@ -843,10 +857,13 @@ function ValidationDialog({
)} )}
</div> </div>
<div className="px-6 py-4 border-t border-gray-200 flex justify-end"> <div
className="px-6 py-4 border-t flex justify-end"
style={{ borderColor: 'var(--color-border-default)' }}
>
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" className="px-4 py-2 text-sm text-content-secondary rounded-lg border border-border-default hover:bg-surface-hover transition-colors"
> >
Close Close
</button> </button>