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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<Partial<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 { data: tenantDefaultWidgets } = useQuery<WidgetConfig[]>({
|
||||
queryKey: ['tenant-default-dashboard'],
|
||||
@@ -276,7 +278,17 @@ export default function AdminPage() {
|
||||
{user.is_active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
<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"
|
||||
title="Delete user"
|
||||
>
|
||||
@@ -1440,7 +1452,17 @@ function AssetLibraryPanel() {
|
||||
</button>
|
||||
<button
|
||||
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} />
|
||||
</button>
|
||||
@@ -1484,6 +1506,14 @@ function AssetLibraryPanel() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmState.open}
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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
|
||||
className="rounded-xl shadow-2xl w-full max-w-md border"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-content 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"
|
||||
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>
|
||||
<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
|
||||
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"
|
||||
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 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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -81,6 +92,7 @@ function NewInvoiceModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
||||
export default function BillingPage() {
|
||||
const qc = useQueryClient()
|
||||
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({
|
||||
queryKey: ['invoices'],
|
||||
@@ -206,9 +218,17 @@ export default function BillingPage() {
|
||||
{inv.status === 'draft' && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
@@ -227,6 +247,14 @@ export default function BillingPage() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Set<string | null>>(new Set())
|
||||
const [expandedAliases, setExpandedAliases] = useState<Set<string>>(new Set())
|
||||
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({
|
||||
queryKey: ['materials'],
|
||||
@@ -213,8 +215,15 @@ export default function MaterialsPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Import 35 Schaeffler standard materials? Existing entries will be skipped.'))
|
||||
seedMut.mutate()
|
||||
setConfirmState({
|
||||
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}
|
||||
className="btn-secondary text-sm flex items-center gap-1.5"
|
||||
@@ -224,8 +233,15 @@ export default function MaterialsPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Seed material aliases from naming scheme mappings? Existing aliases will be skipped.'))
|
||||
seedAliasMut.mutate()
|
||||
setConfirmState({
|
||||
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}
|
||||
className="btn-secondary text-sm flex items-center gap-1.5"
|
||||
@@ -401,7 +417,15 @@ export default function MaterialsPage() {
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
title="Delete"
|
||||
@@ -471,6 +495,14 @@ export default function MaterialsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmState.open}
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
|
||||
/>
|
||||
|
||||
{/* Wizard modal */}
|
||||
<MaterialWizard open={showWizard} onClose={() => setShowWizard(false)} />
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<span className="text-sm text-content-muted">
|
||||
{selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} · {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
|
||||
{priceEstimate && priceEstimate.total > 0 && (
|
||||
<> · Estimated: <span className="font-semibold text-content-secondary">{priceEstimate.total.toFixed(2)}</span></>
|
||||
<> · Estimated: <span className="font-semibold text-content-secondary">{formatCurrency(priceEstimate.total)}</span></>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -695,7 +698,7 @@ export default function NewProductOrderPage() {
|
||||
{(() => {
|
||||
const price = getLinePrice(line.product.id, line.outputType.id)
|
||||
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>
|
||||
)
|
||||
@@ -741,7 +744,7 @@ export default function NewProductOrderPage() {
|
||||
<span className="text-sm text-content-muted">
|
||||
{orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
|
||||
{priceEstimate && priceEstimate.total > 0 && (
|
||||
<> · Estimated: <span className="font-semibold text-content-secondary">{priceEstimate.total.toFixed(2)}</span></>
|
||||
<> · Estimated: <span className="font-semibold text-content-secondary">{formatCurrency(priceEstimate.total)}</span></>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
@@ -839,7 +842,7 @@ function ProductOutputRow({
|
||||
<p className="text-xs text-content-muted">
|
||||
{ot.renderer} · {ot.output_format.toUpperCase()}
|
||||
{ot.price_per_item != null && (
|
||||
<> · <span className="text-emerald-600 font-medium">{ot.price_per_item.toFixed(2)}</span></>
|
||||
<> · <span className="text-emerald-600 font-medium">{formatCurrency(ot.price_per_item)}</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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<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)
|
||||
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() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmState.open}
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
|
||||
/>
|
||||
|
||||
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
|
||||
{selected.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] z-50
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { toast } from 'sonner'
|
||||
import { listProducts, deleteProduct } from '../api/products'
|
||||
import type { Product } from '../api/products'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
TRB: 'TRB',
|
||||
@@ -119,6 +120,7 @@ export default function ProductLibraryPage() {
|
||||
const [materialsFilter, setMaterialsFilter] = useState('')
|
||||
const [view, setView] = useState<'grid' | 'table'>('grid')
|
||||
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({
|
||||
queryKey: ['products', { search, categoryFilter, hasCadFilter, materialsFilter }],
|
||||
@@ -164,8 +166,15 @@ export default function ProductLibraryPage() {
|
||||
const handleDeleteSelected = () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`Delete ${ids.length} product${ids.length > 1 ? 's' : ''}? This cannot be undone.`)) return
|
||||
deleteMut.mutate(ids)
|
||||
setConfirmState({
|
||||
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 (
|
||||
@@ -360,6 +369,14 @@ export default function ProductLibraryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmState.open}
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
|
||||
/>
|
||||
|
||||
{/* ── Floating action bar ───────────────────────────────────────── */}
|
||||
{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">
|
||||
|
||||
@@ -707,13 +707,19 @@ function ValidationDialog({
|
||||
|
||||
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">
|
||||
<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 */}
|
||||
<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>
|
||||
<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">Import Validation</h2>
|
||||
<button
|
||||
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>
|
||||
@@ -721,8 +727,8 @@ function ValidationDialog({
|
||||
|
||||
<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" />
|
||||
<div className="flex items-center gap-3 text-content-muted">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
<span>Validating import...</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -756,16 +762,20 @@ function ValidationDialog({
|
||||
.map((row) => (
|
||||
<div
|
||||
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
|
||||
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'
|
||||
? 'bg-red-50'
|
||||
: row.status === 'warning'
|
||||
? 'bg-yellow-50'
|
||||
: 'bg-white'
|
||||
: ''
|
||||
}`}
|
||||
style={row.status !== 'error' && row.status !== 'warning'
|
||||
? { backgroundColor: 'var(--color-bg-surface-alt)' }
|
||||
: undefined}
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -786,30 +796,34 @@ function ValidationDialog({
|
||||
: '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.pim_id ? ` — ${row.pim_id}` : ''}
|
||||
{row.produkt_baureihe ? ` (${row.produkt_baureihe})` : ''}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
<span className="text-xs text-content-muted">
|
||||
{row.issues.length} issue{row.issues.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-xs">
|
||||
<span className="text-content-muted 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">
|
||||
<div
|
||||
className="border-t divide-y"
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
{row.issues.map((issue: ValidationIssue, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className="px-4 py-3 flex items-start justify-between gap-4"
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">{issue.message}</p>
|
||||
<p className="text-sm text-content">{issue.message}</p>
|
||||
{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>
|
||||
</p>
|
||||
)}
|
||||
@@ -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
|
||||
</button>
|
||||
@@ -843,10 +857,13 @@ function ValidationDialog({
|
||||
)}
|
||||
</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
|
||||
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
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user