Files
HartOMat/frontend/src/pages/NewProductOrder.tsx
T

1107 lines
49 KiB
TypeScript

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<string, Set<string>>
// Maps product_id → Set of position_id
type PositionSelections = Record<string, Set<string>>
// Maps product_id → Set of global_render_position_id
type GlobalPositionSelections = Record<string, Set<string>>
export default function NewProductOrderPage() {
const navigate = useNavigate()
const [step, setStep] = useState<WizardStep>(1)
const [searchQ, setSearchQ] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [selectedProducts, setSelectedProducts] = useState<Map<string, Product>>(new Map())
const [outputSelections, setOutputSelections] = useState<OutputSelections>({})
const [positionSelections, setPositionSelections] = useState<PositionSelections>({})
const [globalPositionSelections, setGlobalPositionSelections] = useState<GlobalPositionSelections>({})
const [notes, setNotes] = useState('')
const [materialOverride, setMaterialOverride] = useState<string>('')
const [lineOverrides, setLineOverrides] = useState<Record<string, string>>({})
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<string>()
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<string>()
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 (
<div className="p-8">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<Link to="/orders/new" className="btn-secondary"><ArrowLeft size={16} />Back</Link>
<h1 className="text-2xl font-bold text-content">New Product Order</h1>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2 mb-8">
{[
{ n: 1, label: 'Select Products' },
{ n: 2, label: 'Configure Outputs' },
{ n: 3, label: 'Review & Submit' },
].map(({ n, label }, i) => (
<div key={n} className="flex items-center gap-2">
{i > 0 && (
<div
className="w-8 h-px"
style={{ backgroundColor: step >= n ? 'var(--color-accent)' : 'var(--color-border)' }}
/>
)}
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
step === n
? 'text-white'
: step > n
? 'bg-status-success-bg text-status-success-text'
: 'bg-surface-muted text-content-muted'
}`}
style={step === n ? { backgroundColor: 'var(--color-accent)' } : undefined}
>
<span className="w-5 h-5 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
{step > n ? <Check size={12} /> : n}
</span>
{label}
</div>
</div>
))}
</div>
{/* ================================================================ */}
{/* STEP 1: Select Products */}
{/* ================================================================ */}
{step === 1 && (
<div className="pb-24">
{/* Search + filter bar */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
type="text"
placeholder="Search by name or PIM-ID..."
value={searchQ}
onChange={(e) => 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"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent"
>
<option value="">All categories</option>
{CATEGORIES.map((c) => (
<option key={c.key} value={c.key}>{c.label}</option>
))}
</select>
{(products?.length ?? 0) > 0 && (
<button
onClick={allFilteredSelected ? deselectAllFiltered : selectAllFiltered}
className="px-3 py-2 rounded-lg border border-border-default text-sm text-content-secondary hover:border-accent hover:text-accent transition-colors whitespace-nowrap"
title={allFilteredSelected ? 'Deselect all currently visible products' : 'Select all currently visible products'}
>
{allFilteredSelected
? `Deselect all (${products!.length})`
: `Select all (${products!.length})`}
</button>
)}
</div>
{/* Product grid */}
{productsLoading ? (
<div className="text-center py-12 text-content-muted">Loading products...</div>
) : !products?.length ? (
<div className="text-center py-12 text-content-muted">No products with STEP files found.</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{products.map((p) => {
const isSelected = selectedProducts.has(p.id)
return (
<div
key={p.id}
onClick={() => toggleProduct(p)}
className={`card cursor-pointer transition-all overflow-hidden relative ${
isSelected
? 'ring-2 ring-accent shadow-md'
: 'hover:shadow-md'
}`}
>
{/* Selection checkbox overlay */}
<div
className={`absolute top-2 right-2 w-6 h-6 rounded-full border-2 flex items-center justify-center z-10 transition-colors ${
isSelected ? 'text-white' : 'bg-surface border-border-default'
}`}
style={isSelected ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{isSelected && <Check size={14} />}
</div>
{/* Thumbnail */}
<div className="h-32 bg-surface-muted flex items-center justify-center overflow-hidden">
{(p.render_image_url || p.thumbnail_url) ? (
<img
src={p.render_image_url || p.thumbnail_url!}
alt={p.name || p.pim_id}
className="h-full w-full object-contain"
/>
) : (
<Box size={36} className="text-content-muted" />
)}
</div>
{/* Info */}
<div className="p-3">
<p className="text-xs text-content-muted font-mono">{p.pim_id}</p>
<p className="text-sm font-medium text-content truncate">
{p.name || p.pim_id}
</p>
{p.category_key && (
<span className="inline-block mt-1 text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text">
{CATEGORIES.find((c) => c.key === p.category_key)?.label || p.category_key}
</span>
)}
</div>
</div>
)
})}
</div>
)}
{/* Sticky bottom bar */}
{selectedProducts.size > 0 && (
<div className="fixed bottom-0 left-60 right-0 bg-surface border-t border-border-default px-8 py-4 flex items-center justify-between z-50 shadow-lg">
<span className="text-sm font-medium text-content-secondary">
<ShoppingCart size={16} className="inline mr-2" />
{selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={() => setStep(2)}
className="btn-primary"
>
Next <ArrowRight size={16} />
</button>
</div>
)}
</div>
)}
{/* ================================================================ */}
{/* STEP 2: Configure Output Types */}
{/* ================================================================ */}
{step === 2 && (
<div className="pb-24">
<p className="text-sm text-content-muted mb-4">
Select which output types to generate for each product. Only compatible types are shown.
</p>
{/* Global toggles — apply to all products at once */}
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0 || (anyProductUsesGlobalPositions && allGlobalPositions.length > 0)) && (
<div className="card p-4 mb-4 space-y-3">
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">
Apply to all products
</p>
{/* Output types row */}
{globalOutputTypes.length > 0 && (
<div>
<p className="text-xs text-content-muted mb-1.5">Output Types</p>
<div className="flex flex-wrap gap-2">
{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 (
<button
key={ot.id}
onClick={() => toggleOutputTypeGlobal(ot.id)}
title={`${selectedCount} / ${compatibleCount} product${compatibleCount !== 1 ? 's' : ''} selected`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
allSel
? 'text-white'
: someSel
? 'bg-status-success-bg text-status-success-text border-green-400'
: 'bg-surface text-content-secondary border-border-default hover:border-accent hover:text-accent'
}`}
style={allSel ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{allSel && <Check size={12} />}
{ot.name}
{selectedProducts.size > 1 && (
<span
className={`text-xs ${someSel ? 'text-status-success-text' : allSel ? '' : 'text-content-muted'}`}
style={allSel ? { color: 'rgba(255,255,255,0.7)' } : undefined}
>
{selectedCount}/{compatibleCount}
</span>
)}
</button>
)
})}
</div>
</div>
)}
{/* Perspectives row — per-product positions (for products that have them) */}
{globalPositionNames.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives (custom)</p>
<div className="flex flex-wrap gap-2">
{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 (
<button
key={posName}
onClick={() => togglePositionGlobal(posName)}
title={`${selectedCount} / ${compatibleCount} product${compatibleCount !== 1 ? 's' : ''} selected`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
allSel
? 'bg-purple-600 text-white border-purple-600'
: someSel
? 'bg-purple-100 text-purple-700 border-purple-400'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{allSel && <Check size={12} />}
{posName}
{selectedProducts.size > 1 && (
<span className={`text-xs ${allSel ? 'text-white/70' : someSel ? 'text-purple-500' : 'text-content-muted'}`}>
{selectedCount}/{compatibleCount}
</span>
)}
</button>
)
})}
</div>
</div>
)}
{/* Perspectives row — global positions (for products without custom positions) */}
{anyProductUsesGlobalPositions && allGlobalPositions.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives (global)</p>
<div className="flex flex-wrap gap-2">
{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 (
<button
key={gp.id}
onClick={() => toggleGlobalPositionForAll(gp.id)}
title={`${selectedCount} / ${eligibleCount} product${eligibleCount !== 1 ? 's' : ''} selected`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
allSel
? 'bg-purple-600 text-white border-purple-600'
: someSel
? 'bg-purple-100 text-purple-700 border-purple-400'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{allSel && <Check size={12} />}
{gp.name}
{gp.is_default && !allSel && <span className="text-xs opacity-60"></span>}
{selectedProducts.size > 1 && eligibleCount > 0 && (
<span className={`text-xs ${allSel ? 'text-white/70' : someSel ? 'text-purple-500' : 'text-content-muted'}`}>
{selectedCount}/{eligibleCount}
</span>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)}
<div className="space-y-3">
{Array.from(selectedProducts.values()).map((product) => (
<ProductOutputRow
key={product.id}
product={product}
compatibleTypes={getCompatibleOutputTypes(product.category_key)}
selected={outputSelections[product.id] || new Set()}
onToggle={(otId) => 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 }
})}
/>
))}
</div>
{/* Bottom bar */}
<div className="fixed bottom-0 left-60 right-0 bg-surface border-t border-border-default px-8 py-4 flex items-center justify-between z-50 shadow-lg">
<div className="flex items-center gap-4">
<button onClick={() => setStep(1)} className="btn-secondary">
<ArrowLeft size={16} /> Back
</button>
<span className="text-sm text-content-muted">
{selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} &middot; {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
{priceEstimate && priceEstimate.total > 0 && (
<> &middot; Estimated: <span className="font-semibold text-content-secondary">{formatCurrency(priceEstimate.total)}</span></>
)}
</span>
</div>
<button
onClick={() => setStep(3)}
disabled={!allProductsHaveOutputTypes}
className="btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
Next <ArrowRight size={16} />
</button>
</div>
</div>
)}
{/* ================================================================ */}
{/* STEP 3: Review & Submit */}
{/* ================================================================ */}
{step === 3 && (
<div className="pb-24">
{orderLines.length === 0 ? (
<div className="card p-8 text-center text-content-muted">
No render jobs configured. Go back and select output types.
</div>
) : (
<>
<div className="card overflow-hidden mb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-default bg-surface-alt text-left">
<th className="px-4 py-3 font-medium text-content-secondary">Product</th>
<th className="px-4 py-3 font-medium text-content-secondary">Output Type</th>
<th className="px-4 py-3 font-medium text-content-secondary">Position</th>
<th className="px-4 py-3 font-medium text-content-secondary">Renderer</th>
<th className="px-4 py-3 font-medium text-content-secondary">Format</th>
<th className="px-4 py-3 font-medium text-content-secondary">Mat Override</th>
<th className="px-4 py-3 font-medium text-content-secondary text-right">Price</th>
<th className="px-4 py-3 font-medium text-content-secondary w-12"></th>
</tr>
</thead>
<tbody>
{orderLines.map((line) => (
<tr key={line.key} className="border-b border-border-light hover:bg-surface-hover">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded bg-surface-muted flex items-center justify-center overflow-hidden shrink-0">
{(line.product.render_image_url || line.product.thumbnail_url) ? (
<img src={line.product.render_image_url || line.product.thumbnail_url!} className="w-full h-full object-contain" />
) : (
<Box size={18} className="text-content-muted" />
)}
</div>
<div className="min-w-0">
<p className="font-medium text-content truncate">
{line.product.name || line.product.pim_id}
</p>
<span className="text-xs text-content-muted font-mono">{line.product.pim_id}</span>
</div>
</div>
</td>
<td className="px-4 py-3 text-content-secondary">{line.outputType.name}</td>
<td className="px-4 py-3">
{line.position ? (
<span className="badge-purple">{line.position.name}</span>
) : line.globalPosition ? (
<span className="badge-purple opacity-70" title="Global position">{line.globalPosition.name}</span>
) : (
<span className="text-content-muted text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-content-muted">{line.outputType.renderer}</td>
<td className="px-4 py-3 text-content-muted uppercase">{line.outputType.output_format}</td>
<td className="px-4 py-3">
<select
className="text-xs border border-border-default rounded px-1.5 py-1 w-full"
style={{ backgroundColor: (lineOverrides[line.key] || materialOverride) ? 'rgba(245, 158, 11, 0.1)' : 'var(--color-bg-surface)' }}
value={lineOverrides[line.key] ?? ''}
onChange={(e) => setLineOverrides((prev) => ({ ...prev, [line.key]: e.target.value }))}
>
<option value="">{materialOverride ? `Global: ${materialOverride.replace('HARTOMAT_', '').replace(/_/g, ' ')}` : 'No override'}</option>
{materialOverride && <option value="__none__"> No override (clear) </option>}
{libMaterials.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('HARTOMAT_', '').replace(/_/g, ' ')}</option>
))}
</select>
</td>
<td className="px-4 py-3 text-right">
{(() => {
const price = getLinePrice(line.product.id, line.outputType.id)
return price != null ? (
<span className="font-medium text-content-secondary">{formatCurrency(price)}</span>
) : (
<span className="text-content-muted"></span>
)
})()}
</td>
<td className="px-4 py-3">
<button
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null, line.globalPosition?.id ?? null)}
className="text-content-muted hover:text-red-500 transition-colors"
title="Remove this render job from the order"
>
<Trash2 size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Material Override */}
<div className="card p-4 mb-4">
<label className="block text-sm font-medium text-content-secondary mb-1">
Material Override (optional)
</label>
<p className="text-xs text-content-muted mb-2">
Apply a single material to all parts of all products in this order. Leave empty to use each product's own materials.
</p>
<select
value={materialOverride}
onChange={(e) => setMaterialOverride(e.target.value)}
className="w-full max-w-md px-3 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent"
style={{ backgroundColor: materialOverride ? 'rgba(245, 158, 11, 0.1)' : 'var(--color-bg-surface)' }}
>
<option value="">No material override</option>
{libMaterials.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name}</option>
))}
</select>
</div>
{/* Notes */}
<div className="card p-4 mb-4">
<label className="block text-sm font-medium text-content-secondary mb-1">
Order Notes (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any special instructions..."
rows={3}
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent resize-none"
/>
</div>
</>
)}
{/* Bottom bar */}
<div className="fixed bottom-0 left-60 right-0 bg-surface border-t border-border-default px-8 py-4 flex items-center justify-between z-50 shadow-lg">
<button onClick={() => setStep(2)} className="btn-secondary">
<ArrowLeft size={16} /> Back
</button>
<div className="flex items-center gap-3">
<span className="text-sm text-content-muted">
{orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
{priceEstimate && priceEstimate.total > 0 && (
<> &middot; Estimated: <span className="font-semibold text-content-secondary">{formatCurrency(priceEstimate.total)}</span></>
)}
</span>
<button
onClick={handleSubmit}
disabled={submitting || orderLines.length === 0}
className="btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
{submitting ? 'Creating...' : 'Create Order'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
// ---- Sub-component: expandable product row for step 2 ----
function ProductOutputRow({
product,
compatibleTypes,
selected,
onToggle,
selectedPositions,
onTogglePosition,
globalPositions,
selectedGlobalPositions,
onToggleGlobalPosition,
}: {
product: Product
compatibleTypes: OutputType[]
selected: Set<string>
onToggle: (otId: string) => void
selectedPositions: Set<string>
onTogglePosition: (posId: string) => void
globalPositions: GlobalRenderPosition[]
selectedGlobalPositions: Set<string>
onToggleGlobalPosition: (gpId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
return (
<div className="card overflow-hidden">
{/* Header row */}
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-surface-hover"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown size={16} className="text-content-muted" /> : <ChevronRight size={16} className="text-content-muted" />}
<div className="w-10 h-10 rounded bg-surface-muted flex items-center justify-center overflow-hidden shrink-0">
{(product.render_image_url || product.thumbnail_url) ? (
<img src={product.render_image_url || product.thumbnail_url!} className="w-full h-full object-contain" />
) : (
<Box size={18} className="text-content-muted" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-content truncate">
{product.name || product.pim_id}
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted font-mono">{product.pim_id}</span>
{product.category_key && (
<span className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text">
{product.category_key}
</span>
)}
</div>
</div>
<span className="text-xs text-content-muted">
{selected.size} selected
</span>
</div>
{/* Output type checkboxes */}
{expanded && (
<div className="px-4 pb-4 pt-1 border-t border-border-light">
{compatibleTypes.length === 0 ? (
<p className="text-sm text-content-muted py-2">No compatible output types found.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-2">
{compatibleTypes.map((ot) => {
const checked = selected.has(ot.id)
return (
<label
key={ot.id}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg border cursor-pointer transition-colors ${
checked ? '' : 'border-border-default'
}`}
style={checked ? { borderColor: 'var(--color-accent)', backgroundColor: 'var(--color-accent-light)' } : undefined}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(ot.id)}
className=""
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-content">{ot.name}</p>
<p className="text-xs text-content-muted">
{ot.renderer} &middot; {ot.output_format.toUpperCase()}
{ot.price_per_item != null && (
<> &middot; <span className="text-emerald-600 font-medium">{formatCurrency(ot.price_per_item)}</span></>
)}
</p>
</div>
</label>
)
})}
</div>
)}
{/* Per-product custom positions */}
{(product.render_positions?.length ?? 0) > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Custom Positions</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => product.render_positions!.forEach((p) => !selectedPositions.has(p.id) && onTogglePosition(p.id))}
>
All
</button>
<span className="text-content-muted text-xs">·</span>
<button
className="text-xs text-content-muted hover:underline"
onClick={() => product.render_positions!.forEach((p) => selectedPositions.has(p.id) && onTogglePosition(p.id))}
>
None
</button>
</div>
<div className="flex flex-wrap gap-2">
{product.render_positions!.map((pos) => {
const active = selectedPositions.has(pos.id)
return (
<button
key={pos.id}
onClick={() => onTogglePosition(pos.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
active
? 'bg-purple-600 text-white border-purple-600'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{active && <Check size={12} />}
{pos.name}
{pos.is_default && !active && (
<span className="text-xs text-content-muted">(default)</span>
)}
</button>
)
})}
</div>
</div>
)}
{/* Global position toggles — always shown for all products */}
{globalPositions.length > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Perspectives</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => globalPositions.forEach((g) => !selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
All
</button>
<span className="text-content-muted text-xs">·</span>
<button
className="text-xs text-content-muted hover:underline"
onClick={() => globalPositions.forEach((g) => selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
None
</button>
</div>
<div className="flex flex-wrap gap-2">
{globalPositions.map((gp) => {
const active = selectedGlobalPositions.has(gp.id)
return (
<button
key={gp.id}
onClick={() => onToggleGlobalPosition(gp.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
active
? 'bg-purple-600 text-white border-purple-600'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{active && <Check size={12} />}
{gp.name}
{gp.is_default && <span className="text-xs opacity-60 ml-0.5">★</span>}
</button>
)
})}
</div>
</div>
)}
</div>
)}
</div>
)
}