import { useState, useMemo } from 'react' import { useNavigate, Link } from 'react-router-dom' import { useQuery, keepPreviousData } from '@tanstack/react-query' import { ArrowLeft, ArrowRight, Search, Box, Check, ShoppingCart, Trash2, ChevronDown, ChevronRight, } from 'lucide-react' import { toast } from 'sonner' import { listProducts } from '../api/products' import { listOutputTypes } from '../api/outputTypes' import { createOrder } from '../api/orders' import { estimatePrice } from '../api/pricing' import { listGlobalRenderPositions } from '../api/renderPositions' import { listMaterials } from '../api/materials' import type { Material } from '../api/materials' import type { Product, RenderPosition } from '../api/products' import type { GlobalRenderPosition } from '../api/renderPositions' 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' }, { key: 'CRB', label: 'CRB' }, { key: 'Gleitlager', label: 'Gleitlager' }, { key: 'SRB_TORB', label: 'SRB/TORB' }, { key: 'Linear_schiene', label: 'Linear' }, { key: 'Anschlagplatten', label: 'Anschlag' }, ] type WizardStep = 1 | 2 | 3 // Maps product_id → Set of output_type_id type OutputSelections = Record> // Maps product_id → Set of position_id type PositionSelections = Record> // Maps product_id → Set of global_render_position_id type GlobalPositionSelections = Record> export default function NewProductOrderPage() { const navigate = useNavigate() const [step, setStep] = useState(1) const [searchQ, setSearchQ] = useState('') const [categoryFilter, setCategoryFilter] = useState('') const [selectedProducts, setSelectedProducts] = useState>(new Map()) const [outputSelections, setOutputSelections] = useState({}) const [positionSelections, setPositionSelections] = useState({}) const [globalPositionSelections, setGlobalPositionSelections] = useState({}) const [notes, setNotes] = useState('') const [materialOverride, setMaterialOverride] = useState('') const [lineOverrides, setLineOverrides] = useState>({}) const [submitting, setSubmitting] = useState(false) // ---- Step 1: load products with STEP files ---- const { data: products, isLoading: productsLoading } = useQuery({ queryKey: ['wizard-products', searchQ, categoryFilter], queryFn: () => listProducts({ q: searchQ, category_key: categoryFilter, ready_only: true, limit: 200, }), }) // ---- Step 2: load all output types (we'll filter client-side per product category) ---- const { data: allOutputTypes } = useQuery({ queryKey: ['wizard-output-types'], queryFn: () => listOutputTypes(false), enabled: step >= 2, }) const { data: allGlobalPositions = [] } = useQuery({ queryKey: ['global-render-positions'], queryFn: listGlobalRenderPositions, }) const { data: allMaterials } = useQuery({ queryKey: ['materials'], queryFn: listMaterials, enabled: step >= 3, }) const libMaterials = (allMaterials ?? []).filter((m: Material) => m.hartomat_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name)) function initPositionsForProduct(product: Product, globals: GlobalRenderPosition[] = []) { // Pre-select all per-product positions (if any) if ((product.render_positions?.length ?? 0) > 0) { setPositionSelections((ps) => ({ ...ps, [product.id]: new Set(product.render_positions!.map((p) => p.id)), })) } // Always pre-select all global positions for every product if (globals.length > 0) { setGlobalPositionSelections((gs) => ({ ...gs, [product.id]: new Set(globals.map((g) => g.id)), })) } } function toggleProduct(product: Product) { const willSelect = !selectedProducts.has(product.id) setSelectedProducts((prev) => { const next = new Map(prev) if (next.has(product.id)) { next.delete(product.id) } else { next.set(product.id, product) } return next }) if (willSelect) { initPositionsForProduct(product, allGlobalPositions) } } const allFilteredSelected = (products?.length ?? 0) > 0 && (products ?? []).every((p) => selectedProducts.has(p.id)) function selectAllFiltered() { const toInit = (products ?? []).filter((p) => !selectedProducts.has(p.id)) setSelectedProducts((prev) => { const next = new Map(prev) ;(products ?? []).forEach((p) => next.set(p.id, p)) return next }) toInit.forEach((p) => initPositionsForProduct(p, allGlobalPositions)) } function deselectAllFiltered() { setSelectedProducts((prev) => { const next = new Map(prev) ;(products ?? []).forEach((p) => next.delete(p.id)) return next }) } function getCompatibleOutputTypes(categoryKey: string | null): OutputType[] { if (!allOutputTypes) return [] return allOutputTypes.filter((ot) => ot.compatible_categories.length === 0 || (categoryKey && ot.compatible_categories.includes(categoryKey)) ) } function toggleOutputType(productId: string, outputTypeId: string) { setOutputSelections((prev) => { const set = new Set(prev[productId] || []) if (set.has(outputTypeId)) { set.delete(outputTypeId) } else { set.add(outputTypeId) } return { ...prev, [productId]: set } }) } // Union of all output types compatible with at least one selected product const globalOutputTypes = useMemo(() => { if (!allOutputTypes || selectedProducts.size === 0) return [] const seenIds = new Set() const result: OutputType[] = [] for (const product of selectedProducts.values()) { for (const ot of getCompatibleOutputTypes(product.category_key)) { if (!seenIds.has(ot.id)) { seenIds.add(ot.id) result.push(ot) } } } return result // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedProducts, allOutputTypes]) function toggleOutputTypeGlobal(otId: string) { let compatibleCount = 0 let selectedCount = 0 for (const [productId, product] of selectedProducts) { const compatible = getCompatibleOutputTypes(product.category_key) if (!compatible.some((ot) => ot.id === otId)) continue compatibleCount++ if (outputSelections[productId]?.has(otId)) selectedCount++ } if (compatibleCount === 0) return const shouldSelect = selectedCount < compatibleCount setOutputSelections((prev) => { const next = { ...prev } for (const [productId, product] of selectedProducts) { const compatible = getCompatibleOutputTypes(product.category_key) if (!compatible.some((ot) => ot.id === otId)) continue const set = new Set(prev[productId] || []) if (shouldSelect) set.add(otId) else set.delete(otId) next[productId] = set } return next }) } function togglePosition(productId: string, positionId: string) { setPositionSelections((prev) => { const set = new Set(prev[productId] || []) if (set.has(positionId)) set.delete(positionId) else set.add(positionId) return { ...prev, [productId]: set } }) } // Union of all unique per-product position names across selected products that have per-product positions const globalPositionNames = useMemo(() => { const seen = new Set() const result: string[] = [] for (const product of selectedProducts.values()) { for (const pos of product.render_positions ?? []) { if (!seen.has(pos.name)) { seen.add(pos.name) result.push(pos.name) } } } return result }, [selectedProducts]) // Global positions apply to all selected products const anyProductUsesGlobalPositions = selectedProducts.size > 0 function toggleGlobalPositionForAll(gpId: string) { // Count how many selected products have this global position selected const eligibleCount = selectedProducts.size let selectedCount = 0 for (const [productId] of selectedProducts) { if (globalPositionSelections[productId]?.has(gpId)) selectedCount++ } if (eligibleCount === 0) return const shouldSelect = selectedCount < eligibleCount setGlobalPositionSelections((prev) => { const next = { ...prev } for (const [productId] of selectedProducts) { const set = new Set(prev[productId] || []) if (shouldSelect) set.add(gpId) else set.delete(gpId) next[productId] = set } return next }) } function togglePositionGlobal(positionName: string) { // Count how many products have this position name and how many have it selected let compatibleCount = 0 let selectedCount = 0 for (const [productId, product] of selectedProducts) { const pos = (product.render_positions ?? []).find((p) => p.name === positionName) if (!pos) continue compatibleCount++ if (positionSelections[productId]?.has(pos.id)) selectedCount++ } if (compatibleCount === 0) return const shouldSelect = selectedCount < compatibleCount setPositionSelections((prev) => { const next = { ...prev } for (const [productId, product] of selectedProducts) { const pos = (product.render_positions ?? []).find((p) => p.name === positionName) if (!pos) continue const set = new Set(prev[productId] || []) if (shouldSelect) set.add(pos.id) else set.delete(pos.id) next[productId] = set } return next }) } // Build flat list of order lines for review (Step 3). // Each (product, outputType, position) triple becomes one line. // Global positions apply to ALL products; per-product positions are additional. const orderLines = useMemo(() => { const lines: Array<{ key: string product: Product outputType: OutputType position: RenderPosition | null globalPosition: GlobalRenderPosition | null }> = [] for (const [productId, product] of selectedProducts) { const selectedOts = outputSelections[productId] if (!selectedOts) continue for (const otId of selectedOts) { const ot = allOutputTypes?.find((o) => o.id === otId) if (!ot) continue const selectedPosIds = positionSelections[productId] || new Set() const selectedGlobalIds = globalPositionSelections[productId] || new Set() const hasAny = selectedPosIds.size > 0 || selectedGlobalIds.size > 0 if (!hasAny) { // No position selected — one unpositioned line lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null, globalPosition: null }) } else { // One line per selected global position for (const gpId of selectedGlobalIds) { const gp = allGlobalPositions.find((g) => g.id === gpId) if (gp) lines.push({ key: `${productId}-${otId}-g${gpId}`, product, outputType: ot, position: null, globalPosition: gp }) } // One line per selected per-product position for (const posId of selectedPosIds) { const pos = product.render_positions?.find((p) => p.id === posId) if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos, globalPosition: null }) } } } } return lines }, [selectedProducts, outputSelections, positionSelections, globalPositionSelections, allOutputTypes, allGlobalPositions]) function removeLine(productId: string, outputTypeId: string, positionId: string | null, globalPositionId: string | null) { if (positionId) { setPositionSelections((prev) => { const set = new Set(prev[productId] || []) set.delete(positionId) return { ...prev, [productId]: set } }) } else if (globalPositionId) { setGlobalPositionSelections((prev) => { const set = new Set(prev[productId] || []) set.delete(globalPositionId) return { ...prev, [productId]: set } }) } else { setOutputSelections((prev) => { const set = new Set(prev[productId] || []) set.delete(outputTypeId) return { ...prev, [productId]: set } }) } } // Check that every selected product has at least one output type const allProductsHaveOutputTypes = useMemo(() => { for (const productId of selectedProducts.keys()) { const set = outputSelections[productId] if (!set || set.size === 0) return false } return true }, [selectedProducts, outputSelections]) const totalRenderJobs = useMemo(() => { let count = 0 for (const set of Object.values(outputSelections)) { count += set.size } return count }, [outputSelections]) // Build estimate lines for pricing query const estimateLines = useMemo(() => { return orderLines.map((l) => ({ product_id: l.product.id, output_type_id: l.outputType.id, })) }, [orderLines]) const { data: priceEstimate } = useQuery({ queryKey: ['price-estimate', estimateLines], queryFn: () => estimatePrice(estimateLines), enabled: estimateLines.length > 0 && step >= 2, placeholderData: keepPreviousData, }) // Helper to find per-line price from estimate breakdown function getLinePrice(productId: string, outputTypeId: string): number | null { if (!priceEstimate) return null const match = priceEstimate.breakdown.find( (b) => b.product_id === productId && b.output_type_id === outputTypeId ) return match?.unit_price ?? null } async function handleSubmit() { if (orderLines.length === 0) return setSubmitting(true) try { const result = await createOrder({ notes: notes || undefined, lines: orderLines.map((l) => { const lineOv = lineOverrides[l.key] const override = lineOv === '__none__' ? null : (lineOv || materialOverride || null) return { product_id: l.product.id, output_type_id: l.outputType.id, render_position_id: l.position?.id ?? null, global_render_position_id: l.globalPosition?.id ?? null, material_override: override, } }), }) toast.success(`Draft order ${result.order_number} created — review and submit`) navigate(`/orders/${result.id}`) } catch (e: any) { toast.error(e.response?.data?.detail || 'Failed to create order') } finally { setSubmitting(false) } } return (
{/* Header */}
Back

New Product Order

{/* Step indicator */}
{[ { n: 1, label: 'Select Products' }, { n: 2, label: 'Configure Outputs' }, { n: 3, label: 'Review & Submit' }, ].map(({ n, label }, i) => (
{i > 0 && (
= n ? 'var(--color-accent)' : 'var(--color-border)' }} /> )}
n ? 'bg-status-success-bg text-status-success-text' : 'bg-surface-muted text-content-muted' }`} style={step === n ? { backgroundColor: 'var(--color-accent)' } : undefined} > {step > n ? : n} {label}
))}
{/* ================================================================ */} {/* STEP 1: Select Products */} {/* ================================================================ */} {step === 1 && (
{/* Search + filter bar */}
setSearchQ(e.target.value)} className="w-full pl-9 pr-3 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent" />
{(products?.length ?? 0) > 0 && ( )}
{/* Product grid */} {productsLoading ? (
Loading products...
) : !products?.length ? (
No products with STEP files found.
) : (
{products.map((p) => { const isSelected = selectedProducts.has(p.id) return (
toggleProduct(p)} className={`card cursor-pointer transition-all overflow-hidden relative ${ isSelected ? 'ring-2 ring-accent shadow-md' : 'hover:shadow-md' }`} > {/* Selection checkbox overlay */}
{isSelected && }
{/* Thumbnail */}
{(p.render_image_url || p.thumbnail_url) ? ( {p.name ) : ( )}
{/* Info */}

{p.pim_id}

{p.name || p.pim_id}

{p.category_key && ( {CATEGORIES.find((c) => c.key === p.category_key)?.label || p.category_key} )}
) })}
)} {/* Sticky bottom bar */} {selectedProducts.size > 0 && (
{selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} selected
)}
)} {/* ================================================================ */} {/* STEP 2: Configure Output Types */} {/* ================================================================ */} {step === 2 && (

Select which output types to generate for each product. Only compatible types are shown.

{/* Global toggles — apply to all products at once */} {(globalOutputTypes.length > 0 || globalPositionNames.length > 0 || (anyProductUsesGlobalPositions && allGlobalPositions.length > 0)) && (

Apply to all products

{/* Output types row */} {globalOutputTypes.length > 0 && (

Output Types

{globalOutputTypes.map((ot) => { let compatibleCount = 0 let selectedCount = 0 for (const [productId, product] of selectedProducts) { const compatible = getCompatibleOutputTypes(product.category_key) if (!compatible.some((o) => o.id === ot.id)) continue compatibleCount++ if (outputSelections[productId]?.has(ot.id)) selectedCount++ } const allSel = selectedCount === compatibleCount && compatibleCount > 0 const someSel = selectedCount > 0 && !allSel return ( ) })}
)} {/* Perspectives row — per-product positions (for products that have them) */} {globalPositionNames.length > 0 && (

Perspectives (custom)

{globalPositionNames.map((posName) => { let compatibleCount = 0 let selectedCount = 0 for (const [productId, product] of selectedProducts) { const pos = (product.render_positions ?? []).find((p) => p.name === posName) if (!pos) continue compatibleCount++ if (positionSelections[productId]?.has(pos.id)) selectedCount++ } const allSel = selectedCount === compatibleCount && compatibleCount > 0 const someSel = selectedCount > 0 && !allSel return ( ) })}
)} {/* Perspectives row — global positions (for products without custom positions) */} {anyProductUsesGlobalPositions && allGlobalPositions.length > 0 && (

Perspectives (global)

{allGlobalPositions.map((gp) => { const eligibleCount = selectedProducts.size let selectedCount = 0 for (const [productId] of selectedProducts) { if (globalPositionSelections[productId]?.has(gp.id)) selectedCount++ } const allSel = selectedCount === eligibleCount && eligibleCount > 0 const someSel = selectedCount > 0 && !allSel return ( ) })}
)}
)}
{Array.from(selectedProducts.values()).map((product) => ( toggleOutputType(product.id, otId)} selectedPositions={positionSelections[product.id] || new Set()} onTogglePosition={(posId) => togglePosition(product.id, posId)} globalPositions={allGlobalPositions} selectedGlobalPositions={globalPositionSelections[product.id] || new Set()} onToggleGlobalPosition={(gpId) => setGlobalPositionSelections((prev) => { const set = new Set(prev[product.id] || []) if (set.has(gpId)) set.delete(gpId); else set.add(gpId) return { ...prev, [product.id]: set } })} /> ))}
{/* Bottom bar */}
{selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} · {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''} {priceEstimate && priceEstimate.total > 0 && ( <> · Estimated: {formatCurrency(priceEstimate.total)} )}
)} {/* ================================================================ */} {/* STEP 3: Review & Submit */} {/* ================================================================ */} {step === 3 && (
{orderLines.length === 0 ? (
No render jobs configured. Go back and select output types.
) : ( <>
{orderLines.map((line) => ( ))}
Product Output Type Position Renderer Format Mat Override Price
{(line.product.render_image_url || line.product.thumbnail_url) ? ( ) : ( )}

{line.product.name || line.product.pim_id}

{line.product.pim_id}
{line.outputType.name} {line.position ? ( {line.position.name} ) : line.globalPosition ? ( {line.globalPosition.name} ) : ( )} {line.outputType.renderer} {line.outputType.output_format} {(() => { const price = getLinePrice(line.product.id, line.outputType.id) return price != null ? ( {formatCurrency(price)} ) : ( ) })()}
{/* Material Override */}

Apply a single material to all parts of all products in this order. Leave empty to use each product's own materials.

{/* Notes */}