feat: material alias seeds expansion, bulk product delete, dashboard stats widgets
- Material alias seeds: 95 → 855 aliases covering German variants, DIN standards, Werkstoffnummern, industry terms, English equivalents, polymer abbreviations - Batch product delete/deactivate endpoint (POST /products/batch-delete) - Multi-select UI on Products page with floating action bar - Dashboard: RenderThroughput + MaterialCoverage widgets - Dashboard stats endpoint (GET /admin/dashboard-stats) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ export type WidgetType =
|
||||
| 'TopProducts'
|
||||
| 'OrdersByUser'
|
||||
| 'RenderBackendStats'
|
||||
| 'RenderThroughput'
|
||||
| 'MaterialCoverage'
|
||||
|
||||
export interface WidgetPosition {
|
||||
col: number
|
||||
@@ -64,3 +66,53 @@ export async function updateTenantDefaultDashboard(
|
||||
)
|
||||
return data.widgets
|
||||
}
|
||||
|
||||
// ── Dashboard Stats ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface RenderThroughputStats {
|
||||
completed_today: number
|
||||
completed_this_week: number
|
||||
completed_this_month: number
|
||||
failed_today: number
|
||||
failed_this_week: number
|
||||
failed_this_month: number
|
||||
avg_render_time_s: number | null
|
||||
median_render_time_s: number | null
|
||||
}
|
||||
|
||||
export interface MaterialCoverageStats {
|
||||
total_unique_materials: number
|
||||
mapped_materials: number
|
||||
unmapped_materials: number
|
||||
coverage_pct: number
|
||||
library_material_count: number
|
||||
alias_count: number
|
||||
}
|
||||
|
||||
export interface ProductStatsOverview {
|
||||
total_products: number
|
||||
with_step_files: number
|
||||
without_step_files: number
|
||||
step_coverage_pct: number
|
||||
}
|
||||
|
||||
export interface OrderStatusBreakdown {
|
||||
draft: number
|
||||
submitted: number
|
||||
processing: number
|
||||
completed: number
|
||||
rejected: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
render_throughput: RenderThroughputStats
|
||||
material_coverage: MaterialCoverageStats
|
||||
product_stats: ProductStatsOverview
|
||||
order_status: OrderStatusBreakdown
|
||||
}
|
||||
|
||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
const { data } = await api.get<DashboardStats>('/admin/dashboard-stats')
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -109,6 +109,17 @@ export async function deleteProduct(id: string, hard = false): Promise<void> {
|
||||
await api.delete(`/products/${id}`, { params: hard ? { hard: true } : undefined })
|
||||
}
|
||||
|
||||
export async function batchDeleteProducts(
|
||||
productIds: string[],
|
||||
hard = false,
|
||||
): Promise<{ deleted: number; not_found: number }> {
|
||||
const res = await api.post<{ deleted: number; not_found: number }>(
|
||||
'/products/batch-delete',
|
||||
{ product_ids: productIds, hard },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface ProductCadUploadResponse {
|
||||
cad_file_id: string
|
||||
original_name: string
|
||||
|
||||
@@ -21,6 +21,8 @@ const WIDGET_LABELS: Record<WidgetType, string> = {
|
||||
TopProducts: 'Top 10 Products',
|
||||
OrdersByUser: 'Orders by User',
|
||||
RenderBackendStats: 'Render Backend Stats',
|
||||
RenderThroughput: 'Render Throughput',
|
||||
MaterialCoverage: 'Material & Product Coverage',
|
||||
}
|
||||
|
||||
const OPERATIONAL_TYPES: WidgetType[] = [
|
||||
@@ -29,6 +31,8 @@ const OPERATIONAL_TYPES: WidgetType[] = [
|
||||
'RecentRenders',
|
||||
'CostOverview',
|
||||
'WorkerStatus',
|
||||
'RenderThroughput',
|
||||
'MaterialCoverage',
|
||||
]
|
||||
|
||||
const ANALYTICS_TYPES: WidgetType[] = [
|
||||
|
||||
@@ -25,6 +25,8 @@ import OutputTypeUsageWidget from './widgets/OutputTypeUsageWidget'
|
||||
import TopProductsWidget from './widgets/TopProductsWidget'
|
||||
import OrdersByUserWidget from './widgets/OrdersByUserWidget'
|
||||
import RenderBackendStatsWidget from './widgets/RenderBackendStatsWidget'
|
||||
import RenderThroughputWidget from './widgets/RenderThroughputWidget'
|
||||
import MaterialCoverageWidget from './widgets/MaterialCoverageWidget'
|
||||
|
||||
const WIDGET_META: Record<WidgetType, { title: string; icon: React.ReactNode }> = {
|
||||
ProductionStats: { title: 'Production Stats', icon: <BarChart2 size={15} /> },
|
||||
@@ -42,6 +44,8 @@ const WIDGET_META: Record<WidgetType, { title: string; icon: React.ReactNode }>
|
||||
TopProducts: { title: 'Top 10 Products', icon: <Table2 size={15} /> },
|
||||
OrdersByUser: { title: 'Orders by User', icon: <Users size={15} /> },
|
||||
RenderBackendStats: { title: 'Render Backend Stats', icon: <Server size={15} /> },
|
||||
RenderThroughput: { title: 'Render Throughput', icon: <TrendingUp size={15} /> },
|
||||
MaterialCoverage: { title: 'Material & Product Coverage', icon: <PieChart size={15} /> },
|
||||
}
|
||||
|
||||
// Analytics widget types that need a timeframe
|
||||
@@ -77,6 +81,8 @@ function WidgetBody({ type }: { type: WidgetType }) {
|
||||
case 'TopProducts': return <TopProductsWidget />
|
||||
case 'OrdersByUser': return <OrdersByUserWidget />
|
||||
case 'RenderBackendStats': return <RenderBackendStatsWidget />
|
||||
case 'RenderThroughput': return <RenderThroughputWidget />
|
||||
case 'MaterialCoverage': return <MaterialCoverageWidget />
|
||||
default: return <p className="text-xs text-content-muted">Unknown widget</p>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Layers, Package, FileBox, AlertTriangle, CheckCircle2, BookOpen, Link2 } from 'lucide-react'
|
||||
import { getDashboardStats } from '../../../api/dashboard'
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-6 rounded bg-surface-muted" />
|
||||
<div className="h-3 rounded-full bg-surface-muted" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-16 rounded-lg bg-surface-muted" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressBar({ pct, color }: { pct: number; color: string }) {
|
||||
return (
|
||||
<div className="w-full h-2 rounded-full bg-surface-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(pct, 100)}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MaterialCoverageWidget() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: getDashboardStats,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
if (isLoading) return <Skeleton />
|
||||
if (error) return <p className="text-xs text-red-500">Failed to load material coverage</p>
|
||||
if (!data) return null
|
||||
|
||||
const mc = data.material_coverage
|
||||
const ps = data.product_stats
|
||||
const os = data.order_status
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Material coverage bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-content-secondary">Material Coverage</span>
|
||||
<span className="text-xs font-bold text-content">{mc.coverage_pct}%</span>
|
||||
</div>
|
||||
<ProgressBar pct={mc.coverage_pct} color={mc.coverage_pct >= 90 ? '#22c55e' : mc.coverage_pct >= 70 ? '#eab308' : '#ef4444'} />
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-[10px] text-content-muted">{mc.mapped_materials} mapped</span>
|
||||
{mc.unmapped_materials > 0 && (
|
||||
<span className="text-[10px] text-amber-600 flex items-center gap-0.5">
|
||||
<AlertTriangle size={10} />
|
||||
{mc.unmapped_materials} unmapped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP coverage bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-content-secondary">STEP File Coverage</span>
|
||||
<span className="text-xs font-bold text-content">{ps.step_coverage_pct}%</span>
|
||||
</div>
|
||||
<ProgressBar pct={ps.step_coverage_pct} color={ps.step_coverage_pct >= 90 ? '#22c55e' : ps.step_coverage_pct >= 70 ? '#eab308' : '#ef4444'} />
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-[10px] text-content-muted">{ps.with_step_files} / {ps.total_products} products</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatCard icon={<Layers size={14} className="text-blue-500" />} label="Total Materials" value={mc.total_unique_materials} />
|
||||
<StatCard icon={<BookOpen size={14} className="text-indigo-500" />} label="Library Materials" value={mc.library_material_count} />
|
||||
<StatCard icon={<Link2 size={14} className="text-teal-500" />} label="Aliases" value={mc.alias_count} />
|
||||
<StatCard icon={<Package size={14} className="text-purple-500" />} label="Products" value={ps.total_products} />
|
||||
</div>
|
||||
|
||||
{/* Order status summary */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-content-secondary mb-2">Orders ({os.total})</p>
|
||||
<div className="flex gap-1 h-5 rounded-full overflow-hidden">
|
||||
{os.completed > 0 && <StatusSlice count={os.completed} total={os.total} color="#22c55e" label="Completed" />}
|
||||
{os.processing > 0 && <StatusSlice count={os.processing} total={os.total} color="#3b82f6" label="Processing" />}
|
||||
{os.submitted > 0 && <StatusSlice count={os.submitted} total={os.total} color="#eab308" label="Submitted" />}
|
||||
{os.draft > 0 && <StatusSlice count={os.draft} total={os.total} color="#9ca3af" label="Draft" />}
|
||||
{os.rejected > 0 && <StatusSlice count={os.rejected} total={os.total} color="#ef4444" label="Rejected" />}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1.5">
|
||||
{os.completed > 0 && <LegendItem color="#22c55e" label="Completed" count={os.completed} />}
|
||||
{os.processing > 0 && <LegendItem color="#3b82f6" label="Processing" count={os.processing} />}
|
||||
{os.submitted > 0 && <LegendItem color="#eab308" label="Submitted" count={os.submitted} />}
|
||||
{os.draft > 0 && <LegendItem color="#9ca3af" label="Draft" count={os.draft} />}
|
||||
{os.rejected > 0 && <LegendItem color="#ef4444" label="Rejected" count={os.rejected} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-border-default p-2.5"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1">{icon}</div>
|
||||
<p className="text-lg font-bold text-content">{value}</p>
|
||||
<p className="text-[10px] text-content-muted">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusSlice({ count, total, color, label }: { count: number; total: number; color: string; label: string }) {
|
||||
const pct = total > 0 ? (count / total) * 100 : 0
|
||||
return (
|
||||
<div
|
||||
title={`${label}: ${count}`}
|
||||
style={{ width: `${pct}%`, backgroundColor: color, minWidth: pct > 0 ? '4px' : '0' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LegendItem({ color, label, count }: { color: string; label: string; count: number }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-content-muted">
|
||||
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: color }} />
|
||||
{label} ({count})
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { CheckCircle2, XCircle, Clock, CalendarDays, CalendarRange, Timer } from 'lucide-react'
|
||||
import { getDashboardStats } from '../../../api/dashboard'
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 rounded-lg bg-surface-muted" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(seconds: number | null): string {
|
||||
if (seconds == null) return '--'
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
|
||||
export default function RenderThroughputWidget() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: getDashboardStats,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
if (isLoading) return <Skeleton />
|
||||
if (error) return <p className="text-xs text-red-500">Failed to load render throughput</p>
|
||||
if (!data) return null
|
||||
|
||||
const t = data.render_throughput
|
||||
|
||||
const periods = [
|
||||
{ label: 'Today', completed: t.completed_today, failed: t.failed_today, icon: <Clock size={14} className="text-blue-500" /> },
|
||||
{ label: 'This Week', completed: t.completed_this_week, failed: t.failed_this_week, icon: <CalendarDays size={14} className="text-indigo-500" /> },
|
||||
{ label: 'This Month', completed: t.completed_this_month, failed: t.failed_this_month, icon: <CalendarRange size={14} className="text-purple-500" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{periods.map(({ label, completed, failed, icon }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center justify-between rounded-lg border border-border-default p-3"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-sm text-content-secondary">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-sm font-semibold text-green-600">
|
||||
<CheckCircle2 size={13} />
|
||||
{completed}
|
||||
</span>
|
||||
{failed > 0 && (
|
||||
<span className="flex items-center gap-1 text-sm font-semibold text-red-500">
|
||||
<XCircle size={13} />
|
||||
{failed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Render time stats */}
|
||||
<div
|
||||
className="flex items-center justify-between rounded-lg border border-border-default p-3"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Timer size={14} className="text-amber-500" />
|
||||
<span className="text-sm text-content-secondary">Avg / Median</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{formatTime(t.avg_render_time_s)} / {formatTime(t.median_render_time_s)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
|
||||
LayoutGrid, List, Trash2, X, ArrowUpDown,
|
||||
CheckSquare, Square, EyeOff, Loader2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { listProducts, deleteProduct } from '../api/products'
|
||||
import { listProducts, batchDeleteProducts } from '../api/products'
|
||||
import type { Product } from '../api/products'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
TRB: 'TRB',
|
||||
@@ -123,7 +123,6 @@ export default function ProductLibraryPage() {
|
||||
const [sortBy, setSortBy] = useState<'pim_id' | 'name' | 'status'>('pim_id')
|
||||
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: () => {} })
|
||||
|
||||
// Debounce search input (300ms)
|
||||
useEffect(() => {
|
||||
@@ -169,31 +168,41 @@ export default function ProductLibraryPage() {
|
||||
setSelected(allSelected ? new Set() : new Set(products.map((p) => p.id)))
|
||||
}
|
||||
|
||||
// ── Bulk delete ────────────────────────────────────────────────────────
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
await Promise.all(ids.map((id) => deleteProduct(id, true)))
|
||||
},
|
||||
onSuccess: (_, ids) => {
|
||||
toast.success(`${ids.length} product${ids.length > 1 ? 's' : ''} deleted`)
|
||||
setSelected(new Set())
|
||||
qc.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
|
||||
})
|
||||
// ── Bulk actions ──────────────────────────────────────────────────────
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const [confirmDeleteHard, setConfirmDeleteHard] = useState(false)
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const handleDeactivateSelected = async () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) return
|
||||
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 }))
|
||||
},
|
||||
})
|
||||
setBatchBusy(true)
|
||||
try {
|
||||
const result = await batchDeleteProducts(ids, false)
|
||||
toast.success(`Deactivated ${result.deleted} product${result.deleted !== 1 ? 's' : ''}`)
|
||||
setSelected(new Set())
|
||||
qc.invalidateQueries({ queryKey: ['products'] })
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.detail || 'Deactivate failed')
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleHardDeleteSelected = async () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) return
|
||||
setBatchBusy(true)
|
||||
try {
|
||||
const result = await batchDeleteProducts(ids, true)
|
||||
toast.success(`Permanently deleted ${result.deleted} product${result.deleted !== 1 ? 's' : ''}`)
|
||||
setSelected(new Set())
|
||||
setConfirmDeleteHard(false)
|
||||
qc.invalidateQueries({ queryKey: ['products'] })
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.detail || 'Delete failed')
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -204,9 +213,21 @@ export default function ProductLibraryPage() {
|
||||
<Library size={22} className="text-accent" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-content">Product Library</h1>
|
||||
<p className="text-sm text-content-muted">
|
||||
{products ? `${products.length} products` : isLoading ? 'Loading…' : '0 products'}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-sm text-content-muted">
|
||||
<span>{products ? `${products.length} products` : isLoading ? 'Loading\u2026' : '0 products'}</span>
|
||||
{products && products.length > 0 && (
|
||||
<button
|
||||
onClick={toggleAll}
|
||||
className="flex items-center gap-1 hover:text-content transition-colors"
|
||||
>
|
||||
{allSelected ? <CheckSquare size={13} /> : <Square size={13} />}
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</button>
|
||||
)}
|
||||
{selected.size > 0 && (
|
||||
<span className="text-accent font-medium">{selected.size} selected</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -433,35 +454,63 @@ 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 ───────────────────────────────────────── */}
|
||||
{/* ── Floating selection action bar (MediaBrowser pattern) ───── */}
|
||||
{selected.size > 0 && (
|
||||
<div
|
||||
className="fixed bottom-6 z-50 flex items-center gap-4 px-5 py-3 bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10"
|
||||
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 px-5 py-3 rounded-xl shadow-2xl border border-border-default"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{selected.size} selected
|
||||
<span className="text-sm font-medium text-content">
|
||||
{selected.size} product{selected.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="w-px h-5 bg-border-default" />
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={deleteMut.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded text-sm font-medium transition-colors disabled:opacity-50"
|
||||
onClick={handleDeactivateSelected}
|
||||
disabled={batchBusy}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-content-secondary hover:text-content transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{deleteMut.isPending ? 'Deleting\u2026' : 'Delete'}
|
||||
{batchBusy && !confirmDeleteHard
|
||||
? <><Loader2 size={14} className="animate-spin" /> Deactivating…</>
|
||||
: <><EyeOff size={14} /> Deactivate</>
|
||||
}
|
||||
</button>
|
||||
<div className="w-px h-5 bg-border-default" />
|
||||
{confirmDeleteHard ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-500 font-medium">
|
||||
Delete {selected.size} permanently?
|
||||
</span>
|
||||
<button
|
||||
onClick={handleHardDeleteSelected}
|
||||
disabled={batchBusy}
|
||||
className="flex items-center gap-1 text-sm font-medium text-white bg-red-500 hover:bg-red-600 px-2.5 py-1 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{batchBusy
|
||||
? <><Loader2 size={12} className="animate-spin" /> Deleting…</>
|
||||
: <><Trash2 size={12} /> Confirm</>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteHard(false)}
|
||||
disabled={batchBusy}
|
||||
className="text-sm text-content-muted hover:text-content transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDeleteHard(true)}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-red-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} /> Delete permanently
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-5 bg-border-default" />
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="flex items-center gap-1 px-2 py-1.5 text-gray-400 hover:text-white text-sm transition-colors"
|
||||
title="Clear selection"
|
||||
onClick={() => { setSelected(new Set()); setConfirmDeleteHard(false) }}
|
||||
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
|
||||
>
|
||||
<X size={14} /> Clear
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user