fix(critical): SQLAlchemy mapper crash + material matching for USD renders + kanban drag-to-reject

- beat_tasks.py: import app.models at module level so SQLAlchemy can
  resolve relationship("Template") and relationship("User") when domain
  models are imported in isolation inside task functions. Fixes all
  beat tasks (batch_render_notifications, recover_stuck_cad_files) that
  crashed every 60s with mapper initialization error.

- _blender_materials.py: build_mat_map_lower() now adds a slug-normalized
  key variant (re.sub([^a-z0-9]+, _, kl)) for each mat_map entry. OCC
  part names like 'F-802007_TR4-D1-H122AG' → slug 'f_802007_tr4_d1_h122ag'
  now matches USD-imported Blender objects. Existing prefix fallback
  (key.startswith(part_key)) catches AF-suffix variants.

- Orders.tsx: kanban drag-to-reject implemented. submitted/processing
  cards are draggable (cursor-grab). Rejected column highlights with
  red ring on drag-over. Drop opens reject reason modal via createPortal.
  Confirm calls rejectOrder() mutation + invalidates orders cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 22:21:46 +01:00
parent 71584edce6
commit 8e1cd41868
4 changed files with 134 additions and 7 deletions
+100 -7
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate } from 'react-router-dom'
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import {
Plus, Package, Trash2, X, Search, SlidersHorizontal,
LayoutGrid, LayoutList, Calendar, FileSpreadsheet,
@@ -8,7 +9,7 @@ import {
ChevronRight, Filter,
} from 'lucide-react'
import { toast } from 'sonner'
import { listOrders, searchOrders, deleteOrder } from '../api/orders'
import { listOrders, searchOrders, deleteOrder, rejectOrder } from '../api/orders'
import { fetchThumbnailBlob } from '../api/cad'
import type { Order, OrderDetail, OrderItem } from '../api/orders'
import ConfirmModal from '../components/ConfirmModal'
@@ -51,6 +52,9 @@ export default function OrdersPage() {
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: () => {} })
const [dragRejectOpen, setDragRejectOpen] = useState(false)
const [dragRejectReason, setDragRejectReason] = useState('')
const pendingRejectIdRef = useRef<string | null>(null)
// Auto-switch to list view on narrow screens
useEffect(() => {
@@ -167,6 +171,25 @@ export default function OrdersPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
})
const rejectOrderMut = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
rejectOrder(id, reason),
onSuccess: () => {
toast.success('Order rejected')
setDragRejectOpen(false)
setDragRejectReason('')
pendingRejectIdRef.current = null
qc.invalidateQueries({ queryKey: ['orders'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Reject failed'),
})
const handleDropReject = (orderId: string) => {
pendingRejectIdRef.current = orderId
setDragRejectReason('')
setDragRejectOpen(true)
}
const handleDeleteSelected = () => {
const ids = [...selected]
if (!ids.length) return
@@ -362,6 +385,7 @@ export default function OrdersPage() {
byStatus={byStatus}
visibleStatuses={visibleStatuses}
onNavigate={(id) => navigate(`/orders/${id}`)}
onDropReject={handleDropReject}
/>
) : (
<ListView
@@ -382,6 +406,52 @@ export default function OrdersPage() {
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
{dragRejectOpen && createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => { if (e.target === e.currentTarget) setDragRejectOpen(false) }}
>
<div className="bg-surface rounded-xl shadow-xl p-6 w-full max-w-sm mx-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-content">Reject Order</h2>
<button onClick={() => setDragRejectOpen(false)} className="text-content-muted hover:text-content">
<X size={18} />
</button>
</div>
<p className="text-sm text-content-secondary">Optionally add a rejection reason:</p>
<textarea
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface-muted text-content resize-none focus:outline-none focus:ring-2 focus:ring-accent"
rows={3}
placeholder="Reason (optional)"
value={dragRejectReason}
onChange={(e) => setDragRejectReason(e.target.value)}
autoFocus
/>
<div className="flex gap-2 justify-end">
<button
onClick={() => setDragRejectOpen(false)}
className="btn-secondary text-sm"
>
Cancel
</button>
<button
onClick={() => {
if (pendingRejectIdRef.current) {
rejectOrderMut.mutate({ id: pendingRejectIdRef.current, reason: dragRejectReason })
}
}}
disabled={rejectOrderMut.isPending}
className="btn-danger text-sm"
>
{rejectOrderMut.isPending ? <Loader2 size={14} className="animate-spin" /> : <XCircle size={14} />}
Reject Order
</button>
</div>
</div>
</div>,
document.body
)}
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
{selected.size > 0 && (
<div
@@ -677,19 +747,35 @@ function KanbanBoard({
byStatus,
visibleStatuses,
onNavigate,
onDropReject,
}: {
byStatus: Record<Status, Order[]>
visibleStatuses: Status[]
onNavigate: (id: string) => void
onDropReject?: (orderId: string) => void
}) {
const [dragOverRejected, setDragOverRejected] = useState(false)
return (
<div className="flex-1 overflow-x-auto min-h-0">
<div className="flex gap-4 p-6 h-full min-w-max">
{visibleStatuses.map((status) => {
const meta = STATUS_META[status]
const cards = byStatus[status]
const isRejectedCol = status === 'rejected'
return (
<div key={status} className="flex flex-col w-72 min-w-[272px]">
<div
key={status}
className={`flex flex-col w-72 min-w-[272px] ${isRejectedCol && dragOverRejected ? 'ring-2 ring-red-400 rounded-xl' : ''}`}
onDragOver={isRejectedCol ? (e) => { e.preventDefault(); setDragOverRejected(true) } : undefined}
onDragLeave={isRejectedCol ? () => setDragOverRejected(false) : undefined}
onDrop={isRejectedCol ? (e) => {
e.preventDefault()
setDragOverRejected(false)
const orderId = e.dataTransfer.getData('orderId')
if (orderId && onDropReject) onDropReject(orderId)
} : undefined}
>
<div className={`${meta.header} rounded-t-xl px-4 py-3 flex items-center gap-2`}>
<meta.icon size={16} className="text-white/80 shrink-0" />
<span className="text-white font-semibold text-sm">{meta.label}</span>
@@ -697,10 +783,10 @@ function KanbanBoard({
{cards.length}
</span>
</div>
<div className="flex-1 bg-surface-muted rounded-b-xl p-2 overflow-y-auto space-y-2 min-h-[120px]">
<div className={`flex-1 bg-surface-muted rounded-b-xl p-2 overflow-y-auto space-y-2 min-h-[120px] ${isRejectedCol && dragOverRejected ? 'bg-red-50' : ''}`}>
{cards.length === 0 ? (
<div className="h-20 flex items-center justify-center text-content-muted text-xs">
No orders
<div className={`h-20 flex items-center justify-center text-xs ${isRejectedCol && dragOverRejected ? 'text-red-400 font-medium' : 'text-content-muted'}`}>
{isRejectedCol && dragOverRejected ? 'Drop to reject' : 'No orders'}
</div>
) : (
cards.map((order) => (
@@ -762,12 +848,19 @@ function KanbanCard({
const rp = order.render_progress
const isDraggable = order.status === 'submitted' || order.status === 'processing'
return (
<button
onClick={onNavigate}
draggable={isDraggable}
onDragStart={isDraggable ? (e) => {
e.dataTransfer.setData('orderId', order.id)
e.dataTransfer.effectAllowed = 'move'
} : undefined}
className={`w-full text-left bg-surface rounded-lg shadow-sm border border-border-default
border-l-4 ${meta.card} hover:shadow-md hover:-translate-y-0.5
transition-all duration-150 p-3 group`}
transition-all duration-150 p-3 group ${isDraggable ? 'cursor-grab active:cursor-grabbing' : ''}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<span className="text-sm font-bold text-content font-mono leading-tight">