feat: per-position camera settings, material alias dialog, product delete, media browser links

- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 12:16:37 +01:00
parent 0020376702
commit b583b0d7a2
48 changed files with 1827 additions and 376 deletions
+34
View File
@@ -87,3 +87,37 @@ export async function seedAliases(): Promise<{ inserted: number; total: number }
const res = await api.post<{ inserted: number; total: number }>('/materials/seed-aliases')
return res.data
}
// --- Material check / batch alias ---
export interface MaterialSuggestion {
id: string
name: string
schaeffler_code: string
}
export interface UnmappedMaterial {
raw_name: string
suggestions: MaterialSuggestion[]
}
export interface UnmappedMaterialCheck {
unmapped: UnmappedMaterial[]
total_materials: number
mapped_count: number
}
export async function checkOrderMaterials(orderId: string): Promise<UnmappedMaterialCheck> {
const res = await api.get<UnmappedMaterialCheck>(`/orders/${orderId}/check-materials`)
return res.data
}
export async function batchCreateAliases(
mappings: Array<{ alias: string; material_id: string }>
): Promise<{ created: number; skipped: number }> {
const res = await api.post<{ created: number; skipped: number }>(
'/materials/batch-aliases',
{ mappings }
)
return res.data
}
+3
View File
@@ -133,3 +133,6 @@ export const archiveMediaAsset = (id: string): Promise<void> =>
export const deleteMediaAssetPermanent = (id: string): Promise<void> =>
api.delete(`/media/${id}/permanent`).then(() => undefined)
export const batchDeleteAssets = (ids: string[]): Promise<{ deleted: number; requested: number }> =>
api.post('/media/batch-delete', ids).then(r => r.data)
+7
View File
@@ -235,6 +235,13 @@ export async function cancelLineRender(orderId: string, lineId: string) {
return res.data
}
export async function dispatchLineRender(orderId: string, lineId: string) {
const res = await api.post<{ dispatched: boolean; line_id: string }>(
`/orders/${orderId}/lines/${lineId}/dispatch-render`
)
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`
+6
View File
@@ -8,6 +8,8 @@ export interface GlobalRenderPosition {
rotation_z: number
is_default: boolean
sort_order: number
focal_length_mm: number | null
sensor_width_mm: number | null
created_at: string
updated_at: string
}
@@ -19,6 +21,8 @@ export interface GlobalRenderPositionCreate {
rotation_z?: number
is_default?: boolean
sort_order?: number
focal_length_mm?: number | null
sensor_width_mm?: number | null
}
export interface GlobalRenderPositionPatch {
@@ -28,6 +32,8 @@ export interface GlobalRenderPositionPatch {
rotation_z?: number
is_default?: boolean
sort_order?: number
focal_length_mm?: number | null
sensor_width_mm?: number | null
}
export async function listGlobalRenderPositions(): Promise<GlobalRenderPosition[]> {
+20
View File
@@ -39,6 +39,26 @@ export async function createRenderTemplate(formData: FormData): Promise<RenderTe
return data;
}
export async function duplicateRenderTemplate(
sourceId: string,
overrides: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit'>> & { output_type_ids?: string[] },
): Promise<RenderTemplate> {
const fd = new FormData();
fd.append('name', overrides.name || 'Untitled (copy)');
fd.append('clone_blend_from', sourceId);
fd.append('category_key', overrides.category_key || '');
fd.append('output_type_ids', (overrides.output_type_ids || []).join(','));
fd.append('target_collection', overrides.target_collection || 'Product');
fd.append('material_replace_enabled', String(overrides.material_replace_enabled ?? false));
fd.append('lighting_only', String(overrides.lighting_only ?? false));
fd.append('shadow_catcher_enabled', String(overrides.shadow_catcher_enabled ?? false));
fd.append('camera_orbit', String(overrides.camera_orbit ?? true));
const { data } = await api.post<RenderTemplate>('/render-templates', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data;
}
export async function updateRenderTemplate(
id: string,
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_ids' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'is_active'>>,
+1 -1
View File
@@ -146,7 +146,7 @@ export interface CeleryWorkersResponse {
}
export interface ScaleRequest {
service: 'render-worker' | 'worker' | 'worker-thumbnail'
service: 'render-worker' | 'worker'
count: number
}