feat: per-order-line material override — override materials for individual renders

- Add `material_override` nullable column on OrderLine (DB migration)
- Line override takes priority over OutputType override
- PATCH /orders/{id}/lines/{id} endpoint to update material_override
- Inline dropdown on each order line in the OrderDetail page
- Amber background when override is active
- Same output type, different material per line — no need to create a new output type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 14:33:00 +01:00
parent 7e57dba085
commit 9d6def84c1
7 changed files with 115 additions and 7 deletions
+10
View File
@@ -55,6 +55,7 @@ export interface OrderLine {
render_log: RenderLog | null
render_started_at: string | null
render_completed_at: string | null
material_override: string | null
notes: string | null
created_at: string
updated_at: string
@@ -66,6 +67,7 @@ export interface OrderLineCreate {
render_position_id?: string | null
global_render_position_id?: string | null
gewuenschte_bildnummer?: string | null
material_override?: string | null
notes?: string | null
}
@@ -242,6 +244,14 @@ export async function dispatchLineRender(orderId: string, lineId: string) {
return res.data
}
export async function patchOrderLine(orderId: string, lineId: string, data: { material_override?: string | null }) {
const res = await api.patch<{ updated: boolean; line_id: string }>(
`/orders/${orderId}/lines/${lineId}`,
data
)
return res.data
}
export async function cancelOrderRenders(orderId: string) {
const res = await api.post<{ cancelled: number; order_status: string; errors: string[] | null }>(
`/orders/${orderId}/cancel-renders`
+28 -2
View File
@@ -12,8 +12,8 @@ import {
XCircle, RotateCw, Info,
} from 'lucide-react'
import { toast } from 'sonner'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine } from '../api/orders'
import { checkOrderMaterials, type UnmappedMaterial } from '../api/materials'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine, patchOrderLine } from '../api/orders'
import { checkOrderMaterials, listMaterials, type UnmappedMaterial, type Material } from '../api/materials'
import UnmappedMaterialsDialog from '../components/orders/UnmappedMaterialsDialog'
import type { OrderItem, OrderLine } from '../api/orders'
import { listOutputTypes } from '../api/outputTypes'
@@ -903,6 +903,18 @@ function OrderLineRow({
onError: (e: any) => toast.error(e.response?.data?.detail || 'Reject failed'),
})
const { data: allMats } = useQuery({ queryKey: ['materials'], queryFn: listMaterials })
const libMats = (allMats ?? []).filter((m: Material) => m.schaeffler_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name))
const overrideMut = useMutation({
mutationFn: (val: string | null) => patchOrderLine(orderId, line.id, { material_override: val }),
onSuccess: () => {
toast.success(line.material_override ? 'Material override updated' : 'Material override set')
qc.invalidateQueries({ queryKey: ['order', orderId] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
})
const canCancel = isPrivileged && (line.render_status === 'processing' || line.render_status === 'pending') && line.output_type_id
const canRejectLine = isPrivileged && line.item_status !== 'rejected'
@@ -979,6 +991,20 @@ function OrderLineRow({
{line.render_position_name}
</span>
)}
{isPrivileged && (
<select
className="text-[10px] border border-border-default rounded px-1 py-0.5 w-full mt-1"
style={{ backgroundColor: line.material_override ? 'rgba(245, 158, 11, 0.1)' : 'var(--color-bg-surface)' }}
value={line.material_override ?? ''}
onChange={(e) => overrideMut.mutate(e.target.value || null)}
title="Material override — apply a single material to all parts for this render"
>
<option value="">No material override</option>
{libMats.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
))}
</select>
)}
</div>
</td>