feat: initial commit
This commit is contained in:
@@ -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' : ''} · {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
|
||||
{priceEstimate && priceEstimate.total > 0 && (
|
||||
<> · 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 && (
|
||||
<> · 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} · {ot.output_format.toUpperCase()}
|
||||
{ot.price_per_item != null && (
|
||||
<> · <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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user