1107 lines
49 KiB
TypeScript
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' : ''} · {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
|
|
{priceEstimate && priceEstimate.total > 0 && (
|
|
<> · 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 && (
|
|
<> · 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} · {ot.output_format.toUpperCase()}
|
|
{ot.price_per_item != null && (
|
|
<> · <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>
|
|
)
|
|
}
|