feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
+899
View File
@@ -0,0 +1,899 @@
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 type { Product, RenderPosition } from '../api/products'
import type { OutputType } from '../api/outputTypes'
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>>
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 [notes, setNotes] = 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,
})
function initPositionsForProduct(product: Product) {
if ((product.render_positions?.length ?? 0) > 0) {
// Default: all positions selected
setPositionSelections((ps) => ({
...ps,
[product.id]: new Set(product.render_positions!.map((p) => p.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)
}
}
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(initPositionsForProduct)
}
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 position names across selected products that have 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])
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.
const orderLines = useMemo(() => {
const lines: Array<{
key: string
product: Product
outputType: OutputType
position: RenderPosition | null
}> = []
for (const [productId, product] of selectedProducts) {
const selectedOts = outputSelections[productId]
if (!selectedOts) continue
const hasPositions = (product.render_positions?.length ?? 0) > 0
for (const otId of selectedOts) {
const ot = allOutputTypes?.find((o) => o.id === otId)
if (!ot) continue
if (hasPositions) {
const selectedPosIds = positionSelections[productId] || new Set()
if (selectedPosIds.size === 0) {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
} else {
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 })
}
}
} else {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
}
}
}
return lines
}, [selectedProducts, outputSelections, positionSelections, allOutputTypes])
function removeLine(productId: string, outputTypeId: string, positionId: string | null) {
if (positionId) {
setPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(positionId)
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) => ({
product_id: l.product.id,
output_type_id: l.outputType.id,
render_position_id: l.position?.id ?? null,
})),
})
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 max-w-6xl mx-auto">
{/* 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) && (
<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 */}
{globalPositionNames.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives</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>
)}
</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)}
/>
))}
</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">{priceEstimate.total.toFixed(2)}</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 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="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">
{line.position.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 text-right">
{(() => {
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="text-content-muted"></span>
)
})()}
</td>
<td className="px-4 py-3">
<button
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.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>
{/* 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">{priceEstimate.total.toFixed(2)}</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,
}: {
product: Product
compatibleTypes: OutputType[]
selected: Set<string>
onToggle: (otId: string) => void
selectedPositions: Set<string>
onTogglePosition: (posId: 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">{ot.price_per_item.toFixed(2)}</span></>
)}
</p>
</div>
</label>
)
})}
</div>
)}
{/* Render position toggles — only shown if product has 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">Render 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>
)}
</div>
)}
</div>
)
}