feat: initial commit
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/auth'
|
||||
import Layout from './components/layout/Layout'
|
||||
import LoginPage from './pages/Login'
|
||||
import DashboardPage from './pages/Dashboard'
|
||||
import OrdersPage from './pages/Orders'
|
||||
import OrderDetailPage from './pages/OrderDetail'
|
||||
import NewOrderPage from './pages/NewOrder'
|
||||
import UploadPage from './pages/Upload'
|
||||
import AdminPage from './pages/Admin'
|
||||
import CadPreviewPage from './pages/CadPreview'
|
||||
import MaterialsPage from './pages/Materials'
|
||||
import WorkerActivityPage from './pages/WorkerActivity'
|
||||
import ProductLibraryPage from './pages/ProductLibrary'
|
||||
import ProductDetailPage from './pages/ProductDetail'
|
||||
import NewProductOrderPage from './pages/NewProductOrder'
|
||||
import NotificationsPage from './pages/Notifications'
|
||||
import PreferencesPage from './pages/Preferences'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { token, user } = useAuthStore()
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
if (user?.role !== 'admin' && user?.role !== 'project_manager') return <Navigate to="/" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
<Route path="orders/new" element={<NewOrderPage />} />
|
||||
<Route path="orders/new/product" element={<NewProductOrderPage />} />
|
||||
<Route path="orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="materials" element={<MaterialsPage />} />
|
||||
<Route path="activity" element={<WorkerActivityPage />} />
|
||||
<Route path="products" element={<ProductLibraryPage />} />
|
||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="preferences" element={<PreferencesPage />} />
|
||||
<Route path="cad/:id" element={<CadPreviewPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import api from './client'
|
||||
|
||||
export interface TopLevelSummary {
|
||||
total_orders: number
|
||||
completed_orders: number
|
||||
total_revenue: number
|
||||
total_rendering_items: number
|
||||
}
|
||||
|
||||
export interface ThroughputPoint {
|
||||
week: string
|
||||
count: number
|
||||
completed: number
|
||||
}
|
||||
|
||||
export interface RevenuePoint {
|
||||
month: string
|
||||
revenue: number
|
||||
order_count: number
|
||||
}
|
||||
|
||||
export interface ProcessingTimeStats {
|
||||
avg_submit_to_complete_s: number | null
|
||||
avg_submit_to_processing_s: number | null
|
||||
p50_s: number | null
|
||||
p95_s: number | null
|
||||
}
|
||||
|
||||
export interface ItemStatusBreakdown {
|
||||
pending: number
|
||||
approved: number
|
||||
rejected: number
|
||||
}
|
||||
|
||||
export interface RenderTimeBreakdown {
|
||||
avg_stl_s: number | null
|
||||
avg_render_s: number | null
|
||||
avg_total_s: number | null
|
||||
sample_count: number
|
||||
}
|
||||
|
||||
export interface CategoryCount {
|
||||
category: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ProductCategoryStats {
|
||||
unique_products_rendered: number
|
||||
total_products: number
|
||||
products_with_cad: number
|
||||
products_by_category: CategoryCount[]
|
||||
}
|
||||
|
||||
export interface OutputTypeUsagePoint {
|
||||
output_type: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface RenderStatusDistribution {
|
||||
pending: number
|
||||
processing: number
|
||||
completed: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface RendererUsagePoint {
|
||||
renderer: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface TopProductEntry {
|
||||
pim_id: string
|
||||
product_name: string | null
|
||||
category: string
|
||||
order_count: number
|
||||
}
|
||||
|
||||
export interface CategoryRevenueEntry {
|
||||
category: string
|
||||
order_count: number
|
||||
revenue: number
|
||||
}
|
||||
|
||||
export interface RenderBackendStatsEntry {
|
||||
backend: string
|
||||
total: number
|
||||
completed: number
|
||||
failed: number
|
||||
avg_render_s: number | null
|
||||
p50_render_s: number | null
|
||||
}
|
||||
|
||||
export interface RenderTimeByOutputType {
|
||||
output_type: string
|
||||
job_count: number
|
||||
avg_render_s: number | null
|
||||
min_render_s: number | null
|
||||
max_render_s: number | null
|
||||
p50_render_s: number | null
|
||||
}
|
||||
|
||||
export interface OrdersByUserEntry {
|
||||
full_name: string
|
||||
email: string
|
||||
role: string
|
||||
order_count: number
|
||||
revenue: number
|
||||
}
|
||||
|
||||
export interface DashboardKPIs {
|
||||
summary: TopLevelSummary
|
||||
throughput: ThroughputPoint[]
|
||||
revenue: RevenuePoint[]
|
||||
processing_times: ProcessingTimeStats
|
||||
item_status: ItemStatusBreakdown
|
||||
render_times: RenderTimeBreakdown
|
||||
product_stats: ProductCategoryStats
|
||||
output_type_usage: OutputTypeUsagePoint[]
|
||||
render_status: RenderStatusDistribution
|
||||
renderer_usage: RendererUsagePoint[]
|
||||
top_products: TopProductEntry[]
|
||||
orders_by_user: OrdersByUserEntry[]
|
||||
category_revenue: CategoryRevenueEntry[]
|
||||
render_backend_stats: RenderBackendStatsEntry[]
|
||||
render_time_by_output_type: RenderTimeByOutputType[]
|
||||
}
|
||||
|
||||
export async function getDashboardKPIs(
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
): Promise<DashboardKPIs> {
|
||||
const params: Record<string, string> = {}
|
||||
if (dateFrom) params.date_from = dateFrom
|
||||
if (dateTo) params.date_to = dateTo
|
||||
const res = await api.get<DashboardKPIs>('/analytics/dashboard', { params })
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import api from './client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CadObjects {
|
||||
cad_file_id: string
|
||||
original_name: string
|
||||
processing_status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
parsed_objects: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface RegenerateThumbnailResponse {
|
||||
cad_file_id: string
|
||||
original_name: string
|
||||
status: 'queued'
|
||||
task_id: string | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the URL to the thumbnail PNG for a CAD file.
|
||||
* Use directly in <img src={getCadThumbnailUrl(id)} /> – the browser will
|
||||
* handle the authenticated request via the axios interceptor when called
|
||||
* programmatically, or you can construct the URL for use in img tags when
|
||||
* the auth token is set as a header.
|
||||
*
|
||||
* For use in <img> tags without auth headers, prefer fetching as a blob and
|
||||
* creating an object URL (see fetchThumbnailBlob below).
|
||||
*/
|
||||
export function getCadThumbnailUrl(cadFileId: string): string {
|
||||
return `/api/cad/${cadFileId}/thumbnail`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the thumbnail PNG as a Blob and return an object URL suitable for
|
||||
* use in <img src=...> without needing explicit auth headers.
|
||||
* Remember to call URL.revokeObjectURL() when the component unmounts.
|
||||
*/
|
||||
export async function fetchThumbnailBlob(cadFileId: string): Promise<string> {
|
||||
const res = await api.get<Blob>(`/cad/${cadFileId}/thumbnail`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
return URL.createObjectURL(res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the glTF model file as a Blob and return an object URL.
|
||||
* Remember to call URL.revokeObjectURL() when the consumer is done.
|
||||
*/
|
||||
export async function fetchModelBlob(cadFileId: string): Promise<string> {
|
||||
const res = await api.get<Blob>(`/cad/${cadFileId}/model`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
return URL.createObjectURL(res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the parsed_objects JSON for a CAD file.
|
||||
*/
|
||||
export async function getCadObjects(cadFileId: string): Promise<CadObjects> {
|
||||
const res = await api.get<CadObjects>(`/cad/${cadFileId}/objects`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the cached STL for a CAD file as a file-save dialog.
|
||||
* quality: 'low' | 'high'
|
||||
* The backend returns a human-readable filename, but we derive it client-side too.
|
||||
*/
|
||||
export async function downloadStl(cadFileId: string, quality: 'low' | 'high', suggestedName?: string): Promise<void> {
|
||||
const res = await api.get<Blob>(`/cad/${cadFileId}/stl/${quality}`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = suggestedName ? `${suggestedName}_${quality}.stl` : `model_${quality}.stl`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function generateStl(cadFileId: string, quality: 'low' | 'high'): Promise<{ task_id: string }> {
|
||||
const res = await api.post<{ task_id: string }>(`/cad/${cadFileId}/generate-stl/${quality}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the backend to re-queue STEP processing for a CAD file (admin only).
|
||||
* Returns the Celery task_id (or null if the worker is not available).
|
||||
*/
|
||||
export async function regenerateThumbnail(
|
||||
cadFileId: string,
|
||||
): Promise<RegenerateThumbnailResponse> {
|
||||
const res = await api.post<RegenerateThumbnailResponse>(
|
||||
`/cad/${cadFileId}/regenerate-thumbnail`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,86 @@
|
||||
import api from './client'
|
||||
|
||||
export interface Material {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
source: string
|
||||
schaeffler_code: number | null
|
||||
created_by_name: string | null
|
||||
aliases: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface MaterialAlias {
|
||||
id: string
|
||||
alias: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function listMaterials() {
|
||||
const res = await api.get<Material[]>('/materials')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createMaterial(data: {
|
||||
name: string
|
||||
description?: string
|
||||
source?: string
|
||||
schaeffler_code?: number | null
|
||||
}) {
|
||||
const res = await api.post<Material>('/materials', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateMaterial(id: string, data: { name?: string; description?: string }) {
|
||||
const res = await api.patch<Material>(`/materials/${id}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteMaterial(id: string) {
|
||||
await api.delete(`/materials/${id}`)
|
||||
}
|
||||
|
||||
export async function saveCadPartMaterials(
|
||||
orderId: string,
|
||||
itemId: string,
|
||||
parts: Array<{ part_name: string; material: string }>,
|
||||
) {
|
||||
const res = await api.put(`/orders/${orderId}/items/${itemId}/cad-materials`, { parts })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function seedSchaefflerMaterials() {
|
||||
const res = await api.post<{ inserted: number; total: number }>('/materials/seed-schaeffler')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getNextCode(typePrefix: string) {
|
||||
const res = await api.get<{ next_code: number; prefix: string; next_consecutive: number }>(
|
||||
`/materials/next-code`,
|
||||
{ params: { type_prefix: typePrefix } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// --- Alias endpoints ---
|
||||
|
||||
export async function listAliases(materialId: string): Promise<MaterialAlias[]> {
|
||||
const res = await api.get<MaterialAlias[]>(`/materials/${materialId}/aliases`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function addAlias(materialId: string, alias: string): Promise<MaterialAlias> {
|
||||
const res = await api.post<MaterialAlias>(`/materials/${materialId}/aliases`, { alias })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteAlias(aliasId: string): Promise<void> {
|
||||
await api.delete(`/materials/aliases/${aliasId}`)
|
||||
}
|
||||
|
||||
export async function seedAliases(): Promise<{ inserted: number; total: number }> {
|
||||
const res = await api.post<{ inserted: number; total: number }>('/materials/seed-aliases')
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import api from './client'
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
action: string
|
||||
entity_type: string | null
|
||||
entity_id: string | null
|
||||
details: Record<string, unknown> | null
|
||||
timestamp: string
|
||||
read_at: string | null
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
items: Notification[]
|
||||
unread_count: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function getNotifications(params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
unread_only?: boolean
|
||||
}): Promise<NotificationListResponse> {
|
||||
const { data } = await api.get('/notifications', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const { data } = await api.get('/notifications/unread-count')
|
||||
return data.unread_count
|
||||
}
|
||||
|
||||
export async function markAsRead(ids?: string[]): Promise<void> {
|
||||
await api.post('/notifications/mark-read', { notification_ids: ids ?? null })
|
||||
}
|
||||
|
||||
export async function markOneAsRead(id: string): Promise<void> {
|
||||
await api.post(`/notifications/${id}/mark-read`)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import api from './client'
|
||||
import type { Product } from './products'
|
||||
import type { OutputType } from './outputTypes'
|
||||
|
||||
export interface OrderLine {
|
||||
id: string
|
||||
order_id: string
|
||||
product_id: string
|
||||
product: Product
|
||||
output_type_id: string | null
|
||||
output_type: OutputType | null
|
||||
gewuenschte_bildnummer: string | null
|
||||
item_status: 'pending' | 'approved' | 'rejected'
|
||||
render_status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'
|
||||
result_path: string | null
|
||||
thumbnail_url: string | null
|
||||
ai_validation_status: string
|
||||
ai_validation_result: Record<string, unknown> | null
|
||||
render_backend_used: string | null
|
||||
flamenco_job_id: string | null
|
||||
unit_price: number | null
|
||||
render_position_id: string | null
|
||||
render_position_name: string | null
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface OrderLineCreate {
|
||||
product_id: string
|
||||
output_type_id?: string | null
|
||||
render_position_id?: string | null
|
||||
gewuenschte_bildnummer?: string | null
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
order_number: string
|
||||
template_id: string | null
|
||||
status: 'draft' | 'submitted' | 'processing' | 'completed' | 'rejected'
|
||||
created_by: string
|
||||
source_excel: string | null
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at: string | null
|
||||
completed_at: string | null
|
||||
estimated_price: number | null
|
||||
item_count: number
|
||||
line_count: number
|
||||
render_progress: {
|
||||
total: number
|
||||
completed: number
|
||||
processing: number
|
||||
failed: number
|
||||
pending: number
|
||||
cancelled: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string
|
||||
order_id: string
|
||||
row_index: number
|
||||
ebene1: string | null
|
||||
ebene2: string | null
|
||||
baureihe: string | null
|
||||
pim_id: string | null
|
||||
produkt_baureihe: string | null
|
||||
gewaehltes_produkt: string | null
|
||||
name_cad_modell: string | null
|
||||
gewuenschte_bildnummer: string | null
|
||||
lagertyp: string | null
|
||||
medias_rendering: boolean | null
|
||||
components: Array<{ part_name: string | null; material: string | null; component_type: string | null; column_index: number }>
|
||||
cad_file_id: string | null
|
||||
thumbnail_path: string | null
|
||||
ai_validation_status: string
|
||||
ai_validation_result: Record<string, unknown> | null
|
||||
item_status: 'pending' | 'approved' | 'rejected'
|
||||
notes: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OrderDetail extends Order {
|
||||
items: OrderItem[]
|
||||
lines: OrderLine[]
|
||||
}
|
||||
|
||||
export async function listOrders(params?: { status?: string; skip?: number; limit?: number }) {
|
||||
const res = await api.get<Order[]>('/orders', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function searchOrders(params: {
|
||||
q?: string
|
||||
statuses?: string[]
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
limit?: number
|
||||
}): Promise<OrderDetail[]> {
|
||||
const res = await api.get<OrderDetail[]>('/orders/search', {
|
||||
params: {
|
||||
q: params.q || '',
|
||||
statuses: params.statuses?.join(',') || '',
|
||||
date_from: params.date_from || '',
|
||||
date_to: params.date_to || '',
|
||||
limit: params.limit || 50,
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getOrder(id: string) {
|
||||
const res = await api.get<OrderDetail>(`/orders/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function patchOrderItem(
|
||||
orderId: string,
|
||||
itemId: string,
|
||||
patch: Partial<{
|
||||
ebene1: string | null
|
||||
ebene2: string | null
|
||||
baureihe: string | null
|
||||
pim_id: string | null
|
||||
produkt_baureihe: string | null
|
||||
gewaehltes_produkt: string | null
|
||||
name_cad_modell: string | null
|
||||
gewuenschte_bildnummer: string | null
|
||||
lagertyp: string | null
|
||||
medias_rendering: boolean | null
|
||||
notes: string | null
|
||||
}>,
|
||||
) {
|
||||
const res = await api.patch<OrderItem>(`/orders/${orderId}/items/${itemId}`, patch)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createOrder(data: {
|
||||
template_id?: string
|
||||
source_excel?: string
|
||||
notes?: string
|
||||
items?: Array<{
|
||||
row_index: number
|
||||
ebene1?: string | null
|
||||
ebene2?: string | null
|
||||
baureihe?: string | null
|
||||
pim_id?: string | null
|
||||
produkt_baureihe?: string | null
|
||||
gewaehltes_produkt?: string | null
|
||||
name_cad_modell?: string | null
|
||||
gewuenschte_bildnummer?: string | null
|
||||
lagertyp?: string | null
|
||||
medias_rendering?: boolean | null
|
||||
components: Array<{ part_name?: string | null; material?: string | null; component_type?: string | null; column_index: number }>
|
||||
}>
|
||||
lines?: OrderLineCreate[]
|
||||
}) {
|
||||
const res = await api.post<OrderDetail>('/orders', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function addOrderLine(orderId: string, data: OrderLineCreate): Promise<OrderLine> {
|
||||
const res = await api.post<OrderLine>(`/orders/${orderId}/lines`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function removeOrderLine(orderId: string, lineId: string): Promise<void> {
|
||||
await api.delete(`/orders/${orderId}/lines/${lineId}`)
|
||||
}
|
||||
|
||||
export async function submitOrder(id: string) {
|
||||
const res = await api.post<Order>(`/orders/${id}/submit`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteOrder(id: string) {
|
||||
await api.delete(`/orders/${id}`)
|
||||
}
|
||||
|
||||
export async function unlinkCadFile(orderId: string, itemId: string) {
|
||||
await api.delete(`/orders/${orderId}/items/${itemId}/cad-file`)
|
||||
}
|
||||
|
||||
export async function dispatchRenders(orderId: string) {
|
||||
const res = await api.post<{ dispatched: number }>(`/orders/${orderId}/dispatch-renders`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function cancelLineRender(orderId: string, lineId: string) {
|
||||
const res = await api.post<{ cancelled: boolean; line_id: string; backend: string; errors: string[] | null }>(
|
||||
`/orders/${orderId}/lines/${lineId}/cancel-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`
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function regenerateItemThumbnail(orderId: string, itemId: string) {
|
||||
const res = await api.post<{ status: string; task_id: string; cad_file_id: string }>(
|
||||
`/orders/${orderId}/items/${itemId}/regenerate-thumbnail`
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface SplitMissingStepResult {
|
||||
new_order_id: string
|
||||
new_order_number: string
|
||||
moved_item_count: number
|
||||
moved_line_count: number
|
||||
}
|
||||
|
||||
export async function splitMissingStep(orderId: string): Promise<SplitMissingStepResult> {
|
||||
const res = await api.post<SplitMissingStepResult>(`/orders/${orderId}/split-missing-step`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface GenerateLinesResult {
|
||||
created: number
|
||||
skipped: number
|
||||
no_product_count: number
|
||||
no_step_count: number
|
||||
}
|
||||
|
||||
export async function generateLinesFromItems(
|
||||
orderId: string,
|
||||
outputTypeIds: string[],
|
||||
): Promise<GenerateLinesResult> {
|
||||
const res = await api.post<GenerateLinesResult>(`/orders/${orderId}/generate-lines`, {
|
||||
output_type_ids: outputTypeIds,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function downloadOrderRenders(orderId: string, orderNumber: string): Promise<void> {
|
||||
const res = await api.get(`/orders/${orderId}/download-renders`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${orderNumber}_renders.zip`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import api from './client'
|
||||
|
||||
export interface OutputType {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
renderer: string
|
||||
render_settings: Record<string, unknown>
|
||||
output_format: string
|
||||
sort_order: number
|
||||
compatible_categories: string[]
|
||||
render_backend: string
|
||||
is_animation: boolean
|
||||
transparent_bg: boolean
|
||||
cycles_device: string | null
|
||||
pricing_tier_id: number | null
|
||||
pricing_tier_name: string | null
|
||||
price_per_item: number | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function listOutputTypes(
|
||||
includeInactive = false,
|
||||
category?: string,
|
||||
): Promise<OutputType[]> {
|
||||
const params: Record<string, unknown> = { include_inactive: includeInactive }
|
||||
if (category) params.category = category
|
||||
const res = await api.get<OutputType[]>('/output-types', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createOutputType(data: Partial<OutputType>): Promise<OutputType> {
|
||||
const res = await api.post<OutputType>('/output-types', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateOutputType(id: string, data: Partial<OutputType>): Promise<OutputType> {
|
||||
const res = await api.patch<OutputType>(`/output-types/${id}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteOutputType(id: string): Promise<void> {
|
||||
await api.delete(`/output-types/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import api from './client'
|
||||
|
||||
export interface PricingTier {
|
||||
id: number
|
||||
category_key: string
|
||||
quality_level: string
|
||||
price_per_item: number
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function listPricingTiers(): Promise<PricingTier[]> {
|
||||
const res = await api.get<PricingTier[]>('/pricing')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createPricingTier(data: {
|
||||
category_key: string
|
||||
quality_level: string
|
||||
price_per_item: number
|
||||
description?: string
|
||||
is_active?: boolean
|
||||
}): Promise<PricingTier> {
|
||||
const res = await api.post<PricingTier>('/pricing', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updatePricingTier(
|
||||
id: number,
|
||||
data: { category_key?: string; quality_level?: string; price_per_item?: number; description?: string; is_active?: boolean },
|
||||
): Promise<PricingTier> {
|
||||
const res = await api.patch<PricingTier>(`/pricing/${id}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deletePricingTier(id: number): Promise<void> {
|
||||
await api.delete(`/pricing/${id}`)
|
||||
}
|
||||
|
||||
export interface PriceEstimateLine {
|
||||
product_id: string
|
||||
output_type_id: string | null
|
||||
}
|
||||
|
||||
export interface PriceEstimate {
|
||||
total: number
|
||||
line_count: number
|
||||
breakdown: Array<{
|
||||
output_type_id: string | null
|
||||
product_id: string | null
|
||||
unit_price: number | null
|
||||
}>
|
||||
has_unpriced: boolean
|
||||
}
|
||||
|
||||
export async function estimatePrice(lines: PriceEstimateLine[]): Promise<PriceEstimate> {
|
||||
const res = await api.post<PriceEstimate>('/pricing/estimate', { lines })
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import api from './client'
|
||||
|
||||
export interface RenderPosition {
|
||||
id: string
|
||||
product_id: string
|
||||
name: string
|
||||
rotation_x: number
|
||||
rotation_y: number
|
||||
rotation_z: number
|
||||
is_default: boolean
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ComponentData {
|
||||
part_name: string | null
|
||||
material: string | null
|
||||
component_type: string | null
|
||||
column_index: number
|
||||
}
|
||||
|
||||
export interface CadPartMaterial {
|
||||
part_name: string
|
||||
material: string
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
pim_id: string
|
||||
name: string | null
|
||||
category_key: string | null
|
||||
ebene1: string | null
|
||||
ebene2: string | null
|
||||
baureihe: string | null
|
||||
produkt_baureihe: string | null
|
||||
lagertyp: string | null
|
||||
name_cad_modell: string | null
|
||||
gewuenschte_bildnummer: string | null
|
||||
medias_rendering: boolean | null
|
||||
components: ComponentData[]
|
||||
cad_part_materials: CadPartMaterial[]
|
||||
cad_file_id: string | null
|
||||
thumbnail_url: string | null
|
||||
render_image_url: string | null
|
||||
processing_status: string | null
|
||||
stl_cached: string[]
|
||||
cad_parsed_objects: string[] | null
|
||||
arbeitspaket: string | null
|
||||
notes: string | null
|
||||
is_active: boolean
|
||||
source_excel: string | null
|
||||
render_positions: RenderPosition[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProductListParams {
|
||||
q?: string
|
||||
category_key?: string
|
||||
has_cad?: boolean
|
||||
ready_only?: boolean
|
||||
materials_filter?: string // "complete" | "incomplete" | ""
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export async function listProducts(params?: ProductListParams): Promise<Product[]> {
|
||||
const res = await api.get<Product[]>('/products', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getProduct(id: string): Promise<Product> {
|
||||
const res = await api.get<Product>(`/products/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createProduct(data: Partial<Product>): Promise<Product> {
|
||||
const res = await api.post<Product>('/products', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateProduct(id: string, data: Partial<Product>): Promise<Product> {
|
||||
const res = await api.patch<Product>(`/products/${id}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: string, hard = false): Promise<void> {
|
||||
await api.delete(`/products/${id}`, { params: hard ? { hard: true } : undefined })
|
||||
}
|
||||
|
||||
export async function uploadProductCad(id: string, file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await api.post(`/products/${id}/cad`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function saveProductCadMaterials(id: string, parts: CadPartMaterial[]): Promise<Product> {
|
||||
const res = await api.post<Product>(`/products/${id}/cad-materials`, { parts })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function regenerateProduct(id: string) {
|
||||
const res = await api.post(`/products/${id}/regenerate`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function reprocessProduct(id: string) {
|
||||
const res = await api.post(`/products/${id}/reprocess`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function reassignMaterialsFromExcel(id: string): Promise<Product> {
|
||||
const res = await api.post<Product>(`/products/${id}/reassign-materials-from-excel`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface ProductRender {
|
||||
order_line_id: string
|
||||
order_number: string | null
|
||||
output_type_name: string | null
|
||||
render_url: string
|
||||
is_video: boolean
|
||||
render_backend: string | null
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export async function getProductRenders(id: string): Promise<ProductRender[]> {
|
||||
const res = await api.get<ProductRender[]>(`/products/${id}/renders`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteProductRender(productId: string, orderLineId: string): Promise<void> {
|
||||
await api.delete(`/products/${productId}/renders/${orderLineId}`)
|
||||
}
|
||||
|
||||
export async function downloadProductRenders(
|
||||
productId: string,
|
||||
orderLineIds: string[],
|
||||
filename?: string,
|
||||
): Promise<void> {
|
||||
const res = await api.post<Blob>(
|
||||
`/products/${productId}/download-renders`,
|
||||
{ order_line_ids: orderLineIds },
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename ?? 'renders.zip'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function getProductOrders(id: string) {
|
||||
const res = await api.get(`/products/${id}/orders`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function listRenderPositions(productId: string): Promise<RenderPosition[]> {
|
||||
const res = await api.get<RenderPosition[]>(`/products/${productId}/render-positions`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createRenderPosition(
|
||||
productId: string,
|
||||
data: { name: string; rotation_x: number; rotation_y: number; rotation_z: number; is_default?: boolean; sort_order?: number },
|
||||
): Promise<RenderPosition> {
|
||||
const res = await api.post<RenderPosition>(`/products/${productId}/render-positions`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateRenderPosition(
|
||||
productId: string,
|
||||
posId: string,
|
||||
data: Partial<{ name: string; rotation_x: number; rotation_y: number; rotation_z: number; is_default: boolean; sort_order: number }>,
|
||||
): Promise<RenderPosition> {
|
||||
const res = await api.patch<RenderPosition>(`/products/${productId}/render-positions/${posId}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteRenderPosition(productId: string, posId: string): Promise<void> {
|
||||
await api.delete(`/products/${productId}/render-positions/${posId}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import api from './client';
|
||||
|
||||
export interface RenderTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
category_key: string | null;
|
||||
output_type_id: string | null;
|
||||
output_type_name: string | null;
|
||||
blend_file_path: string;
|
||||
original_filename: string;
|
||||
target_collection: string;
|
||||
material_replace_enabled: boolean;
|
||||
lighting_only: boolean;
|
||||
shadow_catcher_enabled: boolean;
|
||||
camera_orbit: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MaterialLibraryInfo {
|
||||
exists: boolean;
|
||||
filename: string | null;
|
||||
size_bytes: number | null;
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
export async function listRenderTemplates(): Promise<RenderTemplate[]> {
|
||||
const { data } = await api.get('/render-templates');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createRenderTemplate(formData: FormData): Promise<RenderTemplate> {
|
||||
const { data } = await api.post('/render-templates', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateRenderTemplate(
|
||||
id: string,
|
||||
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_id' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'is_active'>>,
|
||||
): Promise<RenderTemplate> {
|
||||
const { data } = await api.patch(`/render-templates/${id}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteRenderTemplate(id: string): Promise<void> {
|
||||
await api.delete(`/render-templates/${id}`);
|
||||
}
|
||||
|
||||
export async function reuploadBlendFile(id: string, file: File): Promise<RenderTemplate> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const { data } = await api.post(`/render-templates/${id}/upload`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function uploadMaterialLibrary(file: File): Promise<MaterialLibraryInfo> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const { data } = await api.post('/admin/settings/material-library', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMaterialLibraryInfo(): Promise<MaterialLibraryInfo> {
|
||||
const { data } = await api.get('/admin/settings/material-library');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteMaterialLibrary(): Promise<void> {
|
||||
await api.delete('/admin/settings/material-library');
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import api from './client'
|
||||
import type { OrderDetail } from './orders'
|
||||
|
||||
export interface ExcelPreviewRow {
|
||||
row_index: number
|
||||
pim_id: string | null
|
||||
produkt_baureihe: string | null
|
||||
gewaehltes_produkt: string | null
|
||||
product_exists: boolean
|
||||
product_id: string | null
|
||||
medias_rendering: boolean | null
|
||||
category_key: string | null
|
||||
has_step: boolean
|
||||
is_duplicate: boolean
|
||||
duplicate_of_row: number | null
|
||||
}
|
||||
|
||||
export interface ExcelPreviewResult {
|
||||
excel_path: string
|
||||
filename: string
|
||||
category_key: string | null
|
||||
row_count: number
|
||||
existing_product_count: number
|
||||
new_product_count: number
|
||||
no_pim_id_count: number
|
||||
has_step_count: number
|
||||
no_step_count: number
|
||||
duplicate_count: number
|
||||
warnings: string[]
|
||||
rows: ExcelPreviewRow[]
|
||||
column_headers: string[]
|
||||
template_name: string | null
|
||||
}
|
||||
|
||||
export interface ParsedComponent {
|
||||
part_name: string | null
|
||||
material: string | null
|
||||
component_type: string | null
|
||||
column_index: number
|
||||
}
|
||||
|
||||
export interface ParsedRow {
|
||||
row_index: number
|
||||
ebene1: string | null
|
||||
ebene2: string | null
|
||||
baureihe: string | null
|
||||
pim_id: string | null
|
||||
produkt_baureihe: string | null
|
||||
gewaehltes_produkt: string | null
|
||||
name_cad_modell: string | null
|
||||
gewuenschte_bildnummer: string | null
|
||||
lagertyp: string | null
|
||||
medias_rendering: boolean | null
|
||||
components: ParsedComponent[]
|
||||
}
|
||||
|
||||
export interface ParsedExcelResponse {
|
||||
filename: string
|
||||
excel_path?: string
|
||||
category_key: string | null
|
||||
template_name: string | null
|
||||
row_count: number
|
||||
column_headers: string[]
|
||||
rows: ParsedRow[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutputTypeSelection {
|
||||
row_index: number
|
||||
output_type_ids: string[]
|
||||
}
|
||||
|
||||
export interface ExcelFinalizeRequest {
|
||||
excel_path: string
|
||||
included_row_indices: number[]
|
||||
output_type_selections: OutputTypeSelection[]
|
||||
notes?: string | null
|
||||
template_id?: string | null
|
||||
}
|
||||
|
||||
export async function uploadExcel(file: File): Promise<ExcelPreviewResult> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await api.post<ExcelPreviewResult>('/uploads/excel', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function finalizeExcelImport(data: ExcelFinalizeRequest): Promise<OrderDetail> {
|
||||
const res = await api.post<OrderDetail>('/uploads/excel/finalize', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function uploadStep(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await api.post('/uploads/step', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import api from './client'
|
||||
|
||||
export interface RenderLog {
|
||||
renderer: string
|
||||
format?: string
|
||||
engine?: string
|
||||
engine_used?: string
|
||||
samples?: number
|
||||
cycles_device?: string
|
||||
stl_quality?: string
|
||||
smooth_angle?: number
|
||||
width?: number
|
||||
height?: number
|
||||
total_duration_s?: number
|
||||
stl_duration_s?: number
|
||||
render_duration_s?: number
|
||||
stl_size_bytes?: number
|
||||
output_size_bytes?: number
|
||||
parts_count?: number
|
||||
log_lines?: string[]
|
||||
fallback?: boolean
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
export interface CadActivityEntry {
|
||||
cad_file_id: string
|
||||
original_name: string
|
||||
file_size: number | null
|
||||
processing_status: 'pending' | 'processing' | 'completed' | 'failed' | string
|
||||
error_message: string | null
|
||||
updated_at: string
|
||||
created_at: string
|
||||
order_numbers: string[]
|
||||
render_log: RenderLog | null
|
||||
}
|
||||
|
||||
export interface RenderJobEntry {
|
||||
order_line_id: string
|
||||
order_number: string | null
|
||||
product_name: string | null
|
||||
output_type_name: string | null
|
||||
render_status: 'processing' | 'completed' | 'failed' | string
|
||||
render_backend_used: string | null
|
||||
flamenco_job_id: string | null
|
||||
render_started_at: string | null
|
||||
render_completed_at: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WorkerActivity {
|
||||
cad_processing: CadActivityEntry[]
|
||||
active_count: number
|
||||
failed_count: number
|
||||
render_jobs: RenderJobEntry[]
|
||||
render_active_count: number
|
||||
render_failed_count: number
|
||||
}
|
||||
|
||||
export async function getWorkerActivity(): Promise<WorkerActivity> {
|
||||
const res = await api.get<WorkerActivity>('/worker/activity')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function reprocessCadFile(cad_file_id: string): Promise<void> {
|
||||
await api.post(`/worker/activity/${cad_file_id}/reprocess`)
|
||||
}
|
||||
|
||||
export interface RenderLogEntry {
|
||||
ts: number
|
||||
t: string
|
||||
level: 'info' | 'error' | 'success' | string
|
||||
msg: string
|
||||
}
|
||||
|
||||
export async function getRenderLog(orderLineId: string, after: number = 0): Promise<{
|
||||
entries: RenderLogEntry[]
|
||||
total: number
|
||||
next_after: number
|
||||
}> {
|
||||
const res = await api.get(`/worker/render-log/${orderLineId}`, { params: { after } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** Returns the SSE URL for streaming render logs (needs token in query param). */
|
||||
export function renderLogStreamUrl(orderLineId: string): string {
|
||||
return `/api/worker/render-log/${orderLineId}/stream`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue inspection + control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface QueueTask {
|
||||
task_id: string
|
||||
task_name: string
|
||||
args: any[]
|
||||
argsrepr: string
|
||||
status: 'pending' | 'active' | 'reserved'
|
||||
worker?: string
|
||||
queue?: string
|
||||
}
|
||||
|
||||
export interface QueueStatus {
|
||||
queue_depths: Record<string, number>
|
||||
pending_count: number
|
||||
active: QueueTask[]
|
||||
reserved: QueueTask[]
|
||||
pending: QueueTask[]
|
||||
}
|
||||
|
||||
export async function getQueueStatus(): Promise<QueueStatus> {
|
||||
const res = await api.get<QueueStatus>('/worker/queue')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function purgeQueue(): Promise<{ purged: number; message: string }> {
|
||||
const res = await api.post<{ purged: number; message: string }>('/worker/queue/purge')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function cancelTask(taskId: string): Promise<{ revoked: string }> {
|
||||
const res = await api.post<{ revoked: string }>(`/worker/queue/cancel/${taskId}`)
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Terminal, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { getRenderLog } from '../api/worker'
|
||||
import type { RenderLogEntry } from '../api/worker'
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
info: 'text-gray-300',
|
||||
error: 'text-red-400',
|
||||
success: 'text-green-400',
|
||||
warn: 'text-yellow-400',
|
||||
}
|
||||
|
||||
/**
|
||||
* Live render log panel — polls Redis-backed log entries every 2s.
|
||||
* Shows a compact terminal-style output for a render job.
|
||||
*
|
||||
* Always does an initial fetch to check if entries exist (so failed jobs
|
||||
* still show their log). Polls only when isActive.
|
||||
*/
|
||||
export default function LiveRenderLog({
|
||||
orderLineId,
|
||||
isActive,
|
||||
compact = false,
|
||||
}: {
|
||||
orderLineId: string
|
||||
/** Whether the render is still processing — enables polling */
|
||||
isActive: boolean
|
||||
/** Compact mode (inline, no border) for table rows */
|
||||
compact?: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(isActive)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Always fetch once to probe for existing entries; poll only when active
|
||||
const { data } = useQuery({
|
||||
queryKey: ['render-log', orderLineId],
|
||||
queryFn: () => getRenderLog(orderLineId),
|
||||
refetchInterval: isActive ? 2000 : false,
|
||||
})
|
||||
|
||||
const entries: RenderLogEntry[] = data?.entries ?? []
|
||||
const hasEntries = entries.length > 0
|
||||
|
||||
// Auto-scroll to bottom when new entries arrive
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && isActive) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [entries.length, isActive])
|
||||
|
||||
// Auto-expand when active
|
||||
useEffect(() => {
|
||||
if (isActive) setExpanded(true)
|
||||
}, [isActive])
|
||||
|
||||
// Nothing to show at all
|
||||
if (!hasEntries && !isActive) return null
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-[10px] text-gray-400 hover:text-gray-600 flex items-center gap-1"
|
||||
>
|
||||
<Terminal size={10} />
|
||||
Log ({entries.length})
|
||||
{expanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||
</button>
|
||||
{expanded && hasEntries && (
|
||||
<LogPanel entries={entries} isActive={isActive} scrollRef={scrollRef} maxHeight="120px" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 mb-1"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
<span className="font-medium">Render Log</span>
|
||||
<span className="text-gray-400">({entries.length} entries)</span>
|
||||
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<LogPanel entries={entries} isActive={isActive} scrollRef={scrollRef} maxHeight="200px" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LogPanel({
|
||||
entries,
|
||||
isActive,
|
||||
scrollRef,
|
||||
maxHeight,
|
||||
}: {
|
||||
entries: RenderLogEntry[]
|
||||
isActive: boolean
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
maxHeight: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="bg-gray-900 rounded-md p-2 overflow-y-auto font-mono text-[11px] leading-relaxed"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
<div key={i} className={`flex gap-2 ${LEVEL_COLORS[entry.level] || LEVEL_COLORS.info}`}>
|
||||
<span className="text-gray-500 shrink-0 select-none">{entry.t}</span>
|
||||
<span>{entry.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
{isActive && entries.length > 0 && (
|
||||
<div className="text-gray-500 animate-pulse">...</div>
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<div className="text-gray-600 italic">No log entries yet</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { X, ChevronLeft, ChevronRight, Wrench, Paintbrush, Shapes, FlaskConical, HelpCircle } from 'lucide-react'
|
||||
import { createMaterial, getNextCode } from '../api/materials'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated?: (name: string) => void
|
||||
}
|
||||
|
||||
const MATERIAL_TYPES = [
|
||||
{ code: '01', label: 'Metals', icon: Wrench, color: 'bg-slate-100 text-slate-700 border-slate-300', activeColor: 'bg-slate-600 text-white border-slate-600' },
|
||||
{ code: '02', label: 'Coatings', icon: Paintbrush, color: 'bg-status-info-bg text-status-info-text border-border-default', activeColor: 'bg-blue-600 text-white border-blue-600' },
|
||||
{ code: '03', label: 'Non-metals', icon: Shapes, color: 'bg-status-warning-bg text-status-warning-text border-border-default', activeColor: 'bg-amber-600 text-white border-amber-600' },
|
||||
{ code: '04', label: 'Compounds', icon: FlaskConical, color: 'bg-purple-50 text-purple-700 border-purple-300', activeColor: 'bg-purple-600 text-white border-purple-600' },
|
||||
{ code: '05', label: 'Misc', icon: HelpCircle, color: 'bg-surface-alt text-content-secondary border-border-default', activeColor: 'bg-gray-600 text-white border-gray-600' },
|
||||
] as const
|
||||
|
||||
const SUBTYPE_PRESETS: Record<string, Array<{ code: string; label: string }>> = {
|
||||
'01': [
|
||||
{ code: '01', label: 'Steel' },
|
||||
{ code: '02', label: 'Niro' },
|
||||
{ code: '03', label: 'Tin' },
|
||||
{ code: '04', label: 'Aluminium' },
|
||||
{ code: '05', label: 'Brass' },
|
||||
{ code: '06', label: 'Bronze' },
|
||||
],
|
||||
'02': [
|
||||
{ code: '01', label: 'Durotect' },
|
||||
{ code: '02', label: 'Coat' },
|
||||
],
|
||||
'03': [
|
||||
{ code: '01', label: 'Elastomer' },
|
||||
{ code: '02', label: 'Plastic (opaque)' },
|
||||
{ code: '03', label: 'Plastic (translucent)' },
|
||||
{ code: '04', label: 'TPU' },
|
||||
{ code: '05', label: 'Ceramic' },
|
||||
],
|
||||
'04': [
|
||||
{ code: '01', label: 'E-series' },
|
||||
{ code: '02', label: 'Elgo-series' },
|
||||
{ code: '03', label: 'PTFE / GFK' },
|
||||
],
|
||||
'05': [],
|
||||
}
|
||||
|
||||
export default function MaterialWizard({ open, onClose, onCreated }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [step, setStep] = useState(1)
|
||||
const [typeCode, setTypeCode] = useState('')
|
||||
const [subTypeCode, setSubTypeCode] = useState('')
|
||||
const [customSubType, setCustomSubType] = useState('')
|
||||
const [consecutive, setConsecutive] = useState<number | null>(null)
|
||||
const [nameParts, setNameParts] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [loadingCode, setLoadingCode] = useState(false)
|
||||
|
||||
const effectiveSubType = subTypeCode || customSubType
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep(1)
|
||||
setTypeCode('')
|
||||
setSubTypeCode('')
|
||||
setCustomSubType('')
|
||||
setConsecutive(null)
|
||||
setNameParts('')
|
||||
setDescription('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Fetch next consecutive number when type + subtype are set
|
||||
useEffect(() => {
|
||||
if (!typeCode || !effectiveSubType || effectiveSubType.length !== 2) {
|
||||
setConsecutive(null)
|
||||
return
|
||||
}
|
||||
const prefix = typeCode + effectiveSubType
|
||||
setLoadingCode(true)
|
||||
getNextCode(prefix)
|
||||
.then((res) => setConsecutive(res.next_consecutive))
|
||||
.catch(() => setConsecutive(1))
|
||||
.finally(() => setLoadingCode(false))
|
||||
}, [typeCode, effectiveSubType])
|
||||
|
||||
const fullCode = useMemo(() => {
|
||||
if (!typeCode || !effectiveSubType || consecutive === null) return null
|
||||
return `${typeCode}${effectiveSubType}${String(consecutive).padStart(2, '0')}`
|
||||
}, [typeCode, effectiveSubType, consecutive])
|
||||
|
||||
const sanitizedName = nameParts
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9\-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
const fullMaterialName = fullCode && sanitizedName
|
||||
? `SCHAEFFLER_${fullCode}_${sanitizedName}`
|
||||
: null
|
||||
|
||||
const schaefflerCodeInt = fullCode ? parseInt(fullCode, 10) : null
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () =>
|
||||
createMaterial({
|
||||
name: fullMaterialName!,
|
||||
description: description.trim() || undefined,
|
||||
source: 'manual',
|
||||
schaeffler_code: schaefflerCodeInt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Material created')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
if (onCreated && fullMaterialName) onCreated(fullMaterialName)
|
||||
onClose()
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create material'),
|
||||
})
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const nameValid = sanitizedName.length >= 2
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div
|
||||
className="bg-surface rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-content">Schaeffler Material Wizard</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">Step {step} of 3</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-content-muted hover:text-content-secondary">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="px-6 py-5 min-h-[260px]">
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content-secondary mb-4">Select material type:</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{MATERIAL_TYPES.map((t) => {
|
||||
const Icon = t.icon
|
||||
const active = typeCode === t.code
|
||||
return (
|
||||
<button
|
||||
key={t.code}
|
||||
onClick={() => {
|
||||
setTypeCode(t.code)
|
||||
setSubTypeCode('')
|
||||
setCustomSubType('')
|
||||
setStep(2)
|
||||
}}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border-2 transition-all text-left ${
|
||||
active ? t.activeColor : t.color + ' hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t.label}</p>
|
||||
<p className={`text-xs ${active ? 'opacity-80' : 'opacity-60'}`}>Code {t.code}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content-secondary mb-3">
|
||||
Sub-type for <span className="font-bold">{MATERIAL_TYPES.find((t) => t.code === typeCode)?.label}</span>:
|
||||
</p>
|
||||
{(SUBTYPE_PRESETS[typeCode] ?? []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{SUBTYPE_PRESETS[typeCode]!.map((st) => (
|
||||
<button
|
||||
key={st.code}
|
||||
onClick={() => {
|
||||
setSubTypeCode(st.code)
|
||||
setCustomSubType('')
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-full text-sm border transition-all ${
|
||||
subTypeCode === st.code
|
||||
? 'text-white'
|
||||
: 'bg-surface text-content-secondary border-border-default hover:border-accent'
|
||||
}`}
|
||||
style={subTypeCode === st.code ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
{st.label} ({st.code})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-content-muted mb-1">
|
||||
{(SUBTYPE_PRESETS[typeCode] ?? []).length > 0 ? 'Or enter custom sub-type (2 digits):' : 'Enter sub-type (2 digits):'}
|
||||
</label>
|
||||
<input
|
||||
maxLength={2}
|
||||
placeholder="e.g. 07"
|
||||
value={customSubType}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/\D/g, '')
|
||||
setCustomSubType(v)
|
||||
if (v.length > 0) setSubTypeCode('')
|
||||
}}
|
||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{effectiveSubType.length === 2 && (
|
||||
<div className="bg-surface-alt rounded-lg px-4 py-3">
|
||||
<p className="text-xs text-content-muted mb-1">Next consecutive number:</p>
|
||||
<p className="text-lg font-mono font-bold text-content">
|
||||
{loadingCode ? '...' : consecutive !== null ? String(consecutive).padStart(2, '0') : '--'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content-secondary mb-3">Material name and description:</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-content-secondary mb-1">Name (dash-separated) *</label>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="e.g. Chrome-Steel-Hardened"
|
||||
value={nameParts}
|
||||
onChange={(e) => setNameParts(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{nameParts && !nameValid && (
|
||||
<p className="text-xs text-red-500 mt-1">Name must be at least 2 characters (a-z, 0-9, dashes)</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-content-secondary mb-1">Description (optional)</label>
|
||||
<input
|
||||
placeholder="e.g. Gehärteter Chromstahl"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live preview bar */}
|
||||
<div className="px-6 py-3 bg-surface-alt border-t border-border-light">
|
||||
<p className="text-xs text-content-muted mb-0.5">Preview:</p>
|
||||
<p className="font-mono text-sm font-semibold text-content truncate">
|
||||
{fullMaterialName || (
|
||||
<span className="text-content-muted">
|
||||
SCHAEFFLER_{typeCode || 'XX'}{effectiveSubType || 'YY'}{consecutive !== null ? String(consecutive).padStart(2, '0') : 'ZZ'}_{sanitizedName || 'Name'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border-default">
|
||||
<button
|
||||
onClick={() => setStep(Math.max(1, step - 1))}
|
||||
disabled={step === 1}
|
||||
className="flex items-center gap-1 text-sm text-content-secondary hover:text-content disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft size={16} /> Back
|
||||
</button>
|
||||
|
||||
{step < 3 ? (
|
||||
<button
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={step === 2 && effectiveSubType.length !== 2}
|
||||
className="flex items-center gap-1 btn-primary text-sm"
|
||||
>
|
||||
Next <ChevronRight size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={!fullMaterialName || !nameValid || createMut.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createMut.isPending ? 'Creating...' : 'Create Material'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Plus, Trash2, Pencil, Check, X } from 'lucide-react'
|
||||
import { listMaterials, createMaterial, updateMaterial, deleteMaterial } from '../../api/materials'
|
||||
import type { Material } from '../../api/materials'
|
||||
|
||||
export default function MaterialLibrary() {
|
||||
const qc = useQueryClient()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDesc, setEditDesc] = useState('')
|
||||
|
||||
const { data: materials = [] } = useQuery({
|
||||
queryKey: ['materials'],
|
||||
queryFn: listMaterials,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createMaterial({ name: newName.trim(), description: newDesc.trim() || undefined }),
|
||||
onSuccess: () => {
|
||||
toast.success('Material added')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
setShowAdd(false)
|
||||
setNewName('')
|
||||
setNewDesc('')
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to add material'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (id: string) => updateMaterial(id, { name: editName.trim(), description: editDesc.trim() || undefined }),
|
||||
onSuccess: () => {
|
||||
toast.success('Material updated')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
setEditingId(null)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteMaterial,
|
||||
onSuccess: () => {
|
||||
toast.success('Material deleted')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
||||
})
|
||||
|
||||
const startEdit = (mat: Material) => {
|
||||
setEditingId(mat.id)
|
||||
setEditName(mat.name)
|
||||
setEditDesc(mat.description ?? '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-border-default flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">Material Library</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Shared materials available when assigning CAD part materials.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAdd(!showAdd)} className="btn-primary">
|
||||
<Plus size={16} /> Add Material
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="p-4 border-b border-border-light bg-surface-alt flex gap-3 items-end flex-wrap">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="block text-xs font-medium text-content-secondary mb-1">Name *</label>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="e.g. Steel 100Cr6"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && newName.trim() && createMut.mutate()}
|
||||
className="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-xs font-medium text-content-secondary mb-1">Description</label>
|
||||
<input
|
||||
placeholder="e.g. Bearing steel, hardened"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && newName.trim() && createMut.mutate()}
|
||||
className="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={!newName.trim() || createMut.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createMut.isPending ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{materials.length === 0 ? (
|
||||
<div className="p-8 text-center text-content-muted text-sm">
|
||||
No materials yet. Add the first one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-light">
|
||||
{materials.map((mat) => (
|
||||
<div key={mat.id} className="flex items-center px-6 py-3 gap-3">
|
||||
{editingId === mat.id ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="flex-1 px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
placeholder="Description"
|
||||
className="flex-1 px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateMut.mutate(mat.id)}
|
||||
disabled={!editName.trim() || updateMut.isPending}
|
||||
className="text-status-success-text hover:text-status-success-text"
|
||||
title="Save"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="text-content-muted hover:text-content-secondary" title="Cancel">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-content">{mat.name}</p>
|
||||
{mat.description && (
|
||||
<p className="text-xs text-content-muted">{mat.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => startEdit(mat)} className="text-content-muted hover:text-content-secondary" title="Edit">
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete material "${mat.name}"?`)) deleteMut.mutate(mat.id)
|
||||
}}
|
||||
className="text-content-muted hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Pencil, Trash2, Plus, Check, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { listPricingTiers, createPricingTier, updatePricingTier, deletePricingTier } from '../../api/pricing'
|
||||
import type { PricingTier } from '../../api/pricing'
|
||||
|
||||
const EMPTY_FORM = { category_key: '', quality_level: 'Normal', price_per_item: '', description: '' }
|
||||
|
||||
export default function PricingTierTable() {
|
||||
const qc = useQueryClient()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editDraft, setEditDraft] = useState<Partial<PricingTier>>({})
|
||||
|
||||
const { data: tiers, isLoading } = useQuery({
|
||||
queryKey: ['pricing-tiers'],
|
||||
queryFn: listPricingTiers,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () =>
|
||||
createPricingTier({
|
||||
category_key: form.category_key.trim(),
|
||||
quality_level: form.quality_level.trim() || 'Normal',
|
||||
price_per_item: parseFloat(form.price_per_item),
|
||||
description: form.description.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Pricing tier created')
|
||||
qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
|
||||
setForm(EMPTY_FORM)
|
||||
setShowAdd(false)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create tier'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<PricingTier> }) =>
|
||||
updatePricingTier(id, {
|
||||
category_key: data.category_key,
|
||||
quality_level: data.quality_level,
|
||||
price_per_item: data.price_per_item != null ? Number(data.price_per_item) : undefined,
|
||||
description: data.description !== undefined ? data.description ?? undefined : undefined,
|
||||
is_active: data.is_active,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Tier updated')
|
||||
qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
|
||||
setEditingId(null)
|
||||
setEditDraft({})
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update tier'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => deletePricingTier(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Tier deleted')
|
||||
qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete tier'),
|
||||
})
|
||||
|
||||
function startEdit(tier: PricingTier) {
|
||||
setEditingId(tier.id)
|
||||
setEditDraft({
|
||||
category_key: tier.category_key,
|
||||
quality_level: tier.quality_level,
|
||||
price_per_item: tier.price_per_item,
|
||||
description: tier.description ?? '',
|
||||
is_active: tier.is_active,
|
||||
})
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null)
|
||||
setEditDraft({})
|
||||
}
|
||||
|
||||
const canCreate = form.category_key.trim() !== '' && form.price_per_item !== '' && !isNaN(parseFloat(form.price_per_item))
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Add form toggle */}
|
||||
<div className="p-4 border-b border-border-light">
|
||||
{showAdd ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<input
|
||||
placeholder="Category key (e.g. TRB)"
|
||||
value={form.category_key}
|
||||
onChange={(e) => setForm({ ...form, category_key: e.target.value })}
|
||||
className="input-base"
|
||||
title="Product category key this tier applies to (e.g. TRB, Kugellager). Leave empty for the global fallback tier."
|
||||
/>
|
||||
<input
|
||||
placeholder="Quality level (e.g. Normal)"
|
||||
value={form.quality_level}
|
||||
onChange={(e) => setForm({ ...form, quality_level: e.target.value })}
|
||||
className="input-base"
|
||||
title="Quality level label for this tier (e.g. Normal, Premium). Used for display purposes."
|
||||
/>
|
||||
<input
|
||||
placeholder="€ / item"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={form.price_per_item}
|
||||
onChange={(e) => setForm({ ...form, price_per_item: e.target.value })}
|
||||
className="input-base"
|
||||
/>
|
||||
<input
|
||||
placeholder="Description (optional)"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={!canCreate || createMut.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createMut.isPending ? 'Saving…' : 'Add Tier'}
|
||||
</button>
|
||||
<button onClick={() => { setShowAdd(false); setForm(EMPTY_FORM) }} className="btn-secondary text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-primary text-sm">
|
||||
<Plus size={14} />
|
||||
Add New Tier
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-content-muted text-sm">Loading…</div>
|
||||
) : !tiers || tiers.length === 0 ? (
|
||||
<div className="p-6 text-center text-content-muted text-sm">
|
||||
No pricing tiers configured. Add one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
{/* Warning if no default tier */}
|
||||
{!tiers.some((t) => t.category_key === 'default') && (
|
||||
<div className="mx-4 mt-3 mb-1 px-3 py-2 rounded-lg bg-status-warning-bg border border-border-default text-status-warning-text text-xs">
|
||||
No global default tier configured. Orders without a category-specific tier will have no price.
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-surface-alt border-b border-border-default">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-content-secondary">Category</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-content-secondary">Quality Level</th>
|
||||
<th className="text-right px-4 py-2 font-medium text-content-secondary">€ / Item</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-content-secondary">Description</th>
|
||||
<th className="text-center px-4 py-2 font-medium text-content-secondary">Active</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{[...tiers].sort((a, b) => {
|
||||
// Sort 'default' to top
|
||||
if (a.category_key === 'default' && b.category_key !== 'default') return -1
|
||||
if (b.category_key === 'default' && a.category_key !== 'default') return 1
|
||||
return 0
|
||||
}).map((tier) => {
|
||||
const isEditing = editingId === tier.id
|
||||
const isDefault = tier.category_key === 'default'
|
||||
return (
|
||||
<tr key={tier.id} className={`hover:bg-surface-hover transition-colors ${isDefault ? 'bg-status-warning-bg' : ''}`}>
|
||||
<td className="px-4 py-2 font-mono font-medium text-content">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editDraft.category_key ?? tier.category_key}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, category_key: e.target.value }))}
|
||||
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{tier.category_key}
|
||||
{isDefault && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-status-warning-bg text-status-warning-text font-medium font-sans" title="This is the global fallback pricing tier — used when no category-specific tier matches. The 'default' category key identifies this tier.">
|
||||
Global Fallback
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-content-secondary">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editDraft.quality_level ?? tier.quality_level}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, quality_level: e.target.value }))}
|
||||
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
tier.quality_level
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editDraft.price_per_item ?? tier.price_per_item}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, price_per_item: parseFloat(e.target.value) }))}
|
||||
className="w-24 px-2 py-1 border border-border-default rounded text-sm text-right focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium">€ {Number(tier.price_per_item).toFixed(2)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-content-muted">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editDraft.description ?? tier.description ?? ''}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, description: e.target.value }))}
|
||||
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
tier.description || <span className="text-content-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.is_active ?? tier.is_active}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, is_active: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<span className={`badge ${tier.is_active ? 'badge-green' : 'badge-gray'}`}>
|
||||
{tier.is_active ? 'yes' : 'no'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateMut.mutate({ id: tier.id, data: editDraft })}
|
||||
disabled={updateMut.isPending}
|
||||
className="p-1 text-status-success-text hover:bg-surface-hover rounded"
|
||||
title="Save"
|
||||
>
|
||||
<Check size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 text-content-muted hover:bg-surface-muted rounded"
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startEdit(tier)}
|
||||
className="p-1 text-content-muted hover:text-accent hover:bg-surface-hover rounded"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm(`Delete ${tier.category_key} / ${tier.quality_level}?`)) deleteMut.mutate(tier.id) }}
|
||||
className="p-1 text-content-muted hover:text-red-500 hover:bg-red-50 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Pencil, Trash2, Plus, Check, X, Upload, Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
listRenderTemplates,
|
||||
createRenderTemplate,
|
||||
updateRenderTemplate,
|
||||
deleteRenderTemplate,
|
||||
reuploadBlendFile,
|
||||
} from '../../api/renderTemplates'
|
||||
import type { RenderTemplate } from '../../api/renderTemplates'
|
||||
import { listOutputTypes } from '../../api/outputTypes'
|
||||
import type { OutputType } from '../../api/outputTypes'
|
||||
|
||||
const ALL_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' },
|
||||
]
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '',
|
||||
category_key: '' as string,
|
||||
output_type_id: '' as string,
|
||||
target_collection: 'Product',
|
||||
material_replace_enabled: false,
|
||||
lighting_only: false,
|
||||
shadow_catcher_enabled: false,
|
||||
camera_orbit: true,
|
||||
}
|
||||
|
||||
export default function RenderTemplateTable() {
|
||||
const qc = useQueryClient()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [addFile, setAddFile] = useState<File | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editDraft, setEditDraft] = useState<Partial<RenderTemplate>>({})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const reuploadRef = useRef<HTMLInputElement>(null)
|
||||
const [reuploadId, setReuploadId] = useState<string | null>(null)
|
||||
|
||||
const { data: templates, isLoading } = useQuery({
|
||||
queryKey: ['render-templates'],
|
||||
queryFn: listRenderTemplates,
|
||||
})
|
||||
|
||||
const { data: outputTypes } = useQuery({
|
||||
queryKey: ['output-types-admin'],
|
||||
queryFn: () => listOutputTypes(true),
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!addFile) throw new Error('Please select a .blend file')
|
||||
const fd = new FormData()
|
||||
fd.append('name', form.name.trim())
|
||||
fd.append('file', addFile)
|
||||
fd.append('category_key', form.category_key || '')
|
||||
fd.append('output_type_id', form.output_type_id || '')
|
||||
fd.append('target_collection', form.target_collection || 'Product')
|
||||
fd.append('material_replace_enabled', String(form.material_replace_enabled))
|
||||
fd.append('lighting_only', String(form.lighting_only))
|
||||
fd.append('shadow_catcher_enabled', String(form.shadow_catcher_enabled))
|
||||
fd.append('camera_orbit', String(form.camera_orbit))
|
||||
return createRenderTemplate(fd)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Render template created')
|
||||
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
||||
setForm(EMPTY_FORM)
|
||||
setAddFile(null)
|
||||
setShowAdd(false)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create template'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
updateRenderTemplate(id, data as any),
|
||||
onSuccess: () => {
|
||||
toast.success('Template updated')
|
||||
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
||||
setEditingId(null)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteRenderTemplate,
|
||||
onSuccess: () => {
|
||||
toast.success('Template deleted')
|
||||
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
||||
})
|
||||
|
||||
const reuploadMut = useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) => reuploadBlendFile(id, file),
|
||||
onSuccess: () => {
|
||||
toast.success('.blend file updated')
|
||||
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
||||
setReuploadId(null)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to upload'),
|
||||
})
|
||||
|
||||
function startEdit(t: RenderTemplate) {
|
||||
setEditingId(t.id)
|
||||
setEditDraft({
|
||||
name: t.name,
|
||||
category_key: t.category_key,
|
||||
output_type_id: t.output_type_id,
|
||||
target_collection: t.target_collection,
|
||||
material_replace_enabled: t.material_replace_enabled,
|
||||
lighting_only: t.lighting_only,
|
||||
shadow_catcher_enabled: t.shadow_catcher_enabled,
|
||||
camera_orbit: t.camera_orbit,
|
||||
is_active: t.is_active,
|
||||
})
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!editingId) return
|
||||
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> })
|
||||
}
|
||||
|
||||
const inputCls = 'px-2 py-1 text-sm border border-border-default rounded bg-surface focus:outline-none focus:ring-1 focus:ring-blue-400'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-content">Render Templates</h3>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="flex items-center gap-1 text-sm px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
<Plus size={14} /> Add Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={reuploadRef}
|
||||
type="file"
|
||||
accept=".blend"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file && reuploadId) reuploadMut.mutate({ id: reuploadId, file })
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-surface-alt border-b text-left">
|
||||
<th className="px-3 py-2 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Category</th>
|
||||
<th className="px-3 py-2 font-medium">Output Type</th>
|
||||
<th className="px-3 py-2 font-medium">Collection</th>
|
||||
<th className="px-3 py-2 font-medium">Mat. Replace</th>
|
||||
<th className="px-3 py-2 font-medium">Lighting Only</th>
|
||||
<th className="px-3 py-2 font-medium" title="Enable Shadowcatcher collection (Cycles only)">Shadow Catcher</th>
|
||||
<th className="px-3 py-2 font-medium" title="Rotate camera around product instead of product rotation (faster GPU rendering)">Cam Orbit</th>
|
||||
<th className="px-3 py-2 font-medium">.blend File</th>
|
||||
<th className="px-3 py-2 font-medium">Active</th>
|
||||
<th className="px-3 py-2 font-medium w-24">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Add row */}
|
||||
{showAdd && (
|
||||
<tr className="border-b bg-surface-hover/40">
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className={inputCls + ' w-40'}
|
||||
placeholder="Template name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
className={inputCls}
|
||||
value={form.category_key}
|
||||
onChange={(e) => setForm({ ...form, category_key: e.target.value })}
|
||||
>
|
||||
<option value="">Any (default)</option>
|
||||
{ALL_CATEGORIES.map((c) => (
|
||||
<option key={c.key} value={c.key}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
className={inputCls}
|
||||
value={form.output_type_id}
|
||||
onChange={(e) => setForm({ ...form, output_type_id: e.target.value })}
|
||||
>
|
||||
<option value="">Any (default)</option>
|
||||
{outputTypes?.map((ot: OutputType) => (
|
||||
<option key={ot.id} value={ot.id}>{ot.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className={inputCls + ' w-28'}
|
||||
value={form.target_collection}
|
||||
onChange={(e) => setForm({ ...form, target_collection: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.material_replace_enabled}
|
||||
onChange={(e) => setForm({ ...form, material_replace_enabled: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.lighting_only}
|
||||
onChange={(e) => setForm({ ...form, lighting_only: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.shadow_catcher_enabled}
|
||||
title="Enable Shadowcatcher collection (Cycles only)"
|
||||
onChange={(e) => setForm({ ...form, shadow_catcher_enabled: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.camera_orbit}
|
||||
title="Rotate camera around product (better GPU performance)"
|
||||
onChange={(e) => setForm({ ...form, camera_orbit: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer text-accent hover:text-accent-hover">
|
||||
<Upload size={14} />
|
||||
{addFile ? addFile.name : 'Choose .blend'}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".blend"
|
||||
className="hidden"
|
||||
onChange={(e) => setAddFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td />
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={!form.name.trim() || !addFile || createMut.isPending}
|
||||
className="p-1 text-status-success-text hover:bg-surface-hover rounded disabled:opacity-40"
|
||||
title="Create"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowAdd(false); setForm(EMPTY_FORM); setAddFile(null) }}
|
||||
className="p-1 text-content-muted hover:bg-surface-hover rounded"
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Template rows */}
|
||||
{isLoading && (
|
||||
<tr><td colSpan={11} className="px-3 py-4 text-center text-content-muted">Loading...</td></tr>
|
||||
)}
|
||||
{templates?.map((t) => {
|
||||
const isEditing = editingId === t.id
|
||||
return (
|
||||
<tr key={t.id} className="border-b hover:bg-surface-hover/50">
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
className={inputCls + ' w-40'}
|
||||
value={editDraft.name ?? t.name}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium">{t.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<select
|
||||
className={inputCls}
|
||||
value={editDraft.category_key ?? t.category_key ?? ''}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, category_key: e.target.value || null })}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{ALL_CATEGORIES.map((c) => (
|
||||
<option key={c.key} value={c.key}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
t.category_key || <span className="text-content-muted">Any</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<select
|
||||
className={inputCls}
|
||||
value={editDraft.output_type_id ?? t.output_type_id ?? ''}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, output_type_id: e.target.value || null })}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{outputTypes?.map((ot: OutputType) => (
|
||||
<option key={ot.id} value={ot.id}>{ot.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
t.output_type_name || <span className="text-content-muted">Any</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
className={inputCls + ' w-28'}
|
||||
value={editDraft.target_collection ?? t.target_collection}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, target_collection: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.material_replace_enabled ?? t.material_replace_enabled}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, material_replace_enabled: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.material_replace_enabled ? (
|
||||
<span className="text-status-success-text text-xs font-medium">Yes</span>
|
||||
) : (
|
||||
<span className="text-content-muted text-xs">No</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.lighting_only ?? t.lighting_only}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, lighting_only: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.lighting_only ? (
|
||||
<span className="text-status-warning-text text-xs font-medium">HDR</span>
|
||||
) : (
|
||||
<span className="text-content-muted text-xs">—</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.shadow_catcher_enabled ?? t.shadow_catcher_enabled}
|
||||
title="Enable Shadowcatcher collection (Cycles only)"
|
||||
onChange={(e) => setEditDraft({ ...editDraft, shadow_catcher_enabled: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.shadow_catcher_enabled ? (
|
||||
<span className="text-violet-600 text-xs font-medium">On</span>
|
||||
) : (
|
||||
<span className="text-content-muted text-xs">—</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.camera_orbit ?? t.camera_orbit}
|
||||
title="Rotate camera around product (better GPU performance)"
|
||||
onChange={(e) => setEditDraft({ ...editDraft, camera_orbit: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.camera_orbit ? (
|
||||
<span className="text-teal-600 text-xs font-medium">Cam</span>
|
||||
) : (
|
||||
<span className="text-content-muted text-xs">Obj</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
|
||||
{t.original_filename}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setReuploadId(t.id); reuploadRef.current?.click() }}
|
||||
className="p-0.5 text-accent hover:bg-surface-hover rounded"
|
||||
title="Re-upload .blend"
|
||||
>
|
||||
<Upload size={12} />
|
||||
</button>
|
||||
<a
|
||||
href={`/api/render-templates/${t.id}/download`}
|
||||
className="p-0.5 text-accent hover:bg-surface-hover rounded"
|
||||
title="Download .blend"
|
||||
>
|
||||
<Download size={12} />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.is_active ?? t.is_active}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, is_active: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.is_active ? (
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="Active" />
|
||||
) : (
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-surface-muted" title="Inactive" />
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={saveEdit} className="p-1 text-status-success-text hover:bg-surface-hover rounded" title="Save">
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="p-1 text-content-muted hover:bg-surface-muted rounded" title="Cancel">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => startEdit(t)} className="p-1 text-accent hover:bg-surface-hover rounded" title="Edit">
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete template "${t.name}"?`)) deleteMut.mutate(t.id)
|
||||
}}
|
||||
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isLoading && (!templates || templates.length === 0) && !showAdd && (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-3 py-6 text-center text-content-muted">
|
||||
No render templates configured. Click "Add Template" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-content-muted">
|
||||
Templates define pre-designed .blend studio setups. When rendering, the system matches templates by Category + Output Type with fallback cascade.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Save, Plus, Trash2, ChevronUp, ChevronDown,
|
||||
GripVertical, Eye, EyeOff, ToggleLeft, ToggleRight,
|
||||
} from 'lucide-react'
|
||||
import api from '../../api/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Visibility = 'required' | 'optional' | 'hidden'
|
||||
|
||||
interface StdField {
|
||||
key: string
|
||||
label: string
|
||||
visibility: Visibility
|
||||
}
|
||||
|
||||
interface CompPair {
|
||||
component_type: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
name: string
|
||||
category_key: string
|
||||
description?: string | null
|
||||
is_active: boolean
|
||||
standard_fields: any
|
||||
component_schema: any
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// All canonical standard field definitions (maps to DB columns in order_items)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALL_FIELD_DEFS: { key: string; defaultLabel: string }[] = [
|
||||
{ key: 'ebene1', defaultLabel: 'Ebene 1' },
|
||||
{ key: 'ebene2', defaultLabel: 'Ebene 2' },
|
||||
{ key: 'baureihe', defaultLabel: 'Baureihe' },
|
||||
{ key: 'pim_id', defaultLabel: 'PIM-ID' },
|
||||
{ key: 'produkt_baureihe', defaultLabel: 'Produkt / Baureihe' },
|
||||
{ key: 'gewaehltes_produkt', defaultLabel: 'Gewähltes Produkt' },
|
||||
{ key: 'name_cad_modell', defaultLabel: 'Name CAD-Modell' },
|
||||
{ key: 'gewuenschte_bildnummer',defaultLabel: 'Gewünschte Bildnummer' },
|
||||
{ key: 'lagertyp', defaultLabel: 'Lagertyp' },
|
||||
{ key: 'medias_rendering', defaultLabel: 'Medias Rendering' },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalisation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeFields(raw: any): StdField[] {
|
||||
// New array format: [{key, label, visibility}]
|
||||
if (Array.isArray(raw) && raw.length > 0 && raw[0].key) {
|
||||
const existing = new Map(raw.map((f: StdField) => [f.key, f]))
|
||||
// Preserve saved order, then append any missing canonical fields
|
||||
const ordered: StdField[] = raw.filter((f: StdField) =>
|
||||
ALL_FIELD_DEFS.some((d) => d.key === f.key),
|
||||
)
|
||||
ALL_FIELD_DEFS.forEach(({ key, defaultLabel }) => {
|
||||
if (!existing.has(key)) {
|
||||
ordered.push({ key, label: defaultLabel, visibility: 'optional' })
|
||||
}
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
// Legacy dict format {"0": {label, required}} or empty — use canonical defaults
|
||||
return ALL_FIELD_DEFS.map(({ key, defaultLabel }) => ({
|
||||
key,
|
||||
label: defaultLabel,
|
||||
visibility: 'optional' as Visibility,
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizePairs(raw: any): CompPair[] {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw.pairs)) return raw.pairs.map((p: any) => ({
|
||||
component_type: p.component_type ?? p.part_name ?? '',
|
||||
required: p.required ?? false,
|
||||
}))
|
||||
if (Array.isArray(raw)) return raw.map((p: any) => ({
|
||||
component_type: p.component_type ?? p.part_name ?? '',
|
||||
required: p.required ?? false,
|
||||
}))
|
||||
return []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function moveItem<T>(arr: T[], from: number, to: number): T[] {
|
||||
const next = [...arr]
|
||||
const [item] = next.splice(from, 1)
|
||||
next.splice(to, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
const VIS_STYLES: Record<Visibility, string> = {
|
||||
required: 'bg-accent text-white',
|
||||
optional: 'bg-blue-500 text-white',
|
||||
hidden: 'bg-surface-muted text-content-secondary',
|
||||
}
|
||||
|
||||
function VisibilityToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Visibility
|
||||
onChange: (v: Visibility) => void
|
||||
}) {
|
||||
const cycle: Visibility[] = ['required', 'optional', 'hidden']
|
||||
const labels: Record<Visibility, string> = { required: 'Required', optional: 'Optional', hidden: 'Hidden' }
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(cycle[(cycle.indexOf(value) + 1) % 3])}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${VIS_STYLES[value]}`}
|
||||
title="Click to cycle: Required → Optional → Hidden"
|
||||
>
|
||||
{labels[value]}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function TemplateEditor({
|
||||
template,
|
||||
onClose,
|
||||
}: {
|
||||
template: Template
|
||||
onClose: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [name, setName] = useState(template.name)
|
||||
const [description, setDescription] = useState(template.description ?? '')
|
||||
const [isActive, setIsActive] = useState(template.is_active)
|
||||
const [fields, setFields] = useState<StdField[]>(() => normalizeFields(template.standard_fields))
|
||||
const [pairs, setPairs] = useState<CompPair[]>(() => normalizePairs(template.component_schema))
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
const [newFieldKey, setNewFieldKey] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setName(template.name)
|
||||
setDescription(template.description ?? '')
|
||||
setIsActive(template.is_active)
|
||||
setFields(normalizeFields(template.standard_fields))
|
||||
setPairs(normalizePairs(template.component_schema))
|
||||
}, [template.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () =>
|
||||
api.patch(`/templates/${template.id}`, {
|
||||
name,
|
||||
description: description || null,
|
||||
is_active: isActive,
|
||||
standard_fields: fields,
|
||||
component_schema: { pairs },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Template saved')
|
||||
qc.invalidateQueries({ queryKey: ['admin-templates'] })
|
||||
qc.invalidateQueries({ queryKey: ['templates'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to save'),
|
||||
})
|
||||
|
||||
// ---- Standard fields helpers ----
|
||||
function updateField(i: number, patch: Partial<StdField>) {
|
||||
setFields((f) => f.map((x, j) => (j === i ? { ...x, ...patch } : x)))
|
||||
}
|
||||
function removeField(i: number) {
|
||||
// Don't delete — mark hidden so it stays in DB but hidden in UI
|
||||
updateField(i, { visibility: 'hidden' })
|
||||
}
|
||||
const hiddenKeys = new Set(fields.filter((f) => f.visibility === 'hidden').map((f) => f.key))
|
||||
const availableToAdd = ALL_FIELD_DEFS.filter((d) => hiddenKeys.has(d.key))
|
||||
|
||||
function restoreField(key: string) {
|
||||
setFields((f) =>
|
||||
f.map((x) => (x.key === key ? { ...x, visibility: 'optional' } : x)),
|
||||
)
|
||||
setNewFieldKey('')
|
||||
}
|
||||
|
||||
// ---- Component pair helpers ----
|
||||
function updatePair(i: number, patch: Partial<CompPair>) {
|
||||
setPairs((p) => p.map((x, j) => (j === i ? { ...x, ...patch } : x)))
|
||||
}
|
||||
function addPair() {
|
||||
setPairs((p) => [...p, { component_type: '', required: false }])
|
||||
}
|
||||
function removePair(i: number) {
|
||||
setPairs((p) => p.filter((_, j) => j !== i))
|
||||
}
|
||||
|
||||
// ---- Visible fields for rendering ----
|
||||
const visibleFields = showHidden ? fields : fields.filter((f) => f.visibility !== 'hidden')
|
||||
|
||||
// ---- Shared styles ----
|
||||
const ROW = 'flex items-center gap-2 px-3 py-2 rounded-lg border border-border-light bg-surface-alt group'
|
||||
const ICON_BTN = 'p-1 rounded text-content-muted hover:text-content-secondary hover:bg-surface transition-colors disabled:opacity-30'
|
||||
const INPUT = 'flex-1 min-w-0 text-sm bg-transparent border-b border-transparent focus:border-accent focus:outline-none py-0.5 text-content'
|
||||
|
||||
return (
|
||||
<div className="border border-border-default rounded-xl bg-surface shadow-sm">
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Header */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-default bg-surface-alt rounded-t-xl gap-4">
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
{/* Editable name */}
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-base font-semibold text-content bg-transparent border-b border-transparent focus:border-accent focus:outline-none w-full"
|
||||
placeholder="Template name"
|
||||
/>
|
||||
{/* Category key (read-only) + active toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-content-muted font-mono">{template.category_key}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActive((v) => !v)}
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full transition-colors ${
|
||||
isActive ? 'bg-status-success-bg text-status-success-text' : 'bg-surface-muted text-content-muted'
|
||||
}`}
|
||||
>
|
||||
{isActive ? <ToggleRight size={13} /> : <ToggleLeft size={13} />}
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="text-xs text-content-muted bg-transparent border-b border-transparent focus:border-accent focus:outline-none w-full"
|
||||
placeholder="Description (optional)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => saveMut.mutate()}
|
||||
disabled={saveMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
<Save size={14} />
|
||||
{saveMut.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-8">
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Standard Fields */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content-secondary uppercase tracking-wide">
|
||||
Standard Fields
|
||||
</h3>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Rename, reorder, and set visibility for each column. Hidden fields are excluded from forms.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-content-muted hover:text-content px-2 py-1 rounded border border-border-default hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{showHidden ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
{showHidden ? 'Hide hidden' : `Show hidden (${hiddenKeys.size})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{visibleFields.map((field, i) => {
|
||||
// Real index in fields array (needed for moveItem / updateField)
|
||||
const realIdx = fields.indexOf(field)
|
||||
const isHidden = field.visibility === 'hidden'
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={`${ROW} ${isHidden ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{/* Reorder */}
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BTN}
|
||||
disabled={realIdx === 0}
|
||||
onClick={() => setFields((f) => moveItem(f, realIdx, realIdx - 1))}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BTN}
|
||||
disabled={realIdx === fields.length - 1}
|
||||
onClick={() => setFields((f) => moveItem(f, realIdx, realIdx + 1))}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GripVertical size={13} className="text-content-muted shrink-0" />
|
||||
|
||||
{/* Label */}
|
||||
<input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(realIdx, { label: e.target.value })}
|
||||
className={INPUT}
|
||||
placeholder="Field label"
|
||||
/>
|
||||
|
||||
{/* Key badge */}
|
||||
<span className="hidden md:block text-xs text-content-muted font-mono w-48 shrink-0 truncate">
|
||||
{field.key}
|
||||
</span>
|
||||
|
||||
{/* Visibility */}
|
||||
<VisibilityToggle
|
||||
value={field.visibility}
|
||||
onChange={(v) => updateField(realIdx, { visibility: v })}
|
||||
/>
|
||||
|
||||
{/* Hide button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${ICON_BTN} hover:text-red-500 hover:bg-red-50`}
|
||||
onClick={() => removeField(realIdx)}
|
||||
aria-label="Hide field"
|
||||
title="Hide this field"
|
||||
>
|
||||
<EyeOff size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Restore hidden field */}
|
||||
{availableToAdd.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<select
|
||||
value={newFieldKey}
|
||||
onChange={(e) => setNewFieldKey(e.target.value)}
|
||||
className="text-xs border border-border-default rounded px-2 py-1.5 text-content-secondary focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">Restore a hidden field…</option>
|
||||
{availableToAdd.map((d) => (
|
||||
<option key={d.key} value={d.key}>
|
||||
{fields.find((f) => f.key === d.key)?.label || d.defaultLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{newFieldKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restoreField(newFieldKey)}
|
||||
className="text-xs px-2 py-1.5 rounded bg-accent text-white hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Component Schema */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-content-secondary uppercase tracking-wide">
|
||||
Component Schema
|
||||
</h3>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Define the expected component types that appear as column pairs in the Excel file (cols 11+).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{pairs.length === 0 && (
|
||||
<p className="text-sm text-content-muted italic px-3">No component types defined.</p>
|
||||
)}
|
||||
{pairs.map((pair, i) => (
|
||||
<div key={i} className={ROW}>
|
||||
{/* Reorder */}
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BTN}
|
||||
disabled={i === 0}
|
||||
onClick={() => setPairs((p) => moveItem(p, i, i - 1))}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BTN}
|
||||
disabled={i === pairs.length - 1}
|
||||
onClick={() => setPairs((p) => moveItem(p, i, i + 1))}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GripVertical size={13} className="text-content-muted shrink-0" />
|
||||
|
||||
{/* Index badge */}
|
||||
<span className="text-xs text-content-muted font-mono w-6 text-center shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
|
||||
{/* Component type name */}
|
||||
<input
|
||||
value={pair.component_type}
|
||||
onChange={(e) => updatePair(i, { component_type: e.target.value })}
|
||||
placeholder="Component type name"
|
||||
className={INPUT}
|
||||
/>
|
||||
|
||||
{/* Required toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updatePair(i, { required: !pair.required })}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium shrink-0 transition-colors ${
|
||||
pair.required
|
||||
? 'text-white'
|
||||
: 'bg-surface-muted text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
style={pair.required ? { backgroundColor: 'var(--color-accent)' } : undefined}
|
||||
title="Toggle required"
|
||||
>
|
||||
{pair.required ? 'Required' : 'Optional'}
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${ICON_BTN} hover:text-red-500 hover:bg-red-50`}
|
||||
onClick={() => removePair(i)}
|
||||
aria-label="Delete component"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPair}
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-accent hover:text-accent-hover px-3 py-1.5 rounded border border-border-default hover:bg-accent-light transition-colors"
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add component type
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
Suspense,
|
||||
useRef,
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
Component,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { Canvas, useThree, useFrame } from '@react-three/fiber'
|
||||
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
|
||||
import { toast } from 'sonner'
|
||||
import { X, Camera, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import api from '../../api/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ThreeDViewerProps {
|
||||
cadFileId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner model loader – separated so Suspense can catch it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GltfModel({ url }: { url: string }) {
|
||||
const { scene } = useGLTF(url)
|
||||
return <primitive object={scene} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Screenshot helper – lives inside Canvas so it can access gl / useThree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ScreenshotCaptureProps {
|
||||
enabled: boolean
|
||||
cadFileId: string
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProps) {
|
||||
const { gl } = useThree()
|
||||
const didCapture = useRef(false)
|
||||
|
||||
useFrame(() => {
|
||||
if (!enabled || didCapture.current) return
|
||||
didCapture.current = true
|
||||
|
||||
// Grab the canvas as a data-URL after the current frame has been rendered
|
||||
const dataUrl = gl.domElement.toDataURL('image/png')
|
||||
|
||||
// Convert data-URL → Blob without a network fetch:
|
||||
// data:[<mediatype>][;base64],<data>
|
||||
const [header, base64Data] = dataUrl.split(',')
|
||||
const mimeMatch = header.match(/:(.*?);/)
|
||||
const mimeType = mimeMatch ? mimeMatch[1] : 'image/png'
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteArray = new Uint8Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteArray[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: mimeType })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('thumbnail', blob, 'thumbnail.png')
|
||||
|
||||
api
|
||||
.post(`/cad/${cadFileId}/regenerate-thumbnail`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
.then(() => {
|
||||
toast.success('Thumbnail captured and saved')
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
console.error('Thumbnail upload failed', msg)
|
||||
toast.error('Failed to save thumbnail')
|
||||
})
|
||||
.finally(() => {
|
||||
didCapture.current = false
|
||||
onDone()
|
||||
})
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error boundary for the GLTF loader inside Suspense
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class GltfErrorBoundary extends Component<
|
||||
{ children: ReactNode; onError: (msg: string) => void },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: ReactNode; onError: (msg: string) => void }) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, _info: ErrorInfo): void {
|
||||
this.props.onError(error.message || 'Failed to parse GLTF')
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) return null
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading overlay (shown while model resolves inside Canvas)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LoadingOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-3 pointer-events-none z-10">
|
||||
<Loader2 size={40} className="animate-spin text-accent" />
|
||||
<p className="text-sm text-gray-300">Loading 3D model…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model loader with resolved tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ModelWithReadyProps {
|
||||
url: string
|
||||
onReady: () => void
|
||||
}
|
||||
|
||||
function ModelWithReady({ url, onReady }: ModelWithReadyProps) {
|
||||
const { scene } = useGLTF(url)
|
||||
|
||||
useEffect(() => {
|
||||
onReady()
|
||||
}, [onReady])
|
||||
|
||||
return <primitive object={scene} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ThreeDViewer({ cadFileId, onClose }: ThreeDViewerProps) {
|
||||
const modelUrl = `/api/cad/${cadFileId}/model`
|
||||
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [modelReady, setModelReady] = useState(false)
|
||||
|
||||
const handleModelReady = useCallback(() => setModelReady(true), [])
|
||||
const handleError = useCallback((msg: string) => setLoadError(msg), [])
|
||||
const handleCaptureDone = useCallback(() => setCapturing(false), [])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Toolbar */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800 shrink-0">
|
||||
<span className="text-white font-semibold tracking-wide">3D Viewer</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setCapturing(true)}
|
||||
disabled={capturing || !modelReady || loadError !== null}
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{capturing ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<Camera size={15} />
|
||||
)}
|
||||
{capturing ? 'Capturing…' : 'Capture Angle'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
|
||||
aria-label="Close viewer"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Viewport */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="relative flex-1">
|
||||
{/* Error state */}
|
||||
{loadError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-4 z-20">
|
||||
<AlertTriangle size={48} className="text-red-400" />
|
||||
<p className="text-lg font-semibold">Failed to load 3D model</p>
|
||||
<p className="text-sm text-gray-400 max-w-sm text-center">{loadError}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-2 px-4 py-2 rounded-md bg-gray-700 hover:bg-gray-600 text-sm transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading overlay – visible until model signals ready */}
|
||||
{!modelReady && !loadError && <LoadingOverlay />}
|
||||
|
||||
{/* Three.js Canvas */}
|
||||
<Canvas
|
||||
camera={{ position: [0, 2, 5], fov: 45 }}
|
||||
gl={{ preserveDrawingBuffer: true }}
|
||||
style={{ width: '100%', height: '100%', background: '#111827' }}
|
||||
>
|
||||
{/* Lights */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[5, 10, 7]} intensity={1.0} castShadow />
|
||||
<directionalLight position={[-5, -5, -5]} intensity={0.25} />
|
||||
|
||||
{/* GLTF model */}
|
||||
<GltfErrorBoundary onError={handleError}>
|
||||
<Suspense fallback={null}>
|
||||
<ModelWithReady url={modelUrl} onReady={handleModelReady} />
|
||||
</Suspense>
|
||||
</GltfErrorBoundary>
|
||||
|
||||
{/* Camera controls */}
|
||||
<OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} />
|
||||
|
||||
{/* Environment map for PBR materials */}
|
||||
<Environment preset="city" />
|
||||
|
||||
{/* Screenshot capture – only active when triggered */}
|
||||
{capturing && (
|
||||
<ScreenshotCapture
|
||||
enabled={capturing}
|
||||
cadFileId={cadFileId}
|
||||
onDone={handleCaptureDone}
|
||||
/>
|
||||
)}
|
||||
</Canvas>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, Legend,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { getDashboardKPIs } from '../../api/analytics'
|
||||
|
||||
const SCHAEFFLER_GREEN = '#00893d'
|
||||
const INDIGO = '#6366f1'
|
||||
const AMBER = '#f59e0b'
|
||||
const GREEN = '#22c55e'
|
||||
const RED = '#ef4444'
|
||||
const BLUE = '#3b82f6'
|
||||
const PURPLE = '#8b5cf6'
|
||||
const TEAL = '#14b8a6'
|
||||
const ROSE = '#f43f5e'
|
||||
const CYAN = '#06b6d4'
|
||||
|
||||
const CATEGORY_COLORS = [SCHAEFFLER_GREEN, INDIGO, AMBER, BLUE, PURPLE, TEAL, ROSE, CYAN]
|
||||
|
||||
const CHART_TOOLTIP_STYLE = {
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--color-text)',
|
||||
}
|
||||
|
||||
type Preset = '4w' | '3m' | '6m' | '1y' | 'all' | 'custom'
|
||||
|
||||
const PRESETS: { key: Preset; label: string }[] = [
|
||||
{ key: '4w', label: '4 W' },
|
||||
{ key: '3m', label: '3 M' },
|
||||
{ key: '6m', label: '6 M' },
|
||||
{ key: '1y', label: '1 Y' },
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
function toISO(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function presetRange(key: Preset): { from: string; to: string } | null {
|
||||
const now = new Date()
|
||||
const to = toISO(now)
|
||||
switch (key) {
|
||||
case '4w': { const d = new Date(now); d.setDate(d.getDate() - 28); return { from: toISO(d), to } }
|
||||
case '3m': { const d = new Date(now); d.setMonth(d.getMonth() - 3); return { from: toISO(d), to } }
|
||||
case '6m': { const d = new Date(now); d.setMonth(d.getMonth() - 6); return { from: toISO(d), to } }
|
||||
case '1y': { const d = new Date(now); d.setFullYear(d.getFullYear() - 1); return { from: toISO(d), to } }
|
||||
case 'all': return null // no params → backend defaults omitted, we send nothing
|
||||
case 'custom': return null
|
||||
}
|
||||
}
|
||||
|
||||
function presetSubtitle(key: Preset, customFrom: string, customTo: string): string {
|
||||
switch (key) {
|
||||
case '4w': return 'Last 4 weeks'
|
||||
case '3m': return 'Last 3 months'
|
||||
case '6m': return 'Last 6 months'
|
||||
case '1y': return 'Last year'
|
||||
case 'all': return 'All time'
|
||||
case 'custom': {
|
||||
if (customFrom && customTo) {
|
||||
const f = new Date(customFrom + 'T00:00:00')
|
||||
const t = new Date(customTo + 'T00:00:00')
|
||||
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
return `${fmt(f)} – ${fmt(t)}`
|
||||
}
|
||||
return 'Select a date range'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fmtSeconds(s: number | null | undefined): string {
|
||||
if (s == null) return '—'
|
||||
if (s >= 60) return `${(s / 60).toFixed(1)} min`
|
||||
return `${s.toFixed(1)} s`
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [preset, setPreset] = useState<Preset>('6m')
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
|
||||
const { dateFrom, dateTo } = useMemo(() => {
|
||||
if (preset === 'custom') {
|
||||
return { dateFrom: customFrom || undefined, dateTo: customTo || undefined }
|
||||
}
|
||||
if (preset === 'all') {
|
||||
return { dateFrom: '2000-01-01', dateTo: toISO(new Date()) }
|
||||
}
|
||||
const range = presetRange(preset)
|
||||
return range ? { dateFrom: range.from, dateTo: range.to } : { dateFrom: undefined, dateTo: undefined }
|
||||
}, [preset, customFrom, customTo])
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['dashboard-kpis', dateFrom, dateTo],
|
||||
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
function selectPreset(key: Preset) {
|
||||
setPreset(key)
|
||||
if (key !== 'custom') {
|
||||
setCustomFrom('')
|
||||
setCustomTo('')
|
||||
}
|
||||
}
|
||||
|
||||
const subtitle = presetSubtitle(preset, customFrom, customTo)
|
||||
|
||||
if (isLoading) return <div className="p-8 text-center text-content-muted">Loading analytics…</div>
|
||||
if (error) return <div className="p-8 text-center text-red-500">Failed to load analytics</div>
|
||||
if (!data) return null
|
||||
|
||||
const {
|
||||
summary, throughput, revenue, processing_times, item_status, render_times,
|
||||
product_stats, output_type_usage, render_status, renderer_usage,
|
||||
top_products, orders_by_user, category_revenue, render_backend_stats,
|
||||
render_time_by_output_type,
|
||||
} = data
|
||||
|
||||
const pieData = [
|
||||
{ name: 'Pending', value: item_status.pending, color: AMBER },
|
||||
{ name: 'Approved', value: item_status.approved, color: GREEN },
|
||||
{ name: 'Rejected', value: item_status.rejected, color: RED },
|
||||
]
|
||||
|
||||
const renderStatusPieData = [
|
||||
{ name: 'Pending', value: render_status.pending, color: AMBER },
|
||||
{ name: 'Processing', value: render_status.processing, color: BLUE },
|
||||
{ name: 'Completed', value: render_status.completed, color: GREEN },
|
||||
{ name: 'Failed', value: render_status.failed, color: RED },
|
||||
]
|
||||
|
||||
const rendererPieData = renderer_usage.map((r, i) => ({
|
||||
name: r.renderer || 'unknown',
|
||||
value: r.count,
|
||||
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="mb-2">
|
||||
<h1 className="text-2xl font-bold text-content">Analytics Dashboard</h1>
|
||||
<p className="text-content-muted mt-1 text-sm">{subtitle} · refreshes every 60 s</p>
|
||||
</div>
|
||||
|
||||
{/* Timeframe selector bar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PRESETS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => selectPreset(key)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-colors ${
|
||||
preset === key
|
||||
? 'text-white'
|
||||
: 'bg-surface-muted text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
style={preset === key ? { backgroundColor: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{preset === 'custom' && (
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<input
|
||||
type="date"
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
className="border border-border-default rounded px-2 py-1 text-xs"
|
||||
/>
|
||||
<span className="text-content-muted text-xs">–</span>
|
||||
<input
|
||||
type="date"
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
className="border border-border-default rounded px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 1 — Summary cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<SummaryCard label="Total Orders" value={summary.total_orders} />
|
||||
<SummaryCard label="Completed" value={summary.completed_orders} />
|
||||
<SummaryCard label="Rendering Items" value={summary.total_rendering_items} />
|
||||
<SummaryCard label="Total Revenue (€)" value={`€ ${summary.total_revenue.toFixed(2)}`} />
|
||||
<SummaryCard label="Products Rendered" value={product_stats.unique_products_rendered} />
|
||||
<SummaryCard label="CAD Coverage" value={`${product_stats.products_with_cad} / ${product_stats.total_products}`} />
|
||||
</div>
|
||||
|
||||
{/* Row 2 — Throughput + Item status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="card p-4 lg:col-span-2">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Order Throughput (weekly)</h2>
|
||||
{throughput.length === 0 ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={throughput} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="week" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Line type="monotone" dataKey="count" name="Created" stroke={SCHAEFFLER_GREEN} strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="completed" name="Completed" stroke={INDIGO} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Item Status</h2>
|
||||
{pieData.every((d) => d.value === 0) ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
outerRadius={70}
|
||||
label={({ name, value }) => `${name}: ${value}`}
|
||||
labelLine={false}
|
||||
>
|
||||
{pieData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3 — Revenue + Processing times */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Revenue per Month (€)</h2>
|
||||
{revenue.length === 0 ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={revenue} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="month" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} formatter={(v: number | undefined) => v != null ? [`€ ${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']} />
|
||||
<Bar dataKey="revenue" fill={SCHAEFFLER_GREEN} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-4">Processing Times</h2>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border-light">
|
||||
<MetricRow label="Avg submit → complete" value={fmtSeconds(processing_times.avg_submit_to_complete_s)} />
|
||||
<MetricRow label="Avg submit → processing" value={fmtSeconds(processing_times.avg_submit_to_processing_s)} />
|
||||
<MetricRow label="P50 (median)" value={fmtSeconds(processing_times.p50_s)} />
|
||||
<MetricRow label="P95" value={fmtSeconds(processing_times.p95_s)} />
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 className="text-sm font-semibold text-content-secondary mt-5 mb-3">Render Time Breakdown</h2>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border-light">
|
||||
<MetricRow label="Avg render time" value={fmtSeconds(render_times.avg_render_s)} />
|
||||
<MetricRow label="Completed renders" value={String(render_times.sample_count)} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3b — Render Time by Output Type */}
|
||||
{render_time_by_output_type && render_time_by_output_type.length > 0 && (
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-4">Renderzeit pro Output-Typ</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Horizontal bar chart: Avg + P50 per output type */}
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, render_time_by_output_type.length * 44)}>
|
||||
<BarChart
|
||||
data={render_time_by_output_type}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 40, left: 8, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }}
|
||||
tickFormatter={(v: number) => v >= 60 ? `${(v / 60).toFixed(0)}m` : `${v.toFixed(0)}s`}
|
||||
/>
|
||||
<YAxis type="category" dataKey="output_type" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} width={130} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(v: number | null | undefined, name: string) => [
|
||||
v != null ? (v >= 60 ? `${(v / 60).toFixed(1)} min` : `${v.toFixed(0)} s`) : '—',
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="avg_render_s" name="Ø Renderzeit" fill={INDIGO} radius={[0, 3, 3, 0]} />
|
||||
<Bar dataKey="p50_render_s" name="Median (P50)" fill={TEAL} radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Detail table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border-default text-left text-content-muted">
|
||||
<th className="pb-2 pr-3 font-medium">Output-Typ</th>
|
||||
<th className="pb-2 px-2 font-medium text-right">Jobs</th>
|
||||
<th className="pb-2 px-2 font-medium text-right">Ø</th>
|
||||
<th className="pb-2 px-2 font-medium text-right">P50</th>
|
||||
<th className="pb-2 px-2 font-medium text-right">Min</th>
|
||||
<th className="pb-2 pl-2 font-medium text-right">Max</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{render_time_by_output_type.map((r) => (
|
||||
<tr key={r.output_type} className="hover:bg-surface-hover">
|
||||
<td className="py-1.5 pr-3 font-medium text-content-secondary max-w-[160px] truncate" title={r.output_type}>
|
||||
{r.output_type}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-right text-content-muted">{r.job_count}</td>
|
||||
<td className="py-1.5 px-2 text-right tabular-nums">{fmtSeconds(r.avg_render_s)}</td>
|
||||
<td className="py-1.5 px-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.p50_render_s)}</td>
|
||||
<td className="py-1.5 px-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.min_render_s)}</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.max_render_s)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 4 — Output Type Usage + Render Status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Output Type Usage</h2>
|
||||
{output_type_usage.length === 0 ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={output_type_usage} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="output_type" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" fill={INDIGO} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Render Status</h2>
|
||||
{renderStatusPieData.every((d) => d.value === 0) ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={renderStatusPieData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
outerRadius={70}
|
||||
label={({ name, value }) => `${name}: ${value}`}
|
||||
labelLine={false}
|
||||
>
|
||||
{renderStatusPieData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5 — Products by Category + Renderer Usage */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Products by Category</h2>
|
||||
{product_stats.products_by_category.length === 0 ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={product_stats.products_by_category} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="category" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
|
||||
{product_stats.products_by_category.map((_, i) => (
|
||||
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Renderer Usage</h2>
|
||||
{rendererPieData.length === 0 ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={rendererPieData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
outerRadius={70}
|
||||
label={({ name, value }) => `${name}: ${value}`}
|
||||
labelLine={false}
|
||||
>
|
||||
{rendererPieData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5b — Render Backend Comparison */}
|
||||
{render_backend_stats.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Render Backend — Job Count</h2>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={render_backend_stats} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="backend" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="completed" name="Completed" fill={GREEN} radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="failed" name="Failed" fill={RED} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Render Backend — Avg Time</h2>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={render_backend_stats} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="backend" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} label={{ value: 'seconds', angle: -90, position: 'insideLeft', style: { fontSize: 10 } }} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} formatter={(v: number | undefined) => v != null ? [`${v.toFixed(1)}s`, ''] : ['—', '']} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="avg_render_s" name="Avg" fill={INDIGO} radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="p50_render_s" name="Median (P50)" fill={TEAL} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 6 — Top 10 Products + Category Revenue */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Top 10 Products</h2>
|
||||
{top_products.length === 0 ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-light text-left">
|
||||
<th className="py-2 pr-3 text-content-secondary font-medium">PIM-ID</th>
|
||||
<th className="py-2 pr-3 text-content-secondary font-medium">Product</th>
|
||||
<th className="py-2 pr-3 text-content-secondary font-medium">Category</th>
|
||||
<th className="py-2 text-content-secondary font-medium text-right">Orders</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{top_products.map((p) => (
|
||||
<tr key={p.pim_id}>
|
||||
<td className="py-1.5 pr-3 font-mono text-xs text-content-muted">{p.pim_id}</td>
|
||||
<td className="py-1.5 pr-3 text-content truncate max-w-[160px]">{p.product_name || '—'}</td>
|
||||
<td className="py-1.5 pr-3 text-content-muted">{p.category}</td>
|
||||
<td className="py-1.5 font-medium text-content text-right">{p.order_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Revenue by Category (€)</h2>
|
||||
{category_revenue.length === 0 ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={category_revenue} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="category" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} formatter={(v: number | undefined) => v != null ? [`€ ${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']} />
|
||||
<Bar dataKey="revenue" radius={[3, 3, 0, 0]}>
|
||||
{category_revenue.map((_, i) => (
|
||||
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 7 — Orders by User */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-content-secondary mb-3">Orders by User</h2>
|
||||
{orders_by_user.length === 0 ? (
|
||||
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-light text-left">
|
||||
<th className="py-2 pr-3 text-content-secondary font-medium">Name</th>
|
||||
<th className="py-2 pr-3 text-content-secondary font-medium">Email</th>
|
||||
<th className="py-2 pr-3 text-content-secondary font-medium">Role</th>
|
||||
<th className="py-2 pr-3 text-content-secondary font-medium text-right">Orders</th>
|
||||
<th className="py-2 text-content-secondary font-medium text-right">Revenue (€)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{orders_by_user.map((u) => (
|
||||
<tr key={u.email}>
|
||||
<td className="py-1.5 pr-3 text-content">{u.full_name}</td>
|
||||
<td className="py-1.5 pr-3 text-content-muted text-xs">{u.email}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-secondary">
|
||||
{u.role === 'project_manager' ? 'PM' : u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 font-medium text-content text-right">{u.order_count}</td>
|
||||
<td className="py-1.5 font-medium text-content text-right">€ {u.revenue.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<p className="text-2xl font-bold text-content">{value}</p>
|
||||
<p className="text-sm text-content-muted mt-1">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td className="py-1.5 pr-3 text-content-secondary">{label}</td>
|
||||
<td className="py-1.5 font-medium text-content text-right">{value}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Package, Upload, CheckCircle, Clock, AlertCircle } from 'lucide-react'
|
||||
import { listOrders } from '../../api/orders'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
|
||||
export default function ClientDashboard() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const { data: orders } = useQuery({ queryKey: ['orders'], queryFn: () => listOrders({ limit: 100 }) })
|
||||
|
||||
const stats = {
|
||||
total: orders?.length ?? 0,
|
||||
draft: orders?.filter((o) => o.status === 'draft').length ?? 0,
|
||||
submitted: orders?.filter((o) => o.status === 'submitted').length ?? 0,
|
||||
completed: orders?.filter((o) => o.status === 'completed').length ?? 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-content">Welcome, {user?.full_name}</h1>
|
||||
<p className="text-content-muted mt-1">Schaeffler Media Creation Pipeline</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard label="Total Orders" value={stats.total} icon={Package} color="blue" />
|
||||
<StatCard label="Drafts" value={stats.draft} icon={Clock} color="yellow" />
|
||||
<StatCard label="Submitted" value={stats.submitted} icon={AlertCircle} color="orange" />
|
||||
<StatCard label="Completed" value={stats.completed} icon={CheckCircle} color="green" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="card p-6">
|
||||
<h2 className="font-semibold text-content mb-4">Quick Actions</h2>
|
||||
<div className="space-y-3">
|
||||
<Link to="/upload" className="btn-primary w-full justify-center">
|
||||
<Upload size={16} />
|
||||
Upload Excel Order List
|
||||
</Link>
|
||||
<Link to="/orders" className="btn-secondary w-full justify-center">
|
||||
<Package size={16} />
|
||||
View All Orders
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h2 className="font-semibold text-content mb-4">Recent Orders</h2>
|
||||
{orders && orders.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{orders.slice(0, 5).map((order) => (
|
||||
<Link
|
||||
key={order.id}
|
||||
to={`/orders/${order.id}`}
|
||||
className="flex items-center justify-between p-2 rounded hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-content">{order.order_number}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{order.estimated_price != null && (
|
||||
<span className="text-xs text-content-muted">
|
||||
€ {Number(order.estimated_price).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-content-muted">No orders yet. Upload an Excel file to get started.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon: Icon, color }: { label: string; value: number; icon: any; color: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
blue: 'text-status-info-text bg-status-info-bg',
|
||||
yellow: 'text-yellow-600 bg-yellow-50',
|
||||
orange: 'text-status-warning-text bg-status-warning-bg',
|
||||
green: 'text-status-success-text bg-status-success-bg',
|
||||
}
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${colors[color]}`}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-content">{value}</p>
|
||||
<p className="text-sm text-content-muted mt-1">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, string> = {
|
||||
draft: 'badge-gray',
|
||||
submitted: 'badge-blue',
|
||||
processing: 'badge-yellow',
|
||||
completed: 'badge-green',
|
||||
rejected: 'badge-red',
|
||||
}
|
||||
return <span className={map[status] ?? 'badge-gray'}>{status}</span>
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom'
|
||||
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal } from 'lucide-react'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import { clsx } from 'clsx'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getWorkerActivity } from '../../api/worker'
|
||||
import { listOrders } from '../../api/orders'
|
||||
import NotificationCenter from './NotificationCenter'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
{ to: '/orders', icon: Package, label: 'Orders' },
|
||||
{ to: '/products', icon: Library, label: 'Products' },
|
||||
{ to: '/materials', icon: FlaskConical, label: 'Materials' },
|
||||
{ to: '/activity', icon: Activity, label: 'Activity' },
|
||||
{ to: '/preferences', icon: SlidersHorizontal, label: 'Preferences' },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: activity } = useQuery({
|
||||
queryKey: ['worker-activity'],
|
||||
queryFn: getWorkerActivity,
|
||||
refetchInterval: 8000,
|
||||
staleTime: 4000,
|
||||
})
|
||||
|
||||
const { data: draftOrders } = useQuery({
|
||||
queryKey: ['orders', 'draft-count'],
|
||||
queryFn: () => listOrders({ status: 'draft' }),
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const draftCount = draftOrders?.length ?? 0
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-surface-alt">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-60 flex-shrink-0 bg-surface border-r border-border-default flex flex-col">
|
||||
<div className="p-5 border-b border-border-default">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-accent rounded flex items-center justify-center">
|
||||
<span className="text-accent-text text-sm font-bold">S</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-content text-sm">Schaeffler</p>
|
||||
<p className="text-xs text-content-muted">Automat</p>
|
||||
</div>
|
||||
<NotificationCenter />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{/* New Order — primary CTA at the top */}
|
||||
<Link
|
||||
to="/orders/new"
|
||||
className="flex items-center gap-2 px-3 py-2.5 mb-3 rounded-md text-sm font-semibold bg-accent text-accent-text hover:bg-accent-hover transition-colors shadow-sm"
|
||||
>
|
||||
<Plus size={18} />
|
||||
New Order
|
||||
</Link>
|
||||
|
||||
{nav.map(({ to, icon: Icon, label, end }) => {
|
||||
const isActivity = to === '/activity'
|
||||
const isOrders = to === '/orders'
|
||||
const showSpinner = isActivity && ((activity?.active_count ?? 0) + (activity?.render_active_count ?? 0)) > 0
|
||||
const showFailed = isActivity && !showSpinner && ((activity?.failed_count ?? 0) + (activity?.render_failed_count ?? 0)) > 0
|
||||
const showDraftBadge = isOrders && draftCount > 0
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
{showDraftBadge && (
|
||||
<span className="ml-auto text-xs px-1.5 py-0.5 rounded-full bg-surface-muted text-content-muted font-semibold leading-none">
|
||||
{draftCount}
|
||||
</span>
|
||||
)}
|
||||
{showSpinner && (
|
||||
<span className="ml-auto w-2 h-2 rounded-full bg-blue-500 animate-pulse" title="Processing…" />
|
||||
)}
|
||||
{showFailed && (
|
||||
<span className="ml-auto w-2 h-2 rounded-full bg-red-500" title="Failed tasks" />
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
|
||||
{(user?.role === 'admin' || user?.role === 'project_manager') && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings size={18} />
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-border-default space-y-1">
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="w-7 h-7 bg-accent rounded-full flex items-center justify-center shrink-0">
|
||||
<span className="text-accent-text text-xs font-bold">{user?.full_name?.[0] ?? 'U'}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-content truncate">{user?.full_name}</p>
|
||||
<p className="text-xs text-content-muted truncate">
|
||||
{user?.role === 'project_manager' ? 'Project Manager' : user?.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-content-secondary hover:bg-surface-hover rounded-md transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Bell, Send, PlayCircle, CheckCircle, XCircle, Image, AlertTriangle, X,
|
||||
} from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import {
|
||||
getNotifications, getUnreadCount, markAsRead, markOneAsRead,
|
||||
type Notification,
|
||||
} from '../../api/notifications'
|
||||
|
||||
const ACTION_CONFIG: Record<string, { icon: typeof Bell; label: (d: Record<string, unknown> | null) => string; color: string }> = {
|
||||
'order.submitted': {
|
||||
icon: Send,
|
||||
label: (d) => `Order ${d?.order_number ?? '?'} submitted`,
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
'order.processing': {
|
||||
icon: PlayCircle,
|
||||
label: (d) => `Order ${d?.order_number ?? '?'} is processing`,
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
'order.completed': {
|
||||
icon: CheckCircle,
|
||||
label: (d) => `Order ${d?.order_number ?? '?'} completed`,
|
||||
color: 'text-status-success-text',
|
||||
},
|
||||
'order.rejected': {
|
||||
icon: XCircle,
|
||||
label: (d) => `Order ${d?.order_number ?? '?'} was rejected`,
|
||||
color: 'text-red-500',
|
||||
},
|
||||
'render.completed': {
|
||||
icon: Image,
|
||||
label: (d) => `Render done: ${d?.product_name ?? 'unknown'} — ${d?.output_type ?? ''}`,
|
||||
color: 'text-status-success-text',
|
||||
},
|
||||
'render.failed': {
|
||||
icon: AlertTriangle,
|
||||
label: (d) => `Render failed: ${d?.product_name ?? 'unknown'} — ${d?.output_type ?? ''}`,
|
||||
color: 'text-red-500',
|
||||
},
|
||||
'excel.import_warnings': {
|
||||
icon: AlertTriangle,
|
||||
label: (d) => `Excel '${d?.filename ?? '?'}' had ${d?.warning_count ?? '?'} warning(s)`,
|
||||
color: 'text-amber-500',
|
||||
},
|
||||
'excel.import_error': {
|
||||
icon: XCircle,
|
||||
label: (d) => `Excel parse failed: ${d?.filename ?? '?'}`,
|
||||
color: 'text-red-500',
|
||||
},
|
||||
'excel.finalize_error': {
|
||||
icon: XCircle,
|
||||
label: (d) => `Order creation failed: ${d?.filename ?? '?'}`,
|
||||
color: 'text-red-500',
|
||||
},
|
||||
}
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export default function NotificationCenter() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const bellRef = useRef<HTMLButtonElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: unreadCount = 0 } = useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: getUnreadCount,
|
||||
refetchInterval: 15_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['notifications', 'list'],
|
||||
queryFn: () => getNotifications({ limit: 20 }),
|
||||
enabled: open,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
const markAllMutation = useMutation({
|
||||
mutationFn: () => markAsRead(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications'] })
|
||||
},
|
||||
})
|
||||
|
||||
const markOneMutation = useMutation({
|
||||
mutationFn: (id: string) => markOneAsRead(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Click-outside to close
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current && !dropdownRef.current.contains(e.target as Node) &&
|
||||
bellRef.current && !bellRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
function handleNotificationClick(n: Notification) {
|
||||
if (!n.read_at) markOneMutation.mutate(n.id)
|
||||
if (n.entity_type === 'order' && n.entity_id) {
|
||||
navigate(`/orders/${n.entity_id}`)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Position dropdown relative to bell button
|
||||
const bellRect = bellRef.current?.getBoundingClientRect()
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={bellRef}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative p-1.5 rounded-md hover:bg-surface-hover transition-colors"
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell size={18} className="text-content-secondary" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold leading-none">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && bellRect && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[9999] w-80 max-h-[28rem] rounded-lg shadow-xl border flex flex-col"
|
||||
style={{
|
||||
top: bellRect.bottom + 6,
|
||||
left: Math.max(8, bellRect.left - 240),
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border-light">
|
||||
<span className="text-sm font-semibold text-content">Notifications</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markAllMutation.mutate()}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setOpen(false)} className="p-0.5 hover:bg-surface-hover rounded" title="Close notifications">
|
||||
<X size={14} className="text-content-muted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!data?.items.length && (
|
||||
<div className="py-8 text-center text-sm text-content-muted">No notifications</div>
|
||||
)}
|
||||
{data?.items.map((n) => {
|
||||
const cfg = ACTION_CONFIG[n.action] ?? {
|
||||
icon: Bell,
|
||||
label: () => n.action,
|
||||
color: 'text-content-secondary',
|
||||
}
|
||||
const Icon = cfg.icon
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={clsx(
|
||||
'w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-surface-hover transition-colors border-b border-border-light',
|
||||
!n.read_at && 'bg-status-info-bg',
|
||||
)}
|
||||
>
|
||||
<Icon size={16} className={clsx('mt-0.5 shrink-0', cfg.color)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={clsx('text-sm', !n.read_at ? 'font-medium text-content' : 'text-content-secondary')}>
|
||||
{cfg.label(n.details)}
|
||||
</p>
|
||||
{n.details?.error && (
|
||||
<p className="mt-1 text-xs text-red-600 font-mono bg-red-50 rounded px-1.5 py-0.5 truncate">
|
||||
{String(n.details.error)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-content-muted mt-0.5">{relativeTime(n.timestamp)}</p>
|
||||
</div>
|
||||
{!n.read_at && (
|
||||
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border-light text-center">
|
||||
<button
|
||||
onClick={() => { navigate('/notifications'); setOpen(false) }}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
View all notifications
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Sun, Monitor, Moon } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useThemeStore, ACCENT_PRESETS, type ThemeMode } from '../../store/theme'
|
||||
|
||||
const MODES: { key: ThemeMode; icon: typeof Sun; label: string }[] = [
|
||||
{ key: 'light', icon: Sun, label: 'Light' },
|
||||
{ key: 'system', icon: Monitor, label: 'System' },
|
||||
{ key: 'dark', icon: Moon, label: 'Dark' },
|
||||
]
|
||||
|
||||
export default function ThemePreferences() {
|
||||
const { mode, accent, setMode, setAccent } = useThemeStore()
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{/* Mode row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-content-muted w-12 shrink-0">Theme</span>
|
||||
<div className="flex gap-0.5 bg-surface-alt rounded-md p-0.5 border border-border-light">
|
||||
{MODES.map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setMode(key)}
|
||||
title={label}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||
mode === key
|
||||
? 'bg-surface text-content shadow-sm'
|
||||
: 'text-content-muted hover:text-content-secondary',
|
||||
)}
|
||||
>
|
||||
<Icon size={12} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-content-muted w-12 shrink-0">Accent</span>
|
||||
<div className="flex gap-2">
|
||||
{ACCENT_PRESETS.map(({ key, label, hex }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setAccent(key)}
|
||||
title={label}
|
||||
className={clsx(
|
||||
'w-5 h-5 rounded-full transition-all',
|
||||
accent === key ? 'scale-125' : 'hover:scale-110',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hex,
|
||||
outline: accent === key ? `2px solid ${hex}` : undefined,
|
||||
outlineOffset: accent === key ? '2px' : undefined,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Save, FlaskConical, AlertCircle } from 'lucide-react'
|
||||
import { listMaterials, saveCadPartMaterials } from '../../api/materials'
|
||||
import MaterialInput from '../shared/MaterialInput'
|
||||
import MaterialWizard from '../MaterialWizard'
|
||||
|
||||
interface CadPartRow {
|
||||
part_name: string
|
||||
material: string
|
||||
}
|
||||
|
||||
interface ExcelComponent {
|
||||
part_name: string | null
|
||||
material: string | null
|
||||
component_type: string | null
|
||||
column_index: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orderId: string
|
||||
itemId: string
|
||||
partNames: string[] // from cad_parsed_objects
|
||||
savedMaterials: CadPartRow[] // from cad_part_materials
|
||||
excelComponents?: ExcelComponent[] // from item.components (Excel data)
|
||||
}
|
||||
|
||||
function normName(s: string) {
|
||||
return s.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export default function CadPartMaterials({ orderId, itemId, partNames, savedMaterials, excelComponents = [] }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [wizardOpen, setWizardOpen] = useState(false)
|
||||
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
|
||||
|
||||
const initRows = (): CadPartRow[] =>
|
||||
partNames.map((name) => {
|
||||
// 1. Use saved value if present
|
||||
const saved = savedMaterials.find((s) => s.part_name === name)
|
||||
if (saved) return { part_name: name, material: saved.material }
|
||||
// 2. Fall back to Excel component data (case-insensitive match)
|
||||
const excelMatch = excelComponents.find(
|
||||
(c) => c.part_name && normName(c.part_name) === normName(name),
|
||||
)
|
||||
return { part_name: name, material: excelMatch?.material ?? '' }
|
||||
})
|
||||
|
||||
const [rows, setRows] = useState<CadPartRow[]>(initRows)
|
||||
|
||||
// Re-sync when props change (e.g. after save or STEP file change)
|
||||
useEffect(() => {
|
||||
setRows(initRows())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partNames.join(','), savedMaterials.length, excelComponents.length])
|
||||
|
||||
const { data: library = [] } = useQuery({
|
||||
queryKey: ['materials'],
|
||||
queryFn: listMaterials,
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => saveCadPartMaterials(orderId, itemId, rows.filter((r) => r.material.trim())),
|
||||
onSuccess: () => {
|
||||
toast.success('Materials saved')
|
||||
qc.invalidateQueries({ queryKey: ['order', orderId] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Save failed'),
|
||||
})
|
||||
|
||||
const isDirty = rows.some((r) => {
|
||||
const saved = savedMaterials.find((s) => s.part_name === r.part_name)?.material ?? ''
|
||||
return r.material !== saved
|
||||
})
|
||||
|
||||
const missingCount = rows.filter((r) => !r.material.trim()).length
|
||||
|
||||
const setMaterial = (idx: number, value: string) =>
|
||||
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, material: value } : r)))
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FlaskConical size={14} className="text-content-muted" />
|
||||
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">
|
||||
CAD Part Materials ({partNames.length})
|
||||
</p>
|
||||
{missingCount > 0 && (
|
||||
<span className="ml-auto flex items-center gap-1 text-xs font-medium text-red-600">
|
||||
<AlertCircle size={12} />
|
||||
{missingCount} missing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-border-default rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-2 bg-surface-alt border-b border-border-default px-3 py-1.5">
|
||||
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">Part Name</p>
|
||||
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">Material</p>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{rows.map((row, idx) => {
|
||||
const missing = !row.material.trim()
|
||||
return (
|
||||
<div
|
||||
key={row.part_name}
|
||||
className={`grid grid-cols-2 border-b border-border-light last:border-b-0 ${
|
||||
missing
|
||||
? 'bg-red-50'
|
||||
: idx % 2 === 0 ? 'bg-surface' : 'bg-surface-alt/50'
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
{missing && <AlertCircle size={12} className="text-red-400 shrink-0" />}
|
||||
<span
|
||||
className={`text-sm font-mono truncate ${missing ? 'text-red-700' : 'text-content'}`}
|
||||
title={row.part_name}
|
||||
>
|
||||
{row.part_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-2 py-1.5">
|
||||
<MaterialInput
|
||||
value={row.material}
|
||||
onChange={(v) => setMaterial(idx, v)}
|
||||
library={library}
|
||||
missing={missing}
|
||||
onOpenWizard={() => {
|
||||
setWizardTargetIdx(idx)
|
||||
setWizardOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(isDirty || missingCount > 0) && (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
{isDirty && (
|
||||
<button
|
||||
onClick={() => saveMut.mutate()}
|
||||
disabled={saveMut.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
<Save size={14} />
|
||||
{saveMut.isPending ? 'Saving...' : 'Save Materials'}
|
||||
</button>
|
||||
)}
|
||||
{missingCount > 0 && !isDirty && (
|
||||
<p className="text-xs text-red-600 flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{missingCount} part{missingCount !== 1 ? 's' : ''} have no material assigned
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Material Wizard (opened from MaterialInput) */}
|
||||
<MaterialWizard
|
||||
open={wizardOpen}
|
||||
onClose={() => { setWizardOpen(false); setWizardTargetIdx(null) }}
|
||||
onCreated={(name) => {
|
||||
if (wizardTargetIdx !== null) {
|
||||
setMaterial(wizardTargetIdx, name)
|
||||
}
|
||||
setWizardTargetIdx(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import type { Material } from '../../api/materials'
|
||||
|
||||
const TYPE_GROUPS: Record<string, { label: string; color: string }> = {
|
||||
'01': { label: 'Metals', color: 'text-slate-500' },
|
||||
'02': { label: 'Coatings', color: 'text-blue-500' },
|
||||
'03': { label: 'Non-metals', color: 'text-amber-600' },
|
||||
'04': { label: 'Compounds', color: 'text-purple-500' },
|
||||
'05': { label: 'Misc', color: 'text-content-muted' },
|
||||
}
|
||||
|
||||
function getTypeCode(mat: Material): string | null {
|
||||
if (mat.schaeffler_code == null) return null
|
||||
const s = String(mat.schaeffler_code).padStart(6, '0')
|
||||
return s.slice(0, 2)
|
||||
}
|
||||
|
||||
/** Extract the human-readable short name after the last underscore: SCHAEFFLER_010101_Steel-Bare -> Steel-Bare */
|
||||
function shortName(name: string): string {
|
||||
const match = name.match(/^SCHAEFFLER_\d{6}_(.+)$/)
|
||||
return match ? match[1].replace(/-/g, ' ') : name
|
||||
}
|
||||
|
||||
export interface MaterialInputProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
library: Material[]
|
||||
missing: boolean
|
||||
onOpenWizard: () => void
|
||||
}
|
||||
|
||||
export default function MaterialInput({ value, onChange, library, missing, onOpenWizard }: MaterialInputProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const trimmed = value.trim()
|
||||
const suggestions = trimmed
|
||||
? library.filter((m) => m.name.toLowerCase().includes(trimmed.toLowerCase())
|
||||
|| shortName(m.name).toLowerCase().includes(trimmed.toLowerCase())
|
||||
|| (m.description ?? '').toLowerCase().includes(trimmed.toLowerCase()))
|
||||
: library
|
||||
|
||||
// Group suggestions by type code
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Array<{ code: string | null; label: string; color: string; items: Material[] }> = []
|
||||
const buckets = new Map<string | null, Material[]>()
|
||||
|
||||
for (const m of suggestions) {
|
||||
const tc = getTypeCode(m)
|
||||
if (!buckets.has(tc)) buckets.set(tc, [])
|
||||
buckets.get(tc)!.push(m)
|
||||
}
|
||||
|
||||
// Sorted type codes first, then non-schaeffler
|
||||
const sortedKeys = [...buckets.keys()].sort((a, b) => {
|
||||
if (a === null) return 1
|
||||
if (b === null) return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const info = key ? TYPE_GROUPS[key] : null
|
||||
groups.push({
|
||||
code: key,
|
||||
label: info?.label ?? 'Custom',
|
||||
color: info?.color ?? 'text-content-muted',
|
||||
items: buckets.get(key)!,
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}, [suggestions])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const select = (name: string) => {
|
||||
onChange(name)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => { onChange(e.target.value); setOpen(true) }}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={missing ? 'Required — assign a material' : 'Search materials...'}
|
||||
className={`w-full px-2 py-1 text-sm border rounded focus:outline-none bg-surface ${
|
||||
missing
|
||||
? 'border-red-300 focus:border-red-500 placeholder-red-300'
|
||||
: 'border-border-default focus:border-accent'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{open && (suggestions.length > 0 || true) && (
|
||||
<div className="absolute left-0 top-full mt-0.5 w-80 border border-border-default rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto" style={{ backgroundColor: 'var(--color-bg-surface)' }}>
|
||||
{grouped.map((group) => (
|
||||
<div key={group.code ?? 'custom'}>
|
||||
{/* Group header */}
|
||||
<div className="sticky top-0 px-3 py-1 border-b border-border-light" style={{ backgroundColor: 'var(--color-bg-app)' }}>
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider ${group.color}`}>
|
||||
{group.code ? `${group.code} ` : ''}{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onMouseDown={(e) => { e.preventDefault(); select(m.name) }}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-accent-light flex items-baseline gap-2"
|
||||
>
|
||||
<span className="text-sm font-medium text-content truncate">{shortName(m.name)}</span>
|
||||
{m.description && (
|
||||
<span className="text-xs text-content-muted truncate">{m.description}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{suggestions.length === 0 && (
|
||||
<div className="px-3 py-3 text-center text-xs text-content-muted">No materials match "{trimmed}"</div>
|
||||
)}
|
||||
|
||||
{/* Create new material via wizard */}
|
||||
<button
|
||||
onMouseDown={(e) => { e.preventDefault(); setOpen(false); onOpenWizard() }}
|
||||
className="w-full text-left px-3 py-2 border-t border-border-default flex items-center gap-2 hover:bg-surface-hover text-accent sticky bottom-0"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
>
|
||||
<Wand2 size={13} />
|
||||
<span className="text-sm font-medium">Create new material...</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '../../utils/format'
|
||||
|
||||
interface ModalProps {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
/** Extra classes applied to the inner panel */
|
||||
className?: string
|
||||
/** Width preset – defaults to 'md' */
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
}
|
||||
|
||||
const sizeMap: Record<NonNullable<ModalProps['size']>, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
full: 'max-w-full mx-4',
|
||||
}
|
||||
|
||||
export default function Modal({ title, onClose, children, className, size = 'md' }: ModalProps) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* Close on Escape */
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
/* Prevent scroll on body while modal is open */
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [])
|
||||
|
||||
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (e.target === backdropRef.current) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
onClick={handleBackdropClick}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full rounded-xl shadow-2xl flex flex-col max-h-[90vh]',
|
||||
sizeMap[size],
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default shrink-0">
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-content">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md text-content-muted hover:text-content-secondary hover:bg-surface-muted transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import React from 'react'
|
||||
import { ParsedRow, ParsedComponent, ParsedExcelResponse } from '../../api/uploads'
|
||||
|
||||
interface Props {
|
||||
parsed: ParsedExcelResponse
|
||||
rows: ParsedRow[]
|
||||
onChange: (rows: ParsedRow[]) => void
|
||||
}
|
||||
|
||||
const STANDARD_FIELDS: { key: keyof ParsedRow; label: string; width: number; mono?: boolean }[] = [
|
||||
{ key: 'ebene1', label: 'Ebene 1', width: 140 },
|
||||
{ key: 'ebene2', label: 'Ebene 2', width: 120 },
|
||||
{ key: 'baureihe', label: 'Baureihe', width: 160 },
|
||||
{ key: 'pim_id', label: 'PIM-ID', width: 110 },
|
||||
{ key: 'produkt_baureihe', label: 'Produkt-Baureihe', width: 150 },
|
||||
{ key: 'gewaehltes_produkt', label: 'Gewähltes Produkt', width: 150 },
|
||||
{ key: 'name_cad_modell', label: 'CAD-Modell', width: 190, mono: true },
|
||||
{ key: 'gewuenschte_bildnummer', label: 'Bildnummer', width: 170, mono: true },
|
||||
{ key: 'lagertyp', label: 'Lagertyp', width: 100 },
|
||||
]
|
||||
|
||||
export default function ExcelSpreadsheet({ parsed, rows, onChange }: Props) {
|
||||
const maxComps = Math.max(0, ...rows.map((r) => r.components.length))
|
||||
|
||||
function updateField(ri: number, field: keyof ParsedRow, value: string | boolean | null) {
|
||||
const next = rows.map((r, i) => (i === ri ? { ...r, [field]: value } : r))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
function updateComp(ri: number, ci: number, field: keyof ParsedComponent, value: string) {
|
||||
const next = rows.map((r, i) => {
|
||||
if (i !== ri) return r
|
||||
const comps = r.components.map((c, j) =>
|
||||
j === ci ? { ...c, [field]: value || null } : c,
|
||||
)
|
||||
// If the row doesn't have this component slot yet, pad it
|
||||
while (comps.length <= ci) {
|
||||
comps.push({ part_name: null, material: null, component_type: null, column_index: 11 + comps.length * 2 })
|
||||
}
|
||||
comps[ci] = { ...comps[ci], [field]: value || null }
|
||||
return { ...r, components: comps }
|
||||
})
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const cell =
|
||||
'w-full px-2 py-1 text-xs bg-transparent border-0 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-accent rounded focus:bg-surface'
|
||||
const th =
|
||||
'px-2 py-2 text-left text-xs font-semibold text-content-secondary whitespace-nowrap bg-surface-alt border-b border-r border-border-default sticky top-0 z-10'
|
||||
const td = 'border-b border-r border-border-light p-0'
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-border-default flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">
|
||||
{parsed.template_name || parsed.category_key} — {rows.length} rows
|
||||
</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">Click any cell to edit before creating the order</p>
|
||||
</div>
|
||||
<span className="badge badge-blue">{maxComps} component columns</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto" style={{ maxHeight: '65vh' }}>
|
||||
<table className="text-sm border-collapse" style={{ minWidth: 'max-content' }}>
|
||||
<thead>
|
||||
{/* Group header row */}
|
||||
<tr>
|
||||
<th className={`${th} text-center`} colSpan={1}>#</th>
|
||||
<th className={`${th} text-center bg-status-info-bg`} colSpan={STANDARD_FIELDS.length}>
|
||||
Standard Fields
|
||||
</th>
|
||||
<th className={`${th} text-center`}>Rendering</th>
|
||||
{Array.from({ length: maxComps }, (_, i) => (
|
||||
<th key={i} className={`${th} text-center bg-status-warning-bg`} colSpan={2}>
|
||||
Component {i + 1}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* Field name row */}
|
||||
<tr>
|
||||
<th className={`${th} text-content-muted`}>#</th>
|
||||
{STANDARD_FIELDS.map((f) => (
|
||||
<th key={f.key} className={`${th} bg-status-info-bg`} style={{ minWidth: f.width }}>
|
||||
{f.label}
|
||||
</th>
|
||||
))}
|
||||
<th className={`${th} text-center`} style={{ minWidth: 72 }}>
|
||||
Rendering
|
||||
</th>
|
||||
{Array.from({ length: maxComps }, (_, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<th className={`${th} bg-status-warning-bg`} style={{ minWidth: 180 }}>
|
||||
Part Name
|
||||
</th>
|
||||
<th className={`${th} bg-status-warning-bg`} style={{ minWidth: 110 }}>
|
||||
Material
|
||||
</th>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={row.row_index} className={ri % 2 === 0 ? 'bg-surface' : 'bg-surface-alt/50'}>
|
||||
{/* Row number */}
|
||||
<td className={`${td} px-2 py-1.5 text-xs text-content-muted font-mono text-right`}>
|
||||
{row.row_index}
|
||||
</td>
|
||||
|
||||
{/* Standard text fields */}
|
||||
{STANDARD_FIELDS.map((f) => (
|
||||
<td key={f.key} className={td}>
|
||||
<input
|
||||
type="text"
|
||||
value={(row[f.key] as string | null) ?? ''}
|
||||
onChange={(e) => updateField(ri, f.key, e.target.value || null)}
|
||||
className={`${cell} ${f.mono ? 'font-mono' : ''}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{/* Rendering checkbox */}
|
||||
<td className={`${td} text-center`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.medias_rendering ?? false}
|
||||
onChange={(e) => updateField(ri, 'medias_rendering', e.target.checked)}
|
||||
className="w-3.5 h-3.5"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Component pairs */}
|
||||
{Array.from({ length: maxComps }, (_, ci) => {
|
||||
const comp = row.components[ci]
|
||||
return (
|
||||
<React.Fragment key={ci}>
|
||||
<td className={td}>
|
||||
<input
|
||||
type="text"
|
||||
value={comp?.part_name ?? ''}
|
||||
onChange={(e) => updateComp(ri, ci, 'part_name', e.target.value)}
|
||||
className={`${cell} font-mono`}
|
||||
placeholder="—"
|
||||
/>
|
||||
</td>
|
||||
<td className={td}>
|
||||
<input
|
||||
type="text"
|
||||
value={comp?.material ?? ''}
|
||||
onChange={(e) => updateComp(ri, ci, 'material', e.target.value)}
|
||||
className={cell}
|
||||
placeholder="—"
|
||||
/>
|
||||
</td>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* StepDropzone — Phase 3
|
||||
*
|
||||
* Accepts one or more .stp/.step files via react-dropzone, uploads each to
|
||||
* POST /api/uploads/step, then calls POST /api/cad/match-to-order to link
|
||||
* matched files to order items by filename.
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, CheckCircle, XCircle, Loader2, Link2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../../api/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StepUploadResponse {
|
||||
cad_file_id: string
|
||||
original_name: string
|
||||
file_hash: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface MatchedItem {
|
||||
item_id: string
|
||||
cad_file_id: string
|
||||
item_name: string
|
||||
cad_name: string
|
||||
}
|
||||
|
||||
interface MatchToOrderResponse {
|
||||
matched: MatchedItem[]
|
||||
unmatched_cad: string[]
|
||||
unmatched_items: string[]
|
||||
}
|
||||
|
||||
type FileStatus = 'idle' | 'uploading' | 'done' | 'error'
|
||||
|
||||
interface FileEntry {
|
||||
file: File
|
||||
status: FileStatus
|
||||
errorMsg?: string
|
||||
cadFileId?: string
|
||||
}
|
||||
|
||||
interface StepDropzoneProps {
|
||||
orderId: string
|
||||
/** Called after matching completes so the parent can refresh the order */
|
||||
onMatchComplete?: (result: MatchToOrderResponse) => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function StepDropzone({ orderId, onMatchComplete }: StepDropzoneProps) {
|
||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||
const [matching, setMatching] = useState(false)
|
||||
const [matchResult, setMatchResult] = useState<MatchToOrderResponse | null>(null)
|
||||
|
||||
// Update a single entry by index
|
||||
const updateEntry = useCallback(
|
||||
(idx: number, patch: Partial<FileEntry>) =>
|
||||
setEntries((prev) => prev.map((e, i) => (i === idx ? { ...e, ...patch } : e))),
|
||||
[],
|
||||
)
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (accepted: File[]) => {
|
||||
if (accepted.length === 0) return
|
||||
|
||||
// Append new file entries
|
||||
const startIdx = entries.length
|
||||
const newEntries: FileEntry[] = accepted.map((f) => ({ file: f, status: 'uploading' }))
|
||||
setEntries((prev) => [...prev, ...newEntries])
|
||||
setMatchResult(null)
|
||||
|
||||
// Upload each file sequentially to avoid overwhelming the server
|
||||
const uploadedIds: string[] = []
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const globalIdx = startIdx + i
|
||||
const file = accepted[i]
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
try {
|
||||
const res = await api.post<StepUploadResponse>('/uploads/step', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
const { cad_file_id } = res.data
|
||||
uploadedIds.push(cad_file_id)
|
||||
updateEntry(globalIdx, { status: 'done', cadFileId: cad_file_id })
|
||||
} catch (err: any) {
|
||||
const msg: string =
|
||||
err?.response?.data?.detail ?? err?.message ?? 'Upload failed'
|
||||
updateEntry(globalIdx, { status: 'error', errorMsg: msg })
|
||||
toast.error(`${file.name}: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all successful cad_file_ids from this session (including previous uploads)
|
||||
const allSuccessfulIds: string[] = [
|
||||
...entries
|
||||
.filter((e) => e.status === 'done' && e.cadFileId)
|
||||
.map((e) => e.cadFileId as string),
|
||||
...uploadedIds,
|
||||
]
|
||||
|
||||
if (allSuccessfulIds.length === 0) return
|
||||
|
||||
// Match to order
|
||||
setMatching(true)
|
||||
try {
|
||||
const res = await api.post<MatchToOrderResponse>('/cad/match-to-order', {
|
||||
order_id: orderId,
|
||||
cad_file_ids: allSuccessfulIds,
|
||||
})
|
||||
setMatchResult(res.data)
|
||||
const { matched, unmatched_cad } = res.data
|
||||
if (matched.length > 0) {
|
||||
toast.success(`Matched ${matched.length} file(s) to order items`)
|
||||
}
|
||||
if (unmatched_cad.length > 0) {
|
||||
toast.warning(`${unmatched_cad.length} file(s) could not be matched to any item`)
|
||||
}
|
||||
onMatchComplete?.(res.data)
|
||||
} catch (err: any) {
|
||||
const msg: string =
|
||||
err?.response?.data?.detail ?? err?.message ?? 'Matching failed'
|
||||
toast.error(`CAD matching error: ${msg}`)
|
||||
} finally {
|
||||
setMatching(false)
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[entries, orderId, onMatchComplete, updateEntry],
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'application/octet-stream': ['.stp', '.step'] },
|
||||
multiple: true,
|
||||
})
|
||||
|
||||
const hasEntries = entries.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Drop target */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={[
|
||||
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors',
|
||||
isDragActive
|
||||
? 'border-green-500 bg-status-success-bg'
|
||||
: 'border-border-default hover:border-border-default bg-surface-alt',
|
||||
].join(' ')}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={32} className="mx-auto mb-3 text-content-muted" />
|
||||
{isDragActive ? (
|
||||
<p className="text-green-600 font-medium">Drop STEP files here</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-content-secondary font-medium">
|
||||
Drag and drop .stp / .step files here
|
||||
</p>
|
||||
<p className="text-sm text-content-muted mt-1">or click to browse</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per-file status list */}
|
||||
{hasEntries && (
|
||||
<ul className="divide-y divide-border-light rounded-lg border border-border-default bg-surface overflow-hidden">
|
||||
{entries.map((entry, idx) => (
|
||||
<li key={idx} className="flex items-center gap-3 px-4 py-3">
|
||||
<FileStatusIcon status={entry.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-content truncate">
|
||||
{entry.file.name}
|
||||
</p>
|
||||
{entry.status === 'error' && (
|
||||
<p className="text-xs text-red-500 mt-0.5">{entry.errorMsg}</p>
|
||||
)}
|
||||
{entry.status === 'done' && (
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
ID: {entry.cadFileId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusLabel status={entry.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Matching spinner */}
|
||||
{matching && (
|
||||
<div className="flex items-center gap-2 text-sm text-content-secondary">
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
Matching files to order items...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Match result summary */}
|
||||
{matchResult && !matching && (
|
||||
<div className="rounded-lg border border-border-default bg-surface p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-content-secondary">
|
||||
<Link2 size={15} />
|
||||
Matching Results
|
||||
</div>
|
||||
|
||||
{matchResult.matched.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-status-success-text mb-1">
|
||||
Matched ({matchResult.matched.length})
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{matchResult.matched.map((m) => (
|
||||
<li key={m.item_id} className="flex items-center gap-2 text-xs">
|
||||
<CheckCircle size={13} className="text-green-500 shrink-0" />
|
||||
<span className="font-mono text-content-secondary truncate">{m.cad_name}</span>
|
||||
<span className="text-content-muted">→</span>
|
||||
<span className="text-content-secondary truncate">{m.item_name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{matchResult.unmatched_cad.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-status-warning-text mb-1">
|
||||
Unmatched CAD files ({matchResult.unmatched_cad.length})
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{matchResult.unmatched_cad.map((id) => (
|
||||
<li key={id} className="text-xs text-content-secondary font-mono truncate">
|
||||
{entries.find((e) => e.cadFileId === id)?.file.name ?? id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{matchResult.matched.length === 0 && matchResult.unmatched_cad.length === 0 && (
|
||||
<p className="text-xs text-content-muted">No files were processed.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FileStatusIcon({ status }: { status: FileStatus }) {
|
||||
if (status === 'uploading') return <Loader2 size={16} className="animate-spin text-blue-500 shrink-0" />
|
||||
if (status === 'done') return <CheckCircle size={16} className="text-green-500 shrink-0" />
|
||||
if (status === 'error') return <XCircle size={16} className="text-red-500 shrink-0" />
|
||||
return <div className="w-4 h-4 rounded-full bg-surface-muted shrink-0" />
|
||||
}
|
||||
|
||||
function StatusLabel({ status }: { status: FileStatus }) {
|
||||
if (status === 'uploading') return <span className="text-xs text-blue-500">Uploading...</span>
|
||||
if (status === 'done') return <span className="text-xs text-green-600">Uploaded</span>
|
||||
if (status === 'error') return <span className="text-xs text-red-500">Failed</span>
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* StepPreUpload — STEP file uploader used during order creation (before an
|
||||
* order ID exists). Files are uploaded immediately to /api/uploads/step so
|
||||
* we have cad_file_ids ready. Client-side filename matching gives the user
|
||||
* live feedback on which Excel rows already have a STEP file.
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, CheckCircle, XCircle, Loader2, FileBox } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../../api/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StepUploadResponse {
|
||||
cad_file_id: string
|
||||
original_name: string
|
||||
file_hash: string
|
||||
status: string
|
||||
}
|
||||
|
||||
type FileStatus = 'uploading' | 'done' | 'error'
|
||||
|
||||
interface FileEntry {
|
||||
file: File
|
||||
status: FileStatus
|
||||
errorMsg?: string
|
||||
cadFileId?: string
|
||||
}
|
||||
|
||||
export interface StepUploadState {
|
||||
ids: string[] // cad_file_ids of successfully uploaded files
|
||||
names: string[] // original_names of successfully uploaded files
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** name_cad_modell values from parsed rows — used for match preview */
|
||||
itemNames: string[]
|
||||
/** Called whenever the set of successfully uploaded files changes */
|
||||
onUpdate: (state: StepUploadState) => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normStem(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\.(step|stp)$/i, '')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function StepPreUpload({ itemNames, onUpdate }: Props) {
|
||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||
|
||||
const getSuccessState = (updated: FileEntry[]): StepUploadState => ({
|
||||
ids: updated.filter((e) => e.status === 'done' && e.cadFileId).map((e) => e.cadFileId!),
|
||||
names: updated.filter((e) => e.status === 'done').map((e) => e.file.name),
|
||||
})
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (accepted: File[]) => {
|
||||
if (accepted.length === 0) return
|
||||
|
||||
const startIdx = entries.length
|
||||
const newEntries: FileEntry[] = accepted.map((f) => ({ file: f, status: 'uploading' }))
|
||||
const merged = [...entries, ...newEntries]
|
||||
setEntries(merged)
|
||||
|
||||
let working = [...merged]
|
||||
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const idx = startIdx + i
|
||||
const file = accepted[i]
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
try {
|
||||
const res = await api.post<StepUploadResponse>('/uploads/step', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
working = working.map((e, j) =>
|
||||
j === idx ? { ...e, status: 'done', cadFileId: res.data.cad_file_id } : e,
|
||||
)
|
||||
} catch (err: any) {
|
||||
const msg: string = err?.response?.data?.detail ?? err?.message ?? 'Upload failed'
|
||||
working = working.map((e, j) =>
|
||||
j === idx ? { ...e, status: 'error', errorMsg: msg } : e,
|
||||
)
|
||||
toast.error(`${file.name}: ${msg}`)
|
||||
}
|
||||
setEntries([...working])
|
||||
}
|
||||
|
||||
onUpdate(getSuccessState(working))
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[entries, onUpdate],
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'application/octet-stream': ['.stp', '.step'] },
|
||||
multiple: true,
|
||||
})
|
||||
|
||||
// Client-side match preview
|
||||
const uploadedStems = new Set(
|
||||
entries.filter((e) => e.status === 'done').map((e) => normStem(e.file.name)),
|
||||
)
|
||||
const matched = itemNames.filter((n) => uploadedStems.has(normStem(n)))
|
||||
const missing = itemNames.filter((n) => !uploadedStems.has(normStem(n)))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Match status bar */}
|
||||
{itemNames.length > 0 && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="flex items-center gap-1.5 text-status-success-text">
|
||||
<CheckCircle size={15} className="shrink-0" />
|
||||
<span><strong>{matched.length}</strong> matched</span>
|
||||
</div>
|
||||
{missing.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600">
|
||||
<FileBox size={15} className="shrink-0" />
|
||||
<span><strong>{missing.length}</strong> still need a STEP file</span>
|
||||
</div>
|
||||
)}
|
||||
{missing.length === 0 && (
|
||||
<span className="text-status-success-text font-medium">All items covered ✓</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={[
|
||||
'border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-colors',
|
||||
isDragActive
|
||||
? 'border-accent bg-status-success-bg'
|
||||
: 'border-border-default hover:border-border-default bg-surface-alt',
|
||||
].join(' ')}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={28} className="mx-auto mb-2 text-content-muted" />
|
||||
{isDragActive ? (
|
||||
<p className="text-accent font-medium">Drop STEP files here</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-content-secondary font-medium">Drag & drop .stp / .step files</p>
|
||||
<p className="text-sm text-content-muted mt-1">or click to browse — multiple files at once</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Uploaded file list */}
|
||||
{entries.length > 0 && (
|
||||
<ul className="divide-y divide-border-light rounded-lg border border-border-default bg-surface overflow-hidden">
|
||||
{entries.map((entry, idx) => {
|
||||
const stem = normStem(entry.file.name)
|
||||
const isMatched = itemNames.some((n) => normStem(n) === stem)
|
||||
return (
|
||||
<li key={idx} className="flex items-center gap-3 px-4 py-2.5">
|
||||
{entry.status === 'uploading' && (
|
||||
<Loader2 size={15} className="animate-spin text-blue-500 shrink-0" />
|
||||
)}
|
||||
{entry.status === 'done' && (
|
||||
<CheckCircle size={15} className={isMatched ? 'text-green-500 shrink-0' : 'text-amber-400 shrink-0'} />
|
||||
)}
|
||||
{entry.status === 'error' && (
|
||||
<XCircle size={15} className="text-red-500 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-content truncate">{entry.file.name}</p>
|
||||
{entry.status === 'error' && (
|
||||
<p className="text-xs text-red-500">{entry.errorMsg}</p>
|
||||
)}
|
||||
{entry.status === 'done' && !isMatched && (
|
||||
<p className="text-xs text-amber-600">No matching row in Excel</p>
|
||||
)}
|
||||
</div>
|
||||
{entry.status === 'uploading' && (
|
||||
<span className="text-xs text-blue-500 shrink-0">Uploading…</span>
|
||||
)}
|
||||
{entry.status === 'done' && (
|
||||
<span className={`text-xs shrink-0 ${isMatched ? 'text-green-600' : 'text-amber-500'}`}>
|
||||
{isMatched ? 'Matched' : 'Unmatched'}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Missing items list */}
|
||||
{missing.length > 0 && entries.some((e) => e.status === 'done') && (
|
||||
<div className="rounded-lg border border-border-default bg-status-warning-bg p-3">
|
||||
<p className="text-xs font-semibold text-status-warning-text mb-1.5">
|
||||
Still missing ({missing.length}):
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{missing.slice(0, 12).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="text-xs font-mono bg-status-warning-bg text-status-warning-text px-1.5 py-0.5 rounded border border-border-default"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{missing.length > 12 && (
|
||||
<span className="text-xs text-status-warning-text">+{missing.length - 12} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================================
|
||||
ACCENT PRESETS
|
||||
Applied via data-accent="<key>" on <html>
|
||||
============================================================ */
|
||||
|
||||
/* Default / Schaeffler Green */
|
||||
:root,
|
||||
[data-accent="green"] {
|
||||
--color-accent: #00893d;
|
||||
--color-accent-hover: #006e31;
|
||||
--color-accent-light: #e6f4ec;
|
||||
--color-accent-text: #ffffff;
|
||||
}
|
||||
|
||||
[data-accent="blue"] {
|
||||
--color-accent: #2563eb;
|
||||
--color-accent-hover: #1d4ed8;
|
||||
--color-accent-light: #dbeafe;
|
||||
--color-accent-text: #ffffff;
|
||||
}
|
||||
|
||||
[data-accent="purple"] {
|
||||
--color-accent: #7c3aed;
|
||||
--color-accent-hover: #6d28d9;
|
||||
--color-accent-light: #ede9fe;
|
||||
--color-accent-text: #ffffff;
|
||||
}
|
||||
|
||||
[data-accent="amber"] {
|
||||
--color-accent: #d97706;
|
||||
--color-accent-hover: #b45309;
|
||||
--color-accent-light: #fef3c7;
|
||||
--color-accent-text: #ffffff;
|
||||
}
|
||||
|
||||
[data-accent="teal"] {
|
||||
--color-accent: #0d9488;
|
||||
--color-accent-hover: #0f766e;
|
||||
--color-accent-light: #ccfbf1;
|
||||
--color-accent-text: #ffffff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LIGHT THEME (default)
|
||||
============================================================ */
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--color-bg-app: #f9fafb;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-surface-hover: #f9fafb;
|
||||
--color-bg-muted: #f3f4f6;
|
||||
|
||||
/* Text */
|
||||
--color-text: #111827;
|
||||
--color-text-secondary: #4b5563;
|
||||
--color-text-muted: #9ca3af;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
/* Borders */
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-light: #f3f4f6;
|
||||
|
||||
/* Status — Success */
|
||||
--color-status-success-bg: #dcfce7;
|
||||
--color-status-success-text: #166534;
|
||||
|
||||
/* Status — Warning */
|
||||
--color-status-warning-bg: #fef9c3;
|
||||
--color-status-warning-text: #854d0e;
|
||||
|
||||
/* Status — Error */
|
||||
--color-status-error-bg: #fee2e2;
|
||||
--color-status-error-text: #991b1b;
|
||||
|
||||
/* Status — Info */
|
||||
--color-status-info-bg: #dbeafe;
|
||||
--color-status-info-text: #1e40af;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
DARK THEME
|
||||
Applied via .dark class on <html>
|
||||
============================================================ */
|
||||
:root.dark {
|
||||
/* Surfaces */
|
||||
--color-bg-app: #0f172a;
|
||||
--color-bg-surface: #1e293b;
|
||||
--color-bg-surface-hover: #334155;
|
||||
--color-bg-muted: #1e293b;
|
||||
|
||||
/* Text */
|
||||
--color-text: #f1f5f9;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-inverse: #0f172a;
|
||||
|
||||
/* Borders */
|
||||
--color-border: #334155;
|
||||
--color-border-light: #1e293b;
|
||||
|
||||
/* Status — Success */
|
||||
--color-status-success-bg: rgba(34, 197, 94, 0.15);
|
||||
--color-status-success-text: #4ade80;
|
||||
|
||||
/* Status — Warning */
|
||||
--color-status-warning-bg: rgba(234, 179, 8, 0.15);
|
||||
--color-status-warning-text: #facc15;
|
||||
|
||||
/* Status — Error */
|
||||
--color-status-error-bg: rgba(239, 68, 68, 0.15);
|
||||
--color-status-error-text: #f87171;
|
||||
|
||||
/* Status — Info */
|
||||
--color-status-info-bg: rgba(59, 130, 246, 0.15);
|
||||
--color-status-info-text: #60a5fa;
|
||||
}
|
||||
|
||||
/* Dark accent-light overrides (rgba instead of solid pastel) */
|
||||
:root.dark,
|
||||
:root.dark [data-accent="green"] {
|
||||
--color-accent-light: rgba(0, 137, 61, 0.15);
|
||||
}
|
||||
:root.dark [data-accent="blue"] {
|
||||
--color-accent-light: rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
:root.dark [data-accent="purple"] {
|
||||
--color-accent-light: rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
:root.dark [data-accent="amber"] {
|
||||
--color-accent-light: rgba(217, 119, 6, 0.15);
|
||||
}
|
||||
:root.dark [data-accent="teal"] {
|
||||
--color-accent-light: rgba(13, 148, 136, 0.15);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BASE LAYER
|
||||
============================================================ */
|
||||
@layer base {
|
||||
body {
|
||||
@apply antialiased;
|
||||
background-color: var(--color-bg-app);
|
||||
color: var(--color-text);
|
||||
transition: background-color 200ms ease, color 200ms ease;
|
||||
}
|
||||
|
||||
/* Native color scheme for form controls */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Checkbox / radio accent color */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COMPONENT CLASSES
|
||||
============================================================ */
|
||||
@layer components {
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md font-medium text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-accent-text);
|
||||
--tw-ring-color: var(--color-accent);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--color-accent-hover);
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn border;
|
||||
background-color: var(--color-bg-surface);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-border);
|
||||
--tw-ring-color: var(--color-accent);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-surface-hover);
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
@apply rounded-lg border shadow-sm;
|
||||
background-color: var(--color-bg-surface);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
.badge-green {
|
||||
@apply badge;
|
||||
background-color: var(--color-status-success-bg);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
.badge-yellow {
|
||||
@apply badge;
|
||||
background-color: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
.badge-red {
|
||||
@apply badge;
|
||||
background-color: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
.badge-blue {
|
||||
@apply badge;
|
||||
background-color: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
.badge-gray {
|
||||
@apply badge;
|
||||
background-color: var(--color-bg-muted);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Input base — replaces repeated inline input patterns */
|
||||
.input-base {
|
||||
@apply w-full px-3 py-2 rounded-md text-sm border focus:outline-none focus:ring-2 transition-colors;
|
||||
background-color: var(--color-bg-surface);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-border);
|
||||
--tw-ring-color: var(--color-accent);
|
||||
}
|
||||
.input-base::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.input-base:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Small input variant (used in admin tables) */
|
||||
.input-sm {
|
||||
@apply px-2 py-1 rounded text-sm border focus:outline-none focus:ring-1 transition-colors;
|
||||
background-color: var(--color-bg-surface);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-border);
|
||||
--tw-ring-color: var(--color-accent);
|
||||
}
|
||||
.input-sm:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from 'sonner'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import { useThemeStore, applyTheme, resolveTheme, type ThemeMode, type AccentKey } from './store/theme'
|
||||
|
||||
/* ---------------------------------------------------------------
|
||||
Flash prevention: apply theme BEFORE React hydrates.
|
||||
Reads directly from localStorage to avoid the Zustand wrapper.
|
||||
--------------------------------------------------------------- */
|
||||
;(function () {
|
||||
try {
|
||||
const raw = localStorage.getItem('schaeffler-theme')
|
||||
if (raw) {
|
||||
const { state } = JSON.parse(raw) as { state: { mode: ThemeMode; accent: AccentKey } }
|
||||
applyTheme(state.mode ?? 'light', state.accent ?? 'green')
|
||||
}
|
||||
} catch {
|
||||
// ignore — default theme already applied by CSS
|
||||
}
|
||||
})()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/** Subscribes to store changes and system preference changes */
|
||||
function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const mode = useThemeStore((s) => s.mode)
|
||||
const accent = useThemeStore((s) => s.accent)
|
||||
const resolvedTheme = resolveTheme(mode)
|
||||
|
||||
// Apply whenever mode or accent changes
|
||||
useEffect(() => {
|
||||
applyTheme(mode, accent)
|
||||
}, [mode, accent])
|
||||
|
||||
// Listen to system preference changes when mode='system'
|
||||
useEffect(() => {
|
||||
if (mode !== 'system') return
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => applyTheme('system', accent)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [mode, accent])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Toaster position="top-right" richColors theme={resolvedTheme} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import ThreeDViewer from '../components/cad/ThreeDViewer'
|
||||
|
||||
/**
|
||||
* Route: /cad/:id
|
||||
*
|
||||
* Renders the full-screen 3D viewer for a specific CAD file.
|
||||
* When the viewer is closed the user is navigated back.
|
||||
*/
|
||||
export default function CadPreviewPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-content-muted gap-4 p-8">
|
||||
<p className="text-lg">No CAD file ID provided.</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreeDViewer
|
||||
cadFileId={id}
|
||||
onClose={() => navigate(-1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import AdminDashboard from '../components/dashboard/AdminDashboard'
|
||||
import ClientDashboard from '../components/dashboard/ClientDashboard'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager'
|
||||
return isPrivileged ? <AdminDashboard /> : <ClientDashboard />
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../api/client'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((s) => s.setAuth)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/auth/login', { email, password })
|
||||
setAuth(res.data.access_token, res.data.user)
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.detail || 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-alt">
|
||||
<div className="card p-8 w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-accent rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-white text-2xl font-bold">S</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-content">Schaeffler Automat</h1>
|
||||
<p className="text-content-muted text-sm mt-1">Media Creation Pipeline</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="admin@schaeffler.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="input-base w-full"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full justify-center">
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Plus, Trash2, Pencil, Check, X, FlaskConical, Search, Wand2, Download,
|
||||
Wrench, Paintbrush, Shapes, HelpCircle, ChevronDown, ChevronRight, Tag,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
listMaterials, createMaterial, updateMaterial, deleteMaterial,
|
||||
seedSchaefflerMaterials, addAlias, deleteAlias, seedAliases,
|
||||
} from '../api/materials'
|
||||
import type { Material } from '../api/materials'
|
||||
import MaterialWizard from '../components/MaterialWizard'
|
||||
|
||||
const TYPE_GROUPS = [
|
||||
{ code: '01', label: 'Metals', icon: Wrench, bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-700' },
|
||||
{ code: '02', label: 'Coatings', icon: Paintbrush, bg: 'bg-status-info-bg', border: 'border-border-default', text: 'text-status-info-text' },
|
||||
{ code: '03', label: 'Non-metals', icon: Shapes, bg: 'bg-status-warning-bg', border: 'border-border-default', text: 'text-status-warning-text' },
|
||||
{ code: '04', label: 'Compounds', icon: FlaskConical, bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700' },
|
||||
{ code: '05', label: 'Misc', icon: HelpCircle, bg: 'bg-surface-alt', border: 'border-border-default', text: 'text-content-secondary' },
|
||||
] as const
|
||||
|
||||
function getTypeCode(mat: Material): string | null {
|
||||
if (mat.schaeffler_code == null) return null
|
||||
return String(mat.schaeffler_code).padStart(6, '0').slice(0, 2)
|
||||
}
|
||||
|
||||
interface MaterialGroup {
|
||||
code: string | null
|
||||
label: string
|
||||
icon: typeof Wrench
|
||||
bg: string
|
||||
border: string
|
||||
text: string
|
||||
items: Material[]
|
||||
}
|
||||
|
||||
export default function MaterialsPage() {
|
||||
const qc = useQueryClient()
|
||||
const [search, setSearch] = useState('')
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [showWizard, setShowWizard] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDesc, setEditDesc] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Set<string | null>>(new Set())
|
||||
const [expandedAliases, setExpandedAliases] = useState<Set<string>>(new Set())
|
||||
const [aliasInput, setAliasInput] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: materials = [], isLoading } = useQuery({
|
||||
queryKey: ['materials'],
|
||||
queryFn: listMaterials,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createMaterial({ name: newName.trim(), description: newDesc.trim() || undefined }),
|
||||
onSuccess: () => {
|
||||
toast.success('Material added')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
setShowAdd(false)
|
||||
setNewName('')
|
||||
setNewDesc('')
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to add material'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (id: string) => updateMaterial(id, { name: editName.trim(), description: editDesc.trim() || undefined }),
|
||||
onSuccess: () => {
|
||||
toast.success('Material updated')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
setEditingId(null)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteMaterial,
|
||||
onSuccess: () => {
|
||||
toast.success('Material deleted')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
||||
})
|
||||
|
||||
const seedMut = useMutation({
|
||||
mutationFn: seedSchaefflerMaterials,
|
||||
onSuccess: (data) => {
|
||||
if (data.inserted > 0) {
|
||||
toast.success(`Imported ${data.inserted} of ${data.total} Schaeffler standard materials`)
|
||||
} else {
|
||||
toast.info('All Schaeffler standard materials already exist')
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to import'),
|
||||
})
|
||||
|
||||
const seedAliasMut = useMutation({
|
||||
mutationFn: seedAliases,
|
||||
onSuccess: (data) => {
|
||||
if (data.inserted > 0) {
|
||||
toast.success(`Seeded ${data.inserted} aliases (${data.total} total checked)`)
|
||||
} else {
|
||||
toast.info('All aliases already exist')
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to seed aliases'),
|
||||
})
|
||||
|
||||
const addAliasMut = useMutation({
|
||||
mutationFn: ({ materialId, alias }: { materialId: string; alias: string }) => addAlias(materialId, alias),
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success('Alias added')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
setAliasInput((prev) => ({ ...prev, [vars.materialId]: '' }))
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to add alias'),
|
||||
})
|
||||
|
||||
const deleteAliasMut = useMutation({
|
||||
mutationFn: deleteAlias,
|
||||
onSuccess: () => {
|
||||
toast.success('Alias removed')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to remove alias'),
|
||||
})
|
||||
|
||||
const startEdit = (mat: Material) => {
|
||||
setEditingId(mat.id)
|
||||
setEditName(mat.name)
|
||||
setEditDesc(mat.description ?? '')
|
||||
}
|
||||
|
||||
const toggleAliases = (id: string) => {
|
||||
setExpandedAliases((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddAlias = (materialId: string) => {
|
||||
const val = (aliasInput[materialId] || '').trim()
|
||||
if (val) addAliasMut.mutate({ materialId, alias: val })
|
||||
}
|
||||
|
||||
// Search filters include aliases
|
||||
const filtered = search.trim()
|
||||
? materials.filter((m) => {
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.description?.toLowerCase().includes(q) ||
|
||||
m.aliases.some((a) => a.toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
: materials
|
||||
|
||||
// Group filtered materials by type code
|
||||
const groups = useMemo((): MaterialGroup[] => {
|
||||
const buckets = new Map<string | null, Material[]>()
|
||||
for (const m of filtered) {
|
||||
const tc = getTypeCode(m)
|
||||
if (!buckets.has(tc)) buckets.set(tc, [])
|
||||
buckets.get(tc)!.push(m)
|
||||
}
|
||||
|
||||
const result: MaterialGroup[] = []
|
||||
// Known type groups first
|
||||
for (const tg of TYPE_GROUPS) {
|
||||
const items = buckets.get(tg.code)
|
||||
if (items && items.length > 0) {
|
||||
result.push({ code: tg.code, label: tg.label, icon: tg.icon, bg: tg.bg, border: tg.border, text: tg.text, items })
|
||||
buckets.delete(tg.code)
|
||||
}
|
||||
}
|
||||
// Custom / non-schaeffler materials
|
||||
const custom = buckets.get(null)
|
||||
if (custom && custom.length > 0) {
|
||||
result.push({ code: null, label: 'Custom', icon: Plus, bg: 'bg-surface-alt', border: 'border-border-default', text: 'text-content-secondary', items: custom })
|
||||
}
|
||||
return result
|
||||
}, [filtered])
|
||||
|
||||
const toggleGroup = (code: string | null) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(code)) next.delete(code)
|
||||
else next.add(code)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const totalAliases = materials.reduce((sum, m) => sum + m.aliases.length, 0)
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<FlaskConical size={22} className="text-accent" />
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-content">Material Library</h1>
|
||||
<p className="text-sm text-content-secondary mt-0.5">
|
||||
Shared materials used when assigning CAD part materials to order items.
|
||||
{totalAliases > 0 && <span className="ml-2 text-content-muted">({totalAliases} aliases configured)</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Import 35 Schaeffler standard materials? Existing entries will be skipped.'))
|
||||
seedMut.mutate()
|
||||
}}
|
||||
disabled={seedMut.isPending}
|
||||
className="btn-secondary text-sm flex items-center gap-1.5"
|
||||
title="Import the 35 standard Schaeffler SCHAEFFLER_... materials used in Blender material libraries. Existing entries are skipped."
|
||||
>
|
||||
<Download size={14} /> {seedMut.isPending ? 'Importing...' : 'Import Standards'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Seed material aliases from naming scheme mappings? Existing aliases will be skipped.'))
|
||||
seedAliasMut.mutate()
|
||||
}}
|
||||
disabled={seedAliasMut.isPending}
|
||||
className="btn-secondary text-sm flex items-center gap-1.5"
|
||||
title="Seed ~100 material aliases from the Schaeffler naming scheme (German descriptions, intermediate codes → SCHAEFFLER_... library names). Existing aliases are skipped."
|
||||
>
|
||||
<Tag size={14} /> {seedAliasMut.isPending ? 'Seeding...' : 'Seed Aliases'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="btn-secondary text-sm flex items-center gap-1.5"
|
||||
title="Open the Schaeffler Wizard — guided tool to set up SCHAEFFLER_... materials and aliases from the standard naming scheme"
|
||||
>
|
||||
<Wand2 size={14} /> Schaeffler Wizard
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(!showAdd)} className="btn-primary">
|
||||
<Plus size={16} /> Add Material
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
{showAdd && (
|
||||
<div className="card p-4 mb-6 bg-surface-alt flex gap-3 items-end flex-wrap">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="block text-xs font-medium text-content-secondary mb-1">Name *</label>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="e.g. Steel 100Cr6"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && newName.trim() && createMut.mutate()}
|
||||
className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-xs font-medium text-content-secondary mb-1">Description</label>
|
||||
<input
|
||||
placeholder="e.g. Bearing steel, hardened"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && newName.trim() && createMut.mutate()}
|
||||
className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={!newName.trim() || createMut.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createMut.isPending ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search materials or aliases..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent bg-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grouped table */}
|
||||
{isLoading ? (
|
||||
<div className="card p-8 text-center text-content-muted text-sm">Loading...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card p-8 text-center text-content-muted text-sm">
|
||||
{search ? 'No materials match your search.' : 'No materials yet. Add the first one above.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{groups.map((group) => {
|
||||
const Icon = group.icon
|
||||
const isCollapsed = collapsed.has(group.code)
|
||||
return (
|
||||
<div key={group.code ?? 'custom'} className={`card overflow-hidden border ${group.border}`}>
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.code)}
|
||||
className={`w-full flex items-center gap-3 px-5 py-3 ${group.bg} hover:brightness-95 transition-all`}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={16} className={group.text} /> : <ChevronDown size={16} className={group.text} />}
|
||||
<Icon size={16} className={group.text} />
|
||||
<span className={`text-sm font-semibold ${group.text}`}>
|
||||
{group.code ? `${group.code} — ` : ''}{group.label}
|
||||
</span>
|
||||
<span className="text-xs text-content-muted ml-auto">{group.items.length} material{group.items.length !== 1 ? 's' : ''}</span>
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{/* Column header */}
|
||||
<div className="grid grid-cols-[2fr_2fr_1fr_1fr_1fr] bg-surface border-b border-border-light px-6 py-1.5">
|
||||
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Name</p>
|
||||
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Description</p>
|
||||
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Source</p>
|
||||
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Aliases</p>
|
||||
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Actions</p>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y divide-border-light">
|
||||
{group.items.map((mat) => {
|
||||
const aliasesExpanded = expandedAliases.has(mat.id)
|
||||
return (
|
||||
<div key={mat.id}>
|
||||
<div className="grid grid-cols-[2fr_2fr_1fr_1fr_1fr] items-center px-6 py-2.5 gap-3 hover:bg-surface-hover">
|
||||
{editingId === mat.id ? (
|
||||
<div className="col-span-5 flex items-center gap-3 flex-wrap">
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="flex-1 min-w-[140px] px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
placeholder="Description"
|
||||
className="flex-1 min-w-[200px] px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateMut.mutate(mat.id)}
|
||||
disabled={!editName.trim() || updateMut.isPending}
|
||||
className="text-status-success-text hover:text-status-success-text"
|
||||
title="Save"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="text-content-muted hover:text-content" title="Cancel">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-content truncate">{mat.name}</p>
|
||||
{mat.schaeffler_code != null && (
|
||||
<p className="text-xs text-content-muted font-mono">Nr: {mat.schaeffler_code}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-content-muted truncate">{mat.description || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<SourceBadge source={mat.source} />
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleAliases(mat.id)}
|
||||
className="inline-flex items-center gap-1 text-xs text-content-muted hover:text-content-secondary"
|
||||
title={`${mat.aliases.length} alias${mat.aliases.length !== 1 ? 'es' : ''} — click to ${aliasesExpanded ? 'collapse' : 'expand'}`}
|
||||
>
|
||||
<Tag size={12} />
|
||||
<span>{mat.aliases.length}</span>
|
||||
{mat.aliases.length > 0 && (
|
||||
aliasesExpanded
|
||||
? <ChevronDown size={12} />
|
||||
: <ChevronRight size={12} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => startEdit(mat)} className="text-content-muted hover:text-content" title="Edit">
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete material "${mat.name}"?`)) deleteMut.mutate(mat.id)
|
||||
}}
|
||||
className="text-content-muted hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable alias section */}
|
||||
{aliasesExpanded && editingId !== mat.id && (
|
||||
<div className="px-8 pb-3 pt-1 bg-surface-alt/50 border-t border-border-light">
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{mat.aliases.length === 0 && (
|
||||
<span className="text-xs text-content-muted italic">No aliases configured</span>
|
||||
)}
|
||||
{mat.aliases.map((alias) => (
|
||||
<AliasPill
|
||||
key={alias}
|
||||
alias={alias}
|
||||
materialId={mat.id}
|
||||
onDelete={deleteAliasMut}
|
||||
materials={materials}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add alias (e.g. Stahl, brüniert)"
|
||||
value={aliasInput[mat.id] || ''}
|
||||
onChange={(e) => setAliasInput((prev) => ({ ...prev, [mat.id]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleAddAlias(mat.id)
|
||||
}}
|
||||
className="flex-1 max-w-xs px-2 py-1 border border-border-default rounded text-xs focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleAddAlias(mat.id)}
|
||||
disabled={!(aliasInput[mat.id] || '').trim() || addAliasMut.isPending}
|
||||
className="text-xs text-accent hover:text-accent-hover font-medium disabled:opacity-40"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Footer count */}
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-content-muted">
|
||||
{filtered.length} of {materials.length} material{materials.length !== 1 ? 's' : ''}
|
||||
{totalAliases > 0 && ` · ${totalAliases} aliases`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wizard modal */}
|
||||
<MaterialWizard open={showWizard} onClose={() => setShowWizard(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AliasPill({
|
||||
alias,
|
||||
materialId,
|
||||
onDelete,
|
||||
materials,
|
||||
}: {
|
||||
alias: string
|
||||
materialId: string
|
||||
onDelete: { mutate: (id: string) => void; isPending: boolean }
|
||||
materials: Material[]
|
||||
}) {
|
||||
// We need the alias ID for deletion - find it from the material's aliases list
|
||||
// Since we only have alias strings from MaterialOut, we need to query the ID
|
||||
// We'll use a lazy approach: delete by fetching aliases for this material
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const { listAliases: fetchAliases } = await import('../api/materials')
|
||||
const aliases = await fetchAliases(materialId)
|
||||
const found = aliases.find((a) => a.alias === alias)
|
||||
if (found) {
|
||||
onDelete.mutate(found.id)
|
||||
}
|
||||
} catch {
|
||||
// Fallback: ignore
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-indigo-50 text-indigo-700 px-2 py-0.5 rounded-full">
|
||||
{alias}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-indigo-400 hover:text-red-500 ml-0.5"
|
||||
title={`Remove alias "${alias}"`}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: string }) {
|
||||
if (source === 'schaeffler_standard') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium bg-status-success-bg text-status-success-text px-2 py-0.5 rounded-full">
|
||||
Standard
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (source === 'cad_import') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
|
||||
CAD import
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium bg-surface-muted text-content-secondary px-2 py-0.5 rounded-full">
|
||||
Manual
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeft, FileSpreadsheet, Package } from 'lucide-react'
|
||||
|
||||
export default function NewOrderPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link to="/orders" className="btn-secondary"><ArrowLeft size={16} />Back</Link>
|
||||
<h1 className="text-2xl font-bold text-content">New Order</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Excel Upload */}
|
||||
<Link
|
||||
to="/upload"
|
||||
className="card p-6 hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-lg bg-emerald-50 flex items-center justify-center mb-4 group-hover:bg-emerald-100 transition-colors">
|
||||
<FileSpreadsheet size={24} className="text-emerald-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-content mb-1">Upload Excel</h2>
|
||||
<p className="text-sm text-content-muted">
|
||||
Import order items from an Excel template file with product data and components.
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
{/* Product Library */}
|
||||
<Link
|
||||
to="/orders/new/product"
|
||||
className="card p-6 hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-lg bg-status-info-bg flex items-center justify-center mb-4 group-hover:bg-surface-hover transition-colors">
|
||||
<Package size={24} className="text-status-info-text" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-content mb-1">Product Library</h2>
|
||||
<p className="text-sm text-content-muted">
|
||||
Select products from the library and configure output types for rendering.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Bell, Send, PlayCircle, CheckCircle, XCircle, Image, AlertTriangle, CheckCheck,
|
||||
} from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import {
|
||||
getNotifications, markAsRead, markOneAsRead,
|
||||
type Notification,
|
||||
} from '../api/notifications'
|
||||
|
||||
const ACTION_CONFIG: Record<string, { icon: typeof Bell; label: (d: Record<string, unknown> | null) => string; color: string }> = {
|
||||
'order.submitted': { icon: Send, label: (d) => `Order ${d?.order_number ?? '?'} submitted`, color: 'text-blue-500' },
|
||||
'order.processing': { icon: PlayCircle, label: (d) => `Order ${d?.order_number ?? '?'} is processing`, color: 'text-yellow-500' },
|
||||
'order.completed': { icon: CheckCircle, label: (d) => `Order ${d?.order_number ?? '?'} completed`, color: 'text-status-success-text' },
|
||||
'order.rejected': { icon: XCircle, label: (d) => `Order ${d?.order_number ?? '?'} was rejected`, color: 'text-red-500' },
|
||||
'render.completed': { icon: Image, label: (d) => `Render done: ${d?.product_name ?? 'unknown'} — ${d?.output_type ?? ''}`, color: 'text-status-success-text' },
|
||||
'render.failed': { icon: AlertTriangle, label: (d) => `Render failed: ${d?.product_name ?? 'unknown'} — ${d?.output_type ?? ''}`, color: 'text-red-500' },
|
||||
'excel.import_warnings': { icon: AlertTriangle, label: (d) => `Excel '${d?.filename ?? '?'}' had ${d?.warning_count ?? '?'} warning(s)`, color: 'text-amber-500' },
|
||||
'excel.import_error': { icon: XCircle, label: (d) => `Excel parse failed: ${d?.filename ?? '?'}`, color: 'text-red-500' },
|
||||
'excel.finalize_error': { icon: XCircle, label: (d) => `Order creation failed: ${d?.filename ?? '?'}`, color: 'text-red-500' },
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
const d = new Date(ts)
|
||||
const diff = Date.now() - d.getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [unreadOnly, setUnreadOnly] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notifications', 'page', offset, unreadOnly],
|
||||
queryFn: () => getNotifications({ limit: PAGE_SIZE, offset, unread_only: unreadOnly }),
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
const markAllMutation = useMutation({
|
||||
mutationFn: () => markAsRead(),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
})
|
||||
|
||||
const markOneMutation = useMutation({
|
||||
mutationFn: (id: string) => markOneAsRead(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
})
|
||||
|
||||
function handleClick(n: Notification) {
|
||||
if (!n.read_at) markOneMutation.mutate(n.id)
|
||||
if (n.entity_type === 'order' && n.entity_id) {
|
||||
navigate(`/orders/${n.entity_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const unreadCount = data?.unread_count ?? 0
|
||||
const hasMore = offset + PAGE_SIZE < total
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-content">Notifications</h1>
|
||||
<p className="text-sm text-content-muted mt-0.5">{unreadCount} unread</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex bg-surface-muted rounded-md p-0.5">
|
||||
<button
|
||||
onClick={() => { setUnreadOnly(false); setOffset(0) }}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-xs font-medium rounded',
|
||||
!unreadOnly ? 'bg-surface shadow text-content' : 'text-content-muted',
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setUnreadOnly(true); setOffset(0) }}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-xs font-medium rounded',
|
||||
unreadOnly ? 'bg-surface shadow text-content' : 'text-content-muted',
|
||||
)}
|
||||
>
|
||||
Unread
|
||||
</button>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markAllMutation.mutate()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-accent border border-accent rounded-md hover:bg-accent-light transition-colors"
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-lg border border-border-default divide-y divide-border-light">
|
||||
{isLoading && (
|
||||
<div className="py-12 text-center text-sm text-content-muted">Loading...</div>
|
||||
)}
|
||||
{!isLoading && !items.length && (
|
||||
<div className="py-12 text-center text-sm text-content-muted">
|
||||
{unreadOnly ? 'No unread notifications' : 'No notifications yet'}
|
||||
</div>
|
||||
)}
|
||||
{items.map((n) => {
|
||||
const cfg = ACTION_CONFIG[n.action] ?? {
|
||||
icon: Bell,
|
||||
label: () => n.action,
|
||||
color: 'text-content-muted',
|
||||
}
|
||||
const Icon = cfg.icon
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => handleClick(n)}
|
||||
className={clsx(
|
||||
'w-full flex items-start gap-3 px-5 py-4 text-left hover:bg-surface-hover transition-colors',
|
||||
!n.read_at && 'bg-status-info-bg',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('mt-0.5 p-1.5 rounded-full bg-surface-muted', cfg.color)}>
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={clsx('text-sm', !n.read_at ? 'font-medium text-content' : 'text-content-secondary')}>
|
||||
{cfg.label(n.details)}
|
||||
</p>
|
||||
<p className="text-xs text-content-muted mt-1">{formatTime(n.timestamp)}</p>
|
||||
{n.action === 'excel.import_warnings' && n.details?.warnings && (
|
||||
<ul className="mt-1.5 text-xs text-content-secondary list-disc list-inside space-y-0.5">
|
||||
{(n.details.warnings as string[]).slice(0, 3).map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{n.details?.error && (
|
||||
<p className="mt-1.5 text-xs text-red-600 font-mono bg-red-50 rounded px-2 py-1 whitespace-pre-wrap break-all">
|
||||
{String(n.details.error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!n.read_at && (
|
||||
<span className="mt-2 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1.5 text-sm border rounded-md disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-content-muted">
|
||||
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
disabled={!hasMore}
|
||||
className="px-3 py-1.5 text-sm border rounded-md disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,797 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import {
|
||||
Plus, Package, Trash2, X, Search, SlidersHorizontal,
|
||||
LayoutGrid, LayoutList, Calendar, FileSpreadsheet,
|
||||
Clock, CheckCircle2, XCircle, Loader2, Send, FilePen,
|
||||
ChevronRight, Filter,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { listOrders, searchOrders, deleteOrder } from '../api/orders'
|
||||
import { fetchThumbnailBlob } from '../api/cad'
|
||||
import type { Order, OrderDetail, OrderItem } from '../api/orders'
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUSES = ['draft', 'submitted', 'processing', 'completed', 'rejected'] as const
|
||||
type Status = typeof STATUSES[number]
|
||||
|
||||
const STATUS_META: Record<Status, {
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
header: string
|
||||
card: string
|
||||
badge: string
|
||||
chip: string // active chip style
|
||||
chipInactive: string
|
||||
}> = {
|
||||
draft: { label: 'Draft', icon: FilePen, header: 'bg-gray-500', card: 'border-l-gray-400', badge: 'badge-gray', chip: 'bg-gray-500 text-white border-gray-500', chipInactive: 'border-border-default text-content-secondary hover:border-gray-400 hover:bg-surface-hover' },
|
||||
submitted: { label: 'Submitted', icon: Send, header: 'bg-blue-500', card: 'border-l-blue-400', badge: 'badge-blue', chip: 'bg-blue-500 text-white border-blue-500', chipInactive: 'border-border-default text-content-secondary hover:border-blue-300 hover:bg-status-info-bg' },
|
||||
processing: { label: 'Processing', icon: Loader2, header: 'bg-amber-500', card: 'border-l-amber-400', badge: 'badge-yellow', chip: 'bg-amber-500 text-white border-amber-500', chipInactive: 'border-border-default text-content-secondary hover:border-amber-300 hover:bg-status-warning-bg' },
|
||||
completed: { label: 'Completed', icon: CheckCircle2, header: 'bg-green-600', card: 'border-l-green-500', badge: 'badge-green', chip: 'bg-green-600 text-white border-green-600', chipInactive: 'border-border-default text-content-secondary hover:border-green-300 hover:bg-status-success-bg' },
|
||||
rejected: { label: 'Rejected', icon: XCircle, header: 'bg-red-500', card: 'border-l-red-400', badge: 'badge-red', chip: 'bg-red-500 text-white border-red-500', chipInactive: 'border-border-default text-content-secondary hover:border-red-300 hover:bg-status-error-bg' },
|
||||
}
|
||||
|
||||
const isDeletable = (s: string) =>
|
||||
s === 'draft' || s === 'submitted' || s === 'rejected'
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function OrdersPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [view, setView] = useState<'kanban' | 'list'>('kanban')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<Set<Status>>(new Set())
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
// Debounce the search input (400 ms)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
||||
return () => clearTimeout(t)
|
||||
}, [searchInput])
|
||||
|
||||
const isSearchMode = debouncedSearch.length > 0
|
||||
|
||||
// Normal orders list
|
||||
const { data: orders = [], isLoading: ordersLoading } = useQuery({
|
||||
queryKey: ['orders'],
|
||||
queryFn: () => listOrders(),
|
||||
refetchInterval: 15000,
|
||||
})
|
||||
|
||||
// Search results (backend full-text search)
|
||||
const { data: searchResults = [], isLoading: searchLoading } = useQuery({
|
||||
queryKey: [
|
||||
'orders', 'search', debouncedSearch,
|
||||
[...selectedStatuses].sort().join(','), dateFrom, dateTo,
|
||||
],
|
||||
queryFn: () => searchOrders({
|
||||
q: debouncedSearch,
|
||||
statuses: selectedStatuses.size > 0 ? [...selectedStatuses] : undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
}),
|
||||
enabled: isSearchMode,
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
const isLoading = isSearchMode ? searchLoading : ordersLoading
|
||||
|
||||
// Status chip toggle
|
||||
const toggleStatus = (s: Status) =>
|
||||
setSelectedStatuses((prev) => {
|
||||
const n = new Set(prev)
|
||||
n.has(s) ? n.delete(s) : n.add(s)
|
||||
return n
|
||||
})
|
||||
|
||||
// Client-side filtering for non-search mode
|
||||
const filtered = useMemo(() => {
|
||||
let result = orders
|
||||
if (dateFrom) result = result.filter((o) => o.created_at >= dateFrom)
|
||||
if (dateTo) result = result.filter((o) => o.created_at.slice(0, 10) <= dateTo)
|
||||
return result
|
||||
}, [orders, dateFrom, dateTo])
|
||||
|
||||
// Kanban grouping
|
||||
const byStatus = useMemo(() => {
|
||||
const map: Record<Status, Order[]> = {
|
||||
draft: [], submitted: [], processing: [], completed: [], rejected: [],
|
||||
}
|
||||
for (const o of filtered) {
|
||||
if (o.status in map) map[o.status as Status].push(o)
|
||||
}
|
||||
return map
|
||||
}, [filtered])
|
||||
|
||||
// Which columns to show in kanban (all if none selected, or only selected)
|
||||
const visibleStatuses: Status[] =
|
||||
selectedStatuses.size > 0
|
||||
? STATUSES.filter((s) => selectedStatuses.has(s))
|
||||
: [...STATUSES]
|
||||
|
||||
const hasDateFilter = !!(dateFrom || dateTo)
|
||||
const clearFilters = () => {
|
||||
setSelectedStatuses(new Set())
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
setSearchInput('')
|
||||
}
|
||||
|
||||
// ── Bulk-delete helpers ──────────────────────────────────────────────────
|
||||
|
||||
const listFiltered = filtered.filter(
|
||||
(o) => selectedStatuses.size === 0 || selectedStatuses.has(o.status as Status)
|
||||
)
|
||||
const deletableFiltered = listFiltered.filter((o) => isDeletable(o.status))
|
||||
const allSelected =
|
||||
deletableFiltered.length > 0 &&
|
||||
deletableFiltered.every((o) => selected.has(o.id))
|
||||
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev)
|
||||
n.has(id) ? n.delete(id) : n.add(id)
|
||||
return n
|
||||
})
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected(
|
||||
allSelected ? new Set() : new Set(deletableFiltered.map((o) => o.id))
|
||||
)
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
await Promise.all(ids.map((id) => deleteOrder(id)))
|
||||
},
|
||||
onSuccess: (_, ids) => {
|
||||
toast.success(`${ids.length} order${ids.length > 1 ? 's' : ''} deleted`)
|
||||
setSelected(new Set())
|
||||
qc.invalidateQueries({ queryKey: ['orders'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
|
||||
})
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`Delete ${ids.length} order${ids.length > 1 ? 's' : ''}? This cannot be undone.`)) return
|
||||
deleteMut.mutate(ids)
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
|
||||
<div className="px-6 pt-5 pb-4 bg-surface border-b border-border-default shrink-0">
|
||||
|
||||
{/* Row 1: title + view toggle + new order */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className="text-2xl font-bold text-content">Orders</h1>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="flex border border-border-default rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => setView('kanban')}
|
||||
title="Kanban view"
|
||||
className={`px-2.5 py-1.5 text-sm flex items-center transition-colors ${
|
||||
view === 'kanban' ? 'bg-surface-muted text-content' : 'text-content-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
title="List view"
|
||||
className={`px-2.5 py-1.5 text-sm flex items-center border-l border-border-default transition-colors ${
|
||||
view === 'list' ? 'bg-surface-muted text-content' : 'text-content-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<LayoutList size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<Link to="/orders/new" className="btn-primary">
|
||||
<Plus size={16} /> New Order
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Search bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search orders, products, bearings, PIM IDs, CAD model names…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="w-full pl-9 pr-9 py-2.5 text-sm border border-border-default rounded-lg
|
||||
bg-surface-alt focus:bg-surface focus:outline-none focus:ring-2
|
||||
focus:ring-accent focus:border-transparent transition-colors"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => setSearchInput('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-content-muted hover:text-content transition-colors"
|
||||
title="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: Status chips + date filter toggle */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-content-muted mr-1 shrink-0">
|
||||
{isSearchMode ? 'Filter results:' : 'Show:'}
|
||||
</span>
|
||||
{STATUSES.map((s) => {
|
||||
const meta = STATUS_META[s]
|
||||
const active = selectedStatuses.has(s)
|
||||
const count = byStatus[s]?.length ?? 0
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => toggleStatus(s)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all ${
|
||||
active ? meta.chip : meta.chipInactive
|
||||
}`}
|
||||
>
|
||||
<meta.icon size={11} className={active ? '' : 'opacity-60'} />
|
||||
{meta.label}
|
||||
{!isSearchMode && count > 0 && (
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none ${
|
||||
active ? 'bg-white/25 text-white' : 'bg-surface-muted text-content-secondary'
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border transition-colors ${
|
||||
showFilters || hasDateFilter
|
||||
? 'border-accent bg-accent-light text-accent'
|
||||
: 'border-border-default text-content-secondary hover:border-border-default'
|
||||
}`}
|
||||
>
|
||||
<Filter size={12} />
|
||||
Date
|
||||
{hasDateFilter && <span className="w-1.5 h-1.5 bg-accent rounded-full ml-0.5" />}
|
||||
</button>
|
||||
{(selectedStatuses.size > 0 || hasDateFilter || searchInput) && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-content-muted hover:text-content flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<X size={11} /> Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date filter panel */}
|
||||
{showFilters && (
|
||||
<div className="mt-3 flex items-center gap-4 flex-wrap px-3 py-2.5 bg-surface-alt rounded-lg border border-border-default">
|
||||
<Calendar size={14} className="text-content-muted shrink-0" />
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<label className="text-content-secondary whitespace-nowrap">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<label className="text-content-secondary whitespace-nowrap">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
{hasDateFilter && (
|
||||
<button
|
||||
onClick={() => { setDateFrom(''); setDateTo('') }}
|
||||
className="text-xs text-content-muted hover:text-content-secondary flex items-center gap-1"
|
||||
>
|
||||
<X size={11} /> Clear dates
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Content ──────────────────────────────────────────────────────── */}
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-content-muted">
|
||||
<Loader2 size={24} className="animate-spin mr-2" />
|
||||
{isSearchMode ? 'Searching…' : 'Loading orders…'}
|
||||
</div>
|
||||
) : isSearchMode ? (
|
||||
<SearchResultsView
|
||||
results={searchResults}
|
||||
query={debouncedSearch}
|
||||
onNavigate={(id) => navigate(`/orders/${id}`)}
|
||||
/>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-12">
|
||||
<Package size={48} className="text-content-muted mb-4" />
|
||||
<p className="text-content-secondary font-medium mb-1">No orders yet</p>
|
||||
<p className="text-content-muted text-sm mb-4">Upload an Excel file to create your first order.</p>
|
||||
<Link to="/upload" className="btn-primary">
|
||||
<FileSpreadsheet size={16} /> Upload Excel
|
||||
</Link>
|
||||
</div>
|
||||
) : view === 'kanban' ? (
|
||||
<KanbanBoard
|
||||
byStatus={byStatus}
|
||||
visibleStatuses={visibleStatuses}
|
||||
onNavigate={(id) => navigate(`/orders/${id}`)}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
orders={listFiltered}
|
||||
selected={selected}
|
||||
allSelected={allSelected}
|
||||
onToggleOne={toggleOne}
|
||||
onToggleAll={toggleAll}
|
||||
onNavigate={(id) => navigate(`/orders/${id}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
|
||||
{selected.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] z-50
|
||||
flex items-center gap-3 px-5 py-3
|
||||
bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10">
|
||||
<span className="text-sm font-medium">
|
||||
{selected.size} order{selected.size > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="w-px h-5 bg-white/20" />
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={deleteMut.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-500
|
||||
hover:bg-red-600 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{deleteMut.isPending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-white transition-colors"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search results view ───────────────────────────────────────────────────────
|
||||
|
||||
function SearchResultsView({
|
||||
results,
|
||||
query,
|
||||
onNavigate,
|
||||
}: {
|
||||
results: OrderDetail[]
|
||||
query: string
|
||||
onNavigate: (id: string) => void
|
||||
}) {
|
||||
const totalItems = results.reduce((acc, o) => acc + o.items.length, 0)
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-12">
|
||||
<Search size={40} className="text-content-muted mb-4" />
|
||||
<p className="text-content-secondary font-medium mb-1">No results for “{query}”</p>
|
||||
<p className="text-content-muted text-sm">
|
||||
Try searching by product name, bearing type, PIM ID, Baureihe, or order number.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-sm text-content-secondary mb-4">
|
||||
<span className="font-semibold text-content-secondary">{results.length}</span> order{results.length !== 1 ? 's' : ''},
|
||||
<span className="font-semibold text-content-secondary">{totalItems}</span> matching item{totalItems !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{results.map((order) => (
|
||||
<SearchOrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
query={query}
|
||||
onNavigate={() => onNavigate(order.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchOrderCard({
|
||||
order,
|
||||
query,
|
||||
onNavigate,
|
||||
}: {
|
||||
order: OrderDetail
|
||||
query: string
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
const meta = STATUS_META[order.status as Status] ?? STATUS_META.draft
|
||||
const date = new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
{/* Order header */}
|
||||
<div className={`flex items-center gap-3 px-4 py-3 border-l-4 ${meta.card} bg-surface-alt border-b border-border-light`}>
|
||||
<span className="font-bold font-mono text-content">{order.order_number}</span>
|
||||
<span className={`badge ${meta.badge}`}>{order.status}</span>
|
||||
<span className="text-xs text-content-muted flex items-center gap-1">
|
||||
<Clock size={11} /> {date}
|
||||
</span>
|
||||
<span className="text-xs text-content-secondary">
|
||||
{order.items.length} matching item{order.items.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={onNavigate}
|
||||
className="ml-auto text-xs text-accent font-medium hover:underline flex items-center gap-1"
|
||||
>
|
||||
Open order <ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Matching items */}
|
||||
{order.items.length > 0 && (
|
||||
<>
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-[3rem_1fr_1fr_1fr_1fr_1fr] gap-x-4 px-4 py-1.5
|
||||
text-[10px] font-semibold text-content-muted uppercase tracking-wider
|
||||
bg-surface-alt border-b border-border-light">
|
||||
<div />
|
||||
<div>PIM ID / Level</div>
|
||||
<div>Baureihe</div>
|
||||
<div>Product</div>
|
||||
<div>CAD Model</div>
|
||||
<div>Bearing type</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{order.items.map((item) => (
|
||||
<SearchItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
query={query}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Highlight({ text, query }: { text: string | null; query: string }) {
|
||||
if (!text) return null
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase())
|
||||
if (idx === -1) return <span>{text}</span>
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="bg-yellow-100 text-yellow-900 rounded px-0.5 not-italic">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchItemRow({
|
||||
item,
|
||||
query,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: OrderItem
|
||||
query: string
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!item.cad_file_id) return
|
||||
let revoked = false
|
||||
fetchThumbnailBlob(item.cad_file_id).then((url) => {
|
||||
if (!revoked) setThumbUrl(url)
|
||||
}).catch(() => {})
|
||||
return () => {
|
||||
revoked = true
|
||||
if (thumbUrl) URL.revokeObjectURL(thumbUrl)
|
||||
}
|
||||
}, [item.cad_file_id])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-[3rem_1fr_1fr_1fr_1fr_1fr] gap-x-4 px-4 py-2.5 items-center
|
||||
hover:bg-surface-hover cursor-pointer transition-colors"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-10 h-10 rounded bg-surface-muted overflow-hidden flex items-center justify-center shrink-0">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Package size={16} className="text-content-muted" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs min-w-0">
|
||||
{item.pim_id && (
|
||||
<p className="font-semibold text-content truncate">
|
||||
<Highlight text={item.pim_id} query={query} />
|
||||
</p>
|
||||
)}
|
||||
{item.ebene2 && (
|
||||
<p className="text-content-muted truncate">
|
||||
<Highlight text={item.ebene2} query={query} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-content-secondary truncate">
|
||||
<Highlight text={item.baureihe} query={query} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-content-secondary truncate">
|
||||
<Highlight text={item.gewaehltes_produkt} query={query} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs font-mono text-content-secondary truncate">
|
||||
<Highlight text={item.name_cad_modell} query={query} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-content-muted truncate">
|
||||
<Highlight text={item.lagertyp} query={query} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Kanban board ─────────────────────────────────────────────────────────────
|
||||
|
||||
function KanbanBoard({
|
||||
byStatus,
|
||||
visibleStatuses,
|
||||
onNavigate,
|
||||
}: {
|
||||
byStatus: Record<Status, Order[]>
|
||||
visibleStatuses: Status[]
|
||||
onNavigate: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto min-h-0">
|
||||
<div className="flex gap-4 p-6 h-full min-w-max">
|
||||
{visibleStatuses.map((status) => {
|
||||
const meta = STATUS_META[status]
|
||||
const cards = byStatus[status]
|
||||
return (
|
||||
<div key={status} className="flex flex-col w-72 min-w-[272px]">
|
||||
<div className={`${meta.header} rounded-t-xl px-4 py-3 flex items-center gap-2`}>
|
||||
<meta.icon size={16} className="text-white/80 shrink-0" />
|
||||
<span className="text-white font-semibold text-sm">{meta.label}</span>
|
||||
<span className="ml-auto bg-white/20 text-white text-xs font-bold px-2 py-0.5 rounded-full">
|
||||
{cards.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-surface-muted rounded-b-xl p-2 overflow-y-auto space-y-2 min-h-[120px]">
|
||||
{cards.length === 0 ? (
|
||||
<div className="h-20 flex items-center justify-center text-content-muted text-xs">
|
||||
No orders
|
||||
</div>
|
||||
) : (
|
||||
cards.map((order) => (
|
||||
<KanbanCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
meta={meta}
|
||||
onNavigate={() => onNavigate(order.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderProgressBar({ progress }: {
|
||||
progress: { total: number; completed: number; processing: number; failed: number; pending: number; cancelled?: number }
|
||||
}) {
|
||||
const { total, completed, processing, failed, pending, cancelled = 0 } = progress
|
||||
if (total === 0) return null
|
||||
const pct = (n: number) => `${(n / total) * 100}%`
|
||||
const allDone = completed === total
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden bg-surface-muted">
|
||||
{completed > 0 && <div className="bg-green-500 transition-all duration-300" style={{ width: pct(completed) }} />}
|
||||
{processing > 0 && <div className="bg-blue-500 transition-all duration-300" style={{ width: pct(processing) }} />}
|
||||
{failed > 0 && <div className="bg-red-400 transition-all duration-300" style={{ width: pct(failed) }} />}
|
||||
{cancelled > 0 && <div className="bg-orange-300 transition-all duration-300" style={{ width: pct(cancelled) }} />}
|
||||
{pending > 0 && <div className="bg-gray-200 transition-all duration-300" style={{ width: pct(pending) }} />}
|
||||
</div>
|
||||
<p className={`text-[10px] mt-0.5 font-medium ${allDone ? 'text-green-600' : 'text-content-muted'}`}>
|
||||
{allDone ? `${total}/${total}` : `Rendered ${completed}/${total}`}
|
||||
{failed > 0 && <span className="text-red-400 ml-1">({failed} failed)</span>}
|
||||
{cancelled > 0 && <span className="text-orange-400 ml-1">({cancelled} cancelled)</span>}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KanbanCard({
|
||||
order,
|
||||
meta,
|
||||
onNavigate,
|
||||
}: {
|
||||
order: Order
|
||||
meta: typeof STATUS_META[Status]
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
const date = new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
})
|
||||
|
||||
const rp = order.render_progress
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onNavigate}
|
||||
className={`w-full text-left bg-surface rounded-lg shadow-sm border border-border-default
|
||||
border-l-4 ${meta.card} hover:shadow-md hover:-translate-y-0.5
|
||||
transition-all duration-150 p-3 group`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<span className="text-sm font-bold text-content font-mono leading-tight">
|
||||
{order.order_number}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-content-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<Package size={11} />
|
||||
{order.item_count} item{order.item_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={11} />
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
{rp && rp.total > 0 && (
|
||||
<RenderProgressBar progress={rp} />
|
||||
)}
|
||||
{order.notes && !rp && (
|
||||
<p className="mt-2 text-xs text-content-muted truncate">{order.notes}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-xs text-accent font-medium">Open →</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── List view ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ListView({
|
||||
orders,
|
||||
selected,
|
||||
allSelected,
|
||||
onToggleOne,
|
||||
onToggleAll,
|
||||
onNavigate,
|
||||
}: {
|
||||
orders: Order[]
|
||||
selected: Set<string>
|
||||
allSelected: boolean
|
||||
onToggleOne: (id: string) => void
|
||||
onToggleAll: () => void
|
||||
onNavigate: (id: string) => void
|
||||
}) {
|
||||
const deletable = orders.filter((o) => isDeletable(o.status))
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-6 my-4 card overflow-hidden">
|
||||
<div className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] bg-surface-alt border-b border-border-default px-4 py-2.5 text-xs font-semibold text-content-secondary uppercase tracking-wide">
|
||||
<div>
|
||||
{deletable.length > 0 && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={onToggleAll}
|
||||
className="w-3.5 h-3.5 rounded accent-red-500 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>Order</div>
|
||||
<div>Items</div>
|
||||
<div>Status</div>
|
||||
<div>Created</div>
|
||||
</div>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="p-10 text-center text-content-muted text-sm">
|
||||
No orders match the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-light">
|
||||
{orders.map((order) => {
|
||||
const canSelect = isDeletable(order.status)
|
||||
const isSelected = selected.has(order.id)
|
||||
const meta = STATUS_META[order.status as Status]
|
||||
return (
|
||||
<div
|
||||
key={order.id}
|
||||
className={`grid grid-cols-[2rem_1fr_6rem_5rem_6rem] items-center px-4 py-3
|
||||
hover:bg-surface-hover transition-colors cursor-pointer
|
||||
${isSelected ? 'bg-red-50 hover:bg-red-50' : ''}`}
|
||||
onClick={() => onNavigate(order.id)}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{canSelect ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleOne(order.id)}
|
||||
className="w-3.5 h-3.5 rounded accent-red-500 cursor-pointer"
|
||||
/>
|
||||
) : <span className="w-3.5 h-3.5 inline-block" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-content font-mono">{order.order_number}</p>
|
||||
{order.notes && (
|
||||
<p className="text-xs text-content-muted truncate mt-0.5">{order.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-content-secondary">
|
||||
{order.item_count} item{order.item_count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div>
|
||||
<span className={`badge ${meta?.badge ?? 'badge-gray'}`}>{order.status}</span>
|
||||
</div>
|
||||
<div className="text-xs text-content-muted">
|
||||
{new Date(order.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Sun, Monitor, Moon } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useThemeStore, ACCENT_PRESETS, type ThemeMode } from '../store/theme'
|
||||
|
||||
const MODES: { key: ThemeMode; icon: typeof Sun; label: string }[] = [
|
||||
{ key: 'light', icon: Sun, label: 'Light' },
|
||||
{ key: 'system', icon: Monitor, label: 'System' },
|
||||
{ key: 'dark', icon: Moon, label: 'Dark' },
|
||||
]
|
||||
|
||||
export default function PreferencesPage() {
|
||||
const { mode, accent, setMode, setAccent } = useThemeStore()
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<h1 className="text-2xl font-bold text-content mb-1">Preferences</h1>
|
||||
<p className="text-sm text-content-muted mb-8">Customize your Schaeffler Automat experience.</p>
|
||||
|
||||
{/* Appearance */}
|
||||
<section className="card p-6 space-y-6">
|
||||
<h2 className="text-base font-semibold text-content">Appearance</h2>
|
||||
|
||||
{/* Theme mode */}
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-36 shrink-0">
|
||||
<p className="text-sm font-medium text-content">Theme</p>
|
||||
<p className="text-xs text-content-muted mt-0.5">Light, dark, or follow system</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{MODES.map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setMode(key)}
|
||||
className={clsx(
|
||||
'flex flex-col items-center gap-2 w-20 py-3 rounded-lg border text-xs font-medium transition-colors',
|
||||
mode === key
|
||||
? 'text-accent'
|
||||
: 'border-border-default bg-surface text-content-secondary hover:bg-surface-hover',
|
||||
)}
|
||||
style={mode === key ? { borderColor: 'var(--color-accent)', backgroundColor: 'var(--color-accent-light)' } : undefined}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-light" />
|
||||
|
||||
{/* Accent color */}
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-36 shrink-0">
|
||||
<p className="text-sm font-medium text-content">Accent color</p>
|
||||
<p className="text-xs text-content-muted mt-0.5">Used for buttons, links, and highlights</p>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-1">
|
||||
{ACCENT_PRESETS.map(({ key, label, hex }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setAccent(key)}
|
||||
title={label}
|
||||
className={clsx(
|
||||
'w-8 h-8 rounded-full transition-all',
|
||||
accent === key ? 'scale-125' : 'hover:scale-110',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hex,
|
||||
outline: accent === key ? `2px solid ${hex}` : undefined,
|
||||
outlineOffset: accent === key ? '3px' : undefined,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
|
||||
LayoutGrid, List, Trash2, X,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { listProducts, deleteProduct } from '../api/products'
|
||||
import type { Product } from '../api/products'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
TRB: 'TRB',
|
||||
Kugellager: 'Kugellager',
|
||||
CRB: 'CRB',
|
||||
Gleitlager: 'Gleitlager',
|
||||
SRB_TORB: 'SRB/TORB',
|
||||
Linear_schiene: 'Linear',
|
||||
Anschlagplatten: 'Anschlag',
|
||||
}
|
||||
|
||||
function CadStatusChip({ status }: { status: string | null }) {
|
||||
if (!status) return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted">no STEP</span>
|
||||
)
|
||||
if (status === 'completed') return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text flex items-center gap-1">
|
||||
<CheckCircle2 size={11} /> ready
|
||||
</span>
|
||||
)
|
||||
if (status === 'processing') return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text flex items-center gap-1">
|
||||
<Clock size={11} /> processing
|
||||
</span>
|
||||
)
|
||||
if (status === 'failed') return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-status-error-bg text-status-error-text flex items-center gap-1">
|
||||
<AlertTriangle size={11} /> failed
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-text">{status}</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductCard({ product, onClick, selected, onSelect }: {
|
||||
product: Product
|
||||
onClick: () => void
|
||||
selected: boolean
|
||||
onSelect: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`card cursor-pointer hover:shadow-md transition-shadow overflow-hidden relative ${
|
||||
selected ? 'ring-2 ring-accent' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Checkbox overlay */}
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(e) => onSelect(e.target.checked)}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="h-40 bg-surface-muted flex items-center justify-center overflow-hidden">
|
||||
{(product.render_image_url || product.thumbnail_url) ? (
|
||||
<img
|
||||
src={product.render_image_url || product.thumbnail_url!}
|
||||
alt={product.name || product.pim_id}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Box size={48} className="text-content-muted" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-1">
|
||||
<span className="inline-block text-xs font-mono bg-surface-muted text-content-secondary px-2 py-0.5 rounded">
|
||||
{product.pim_id}
|
||||
</span>
|
||||
|
||||
<p className="font-semibold text-content text-sm leading-tight truncate" title={product.name || ''}>
|
||||
{product.name || <span className="text-content-muted italic">no name</span>}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{product.category_key && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
|
||||
{CATEGORY_LABELS[product.category_key] || product.category_key}
|
||||
</span>
|
||||
)}
|
||||
{product.baureihe && (
|
||||
<span className="text-xs text-content-muted truncate">{product.baureihe}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1 flex items-center gap-2">
|
||||
<CadStatusChip status={product.processing_status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProductLibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [search, setSearch] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [hasCadFilter, setHasCadFilter] = useState<string>('')
|
||||
const [materialsFilter, setMaterialsFilter] = useState('')
|
||||
const [view, setView] = useState<'grid' | 'table'>('grid')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: products, isLoading } = useQuery({
|
||||
queryKey: ['products', { search, categoryFilter, hasCadFilter, materialsFilter }],
|
||||
queryFn: () => listProducts({
|
||||
q: search || undefined,
|
||||
category_key: categoryFilter || undefined,
|
||||
has_cad: hasCadFilter === 'yes' ? true : hasCadFilter === 'no' ? false : undefined,
|
||||
materials_filter: materialsFilter || undefined,
|
||||
limit: 200,
|
||||
}),
|
||||
})
|
||||
|
||||
// ── Selection helpers ──────────────────────────────────────────────────
|
||||
const toggleOne = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev)
|
||||
n.has(id) ? n.delete(id) : n.add(id)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
const allSelected =
|
||||
!!products?.length && products.every((p) => selected.has(p.id))
|
||||
|
||||
const toggleAll = () => {
|
||||
if (!products) return
|
||||
setSelected(allSelected ? new Set() : new Set(products.map((p) => p.id)))
|
||||
}
|
||||
|
||||
// ── Bulk delete ────────────────────────────────────────────────────────
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
await Promise.all(ids.map((id) => deleteProduct(id, true)))
|
||||
},
|
||||
onSuccess: (_, ids) => {
|
||||
toast.success(`${ids.length} product${ids.length > 1 ? 's' : ''} deleted`)
|
||||
setSelected(new Set())
|
||||
qc.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
|
||||
})
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`Delete ${ids.length} product${ids.length > 1 ? 's' : ''}? This cannot be undone.`)) return
|
||||
deleteMut.mutate(ids)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Library size={22} className="text-accent" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-content">Product Library</h1>
|
||||
<p className="text-sm text-content-muted">
|
||||
{products ? `${products.length} products` : 'Loading\u2026'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex border border-border-default rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => setView('grid')}
|
||||
title="Grid view"
|
||||
className={`px-2.5 py-1.5 text-sm flex items-center transition-colors ${
|
||||
view === 'grid'
|
||||
? 'text-white'
|
||||
: 'bg-surface text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
style={view === 'grid' ? { backgroundColor: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('table')}
|
||||
title="Table view"
|
||||
className={`px-2.5 py-1.5 text-sm flex items-center transition-colors ${
|
||||
view === 'table'
|
||||
? 'text-white'
|
||||
: 'bg-surface text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
style={view === 'table' ? { backgroundColor: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48 max-w-sm">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by PIM-ID or name\u2026"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none"
|
||||
title="Filter by product category (TRB, Kugellager, CRB, etc.)"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={hasCadFilter}
|
||||
onChange={(e) => setHasCadFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none"
|
||||
title="Filter by STEP file status — only products with an uploaded STEP file can be rendered"
|
||||
>
|
||||
<option value="">All CAD status</option>
|
||||
<option value="yes">Has STEP</option>
|
||||
<option value="no">No STEP</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={materialsFilter}
|
||||
onChange={(e) => setMaterialsFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none"
|
||||
title="Filter by material assignment status — complete = all CAD parts have a material assigned"
|
||||
>
|
||||
<option value="">All materials</option>
|
||||
<option value="complete">✓ All materials assigned</option>
|
||||
<option value="incomplete">⚠ Incomplete materials</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-16 text-content-muted">Loading products\u2026</div>
|
||||
) : !products?.length ? (
|
||||
<div className="text-center py-16 text-content-muted">
|
||||
<Library size={48} className="mx-auto mb-3 opacity-30" />
|
||||
<p>No products found</p>
|
||||
<p className="text-sm mt-1">Upload an Excel file to populate the library</p>
|
||||
</div>
|
||||
) : view === 'grid' ? (
|
||||
/* ── Grid view ─────────────────────────────────────────────────── */
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onClick={() => navigate(`/products/${product.id}`)}
|
||||
selected={selected.has(product.id)}
|
||||
onSelect={() => toggleOne(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ── Table view ────────────────────────────────────────────────── */
|
||||
<div className="card overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-default text-left bg-surface-alt">
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
title="Select / deselect all visible products"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 w-16"></th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">PIM-ID</th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">Name</th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">Category</th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">Baureihe</th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">CAD Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((product) => (
|
||||
<tr
|
||||
key={product.id}
|
||||
className={`border-b border-border-light hover:bg-surface-hover cursor-pointer transition-colors ${
|
||||
selected.has(product.id) ? 'bg-status-success-bg' : ''
|
||||
}`}
|
||||
onClick={() => navigate(`/products/${product.id}`)}
|
||||
>
|
||||
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(product.id)}
|
||||
onChange={() => toggleOne(product.id)}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="w-10 h-10 bg-surface-muted rounded flex items-center justify-center overflow-hidden">
|
||||
{(product.render_image_url || product.thumbnail_url) ? (
|
||||
<img
|
||||
src={product.render_image_url || product.thumbnail_url!}
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Box size={18} className="text-content-muted" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-content-secondary">
|
||||
{product.pim_id}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium text-content max-w-48 truncate" title={product.name || ''}>
|
||||
{product.name || <span className="text-content-muted italic">no name</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{product.category_key && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
|
||||
{CATEGORY_LABELS[product.category_key] || product.category_key}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-content-secondary text-xs max-w-40 truncate" title={product.baureihe || ''}>
|
||||
{product.baureihe || '\u2014'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<CadStatusChip status={product.processing_status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Floating action bar ───────────────────────────────────────── */}
|
||||
{selected.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] bg-gray-900 text-white rounded-lg shadow-xl px-5 py-3 flex items-center gap-4 z-50">
|
||||
<span className="text-sm font-medium">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={deleteMut.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{deleteMut.isPending ? 'Deleting\u2026' : 'Delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="flex items-center gap-1 px-2 py-1.5 text-gray-400 hover:text-white text-sm transition-colors"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X size={14} /> Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
FileSpreadsheet, CheckCircle, X, Plus,
|
||||
Info, PackagePlus, PackageCheck, Ban, FileBox, ArrowRight, Copy,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { uploadExcel, finalizeExcelImport } from '../api/uploads'
|
||||
import type { ExcelPreviewResult, OutputTypeSelection } from '../api/uploads'
|
||||
import { listOutputTypes } from '../api/outputTypes'
|
||||
import type { OutputType } from '../api/outputTypes'
|
||||
import api from '../api/client'
|
||||
import StepDropzone from '../components/upload/StepDropzone'
|
||||
|
||||
function StatCard({ icon, value, label, description, color }: {
|
||||
icon: React.ReactNode
|
||||
value: number
|
||||
label: string
|
||||
description: string
|
||||
color: string
|
||||
}) {
|
||||
if (value === 0) return null
|
||||
return (
|
||||
<div className={`flex gap-3 p-3 rounded-lg border ${color}`}>
|
||||
<div className="shrink-0 mt-0.5">{icon}</div>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-lg font-bold">{value}</span>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-75 mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
// Step 1: Excel parsed (preview only — no products created yet)
|
||||
const [previewResult, setPreviewResult] = useState<ExcelPreviewResult | null>(null)
|
||||
// Step 2: per-row include toggles
|
||||
const [includedRows, setIncludedRows] = useState<Record<number, boolean>>({})
|
||||
// Step 3: per-row selected output_type_ids
|
||||
const [rowOutputTypes, setRowOutputTypes] = useState<Record<number, Record<string, boolean>>>({})
|
||||
const [step, setStep] = useState<1 | 2 | 3 | 4>(1)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [createdOrder, setCreatedOrder] = useState<{ id: string; order_number: string } | null>(null)
|
||||
const [createDraftForSkipped, setCreateDraftForSkipped] = useState(false)
|
||||
|
||||
const { data: outputTypes = [] } = useQuery<OutputType[]>({
|
||||
queryKey: ['output-types'],
|
||||
queryFn: () => listOutputTypes(false),
|
||||
})
|
||||
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ['templates'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/templates')
|
||||
return res.data as Array<{ id: string; category_key: string; name: string }>
|
||||
},
|
||||
})
|
||||
|
||||
const uploadMut = useMutation({
|
||||
mutationFn: uploadExcel,
|
||||
onSuccess: (data) => {
|
||||
setPreviewResult(data)
|
||||
// Default: include all rows that have an identifier (pim_id or produkt_baureihe)
|
||||
// Pre-uncheck duplicate rows so only the first occurrence is included
|
||||
const inc: Record<number, boolean> = {}
|
||||
const rot: Record<number, Record<string, boolean>> = {}
|
||||
data.rows.forEach((row) => {
|
||||
const hasId = !!(row.pim_id || row.produkt_baureihe)
|
||||
inc[row.row_index] = hasId && !row.is_duplicate
|
||||
rot[row.row_index] = {}
|
||||
})
|
||||
setIncludedRows(inc)
|
||||
setRowOutputTypes(rot)
|
||||
data.warnings.forEach((w) => toast.warning(w))
|
||||
setStep(2)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'),
|
||||
})
|
||||
|
||||
const finalizeMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!previewResult) throw new Error('No preview result')
|
||||
const templateId = templates?.find(
|
||||
(t) => t.category_key === previewResult.category_key,
|
||||
)?.id
|
||||
|
||||
const included_row_indices: number[] = []
|
||||
const output_type_selections: OutputTypeSelection[] = []
|
||||
|
||||
previewResult.rows.forEach((row) => {
|
||||
if (!includedRows[row.row_index]) return
|
||||
if (!row.pim_id && !row.produkt_baureihe) return
|
||||
|
||||
included_row_indices.push(row.row_index)
|
||||
|
||||
const selectedTypes = rowOutputTypes[row.row_index] || {}
|
||||
const typeIds = Object.entries(selectedTypes)
|
||||
.filter(([, checked]) => checked)
|
||||
.map(([id]) => id)
|
||||
|
||||
if (typeIds.length > 0) {
|
||||
output_type_selections.push({
|
||||
row_index: row.row_index,
|
||||
output_type_ids: typeIds,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const primaryOrder = await finalizeExcelImport({
|
||||
excel_path: previewResult.excel_path,
|
||||
included_row_indices,
|
||||
output_type_selections,
|
||||
notes: notes || undefined,
|
||||
template_id: templateId,
|
||||
})
|
||||
|
||||
// Optionally create a tracking-only draft for the unchecked rows
|
||||
let draftOrder = null
|
||||
if (createDraftForSkipped && excludedWithId.length > 0) {
|
||||
const skippedIndices = excludedWithId.map((r) => r.row_index)
|
||||
draftOrder = await finalizeExcelImport({
|
||||
excel_path: previewResult.excel_path,
|
||||
included_row_indices: skippedIndices,
|
||||
output_type_selections: [],
|
||||
notes: 'Draft — awaiting STEP files',
|
||||
template_id: templateId,
|
||||
})
|
||||
}
|
||||
|
||||
return { primaryOrder, draftOrder }
|
||||
},
|
||||
onSuccess: ({ primaryOrder, draftOrder }) => {
|
||||
qc.invalidateQueries({ queryKey: ['orders'] })
|
||||
setCreatedOrder({ id: primaryOrder.id, order_number: primaryOrder.order_number })
|
||||
if (draftOrder) {
|
||||
const n = draftOrder.items?.length ?? 0
|
||||
toast.success(
|
||||
`Draft ${draftOrder.order_number} created with ${n} product${n !== 1 ? 's' : ''} awaiting STEP files`,
|
||||
)
|
||||
}
|
||||
setStep(4)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create order'),
|
||||
})
|
||||
|
||||
const onDrop = useCallback(
|
||||
(files: File[]) => { if (files[0]) uploadMut.mutate(files[0]) },
|
||||
[uploadMut],
|
||||
)
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
},
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
// Rows that are included and have an identifier
|
||||
const includedWithId = previewResult?.rows.filter(
|
||||
(r) => includedRows[r.row_index] && (r.pim_id || r.produkt_baureihe),
|
||||
) ?? []
|
||||
|
||||
// Rows that are excluded (have an identifier but not included in the primary order)
|
||||
const excludedWithId = previewResult?.rows.filter(
|
||||
(r) => !includedRows[r.row_index] && (r.pim_id || r.produkt_baureihe),
|
||||
) ?? []
|
||||
|
||||
// Count how many rows actually have an output type selected
|
||||
const rowsWithOutputType = includedWithId.filter((row) => {
|
||||
const sel = rowOutputTypes[row.row_index] || {}
|
||||
return Object.values(sel).some(Boolean)
|
||||
}).length
|
||||
|
||||
function toggleAllOutputType(typeId: string, checked: boolean) {
|
||||
setRowOutputTypes((prev) => {
|
||||
const updated = { ...prev }
|
||||
includedWithId.forEach((row) => {
|
||||
updated[row.row_index] = { ...(updated[row.row_index] || {}), [typeId]: checked }
|
||||
})
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
function deselectWithoutStep() {
|
||||
if (!previewResult) return
|
||||
setIncludedRows((prev) => {
|
||||
const updated = { ...prev }
|
||||
previewResult.rows.forEach((row) => {
|
||||
if (!row.has_step && (row.pim_id || row.produkt_baureihe)) {
|
||||
updated[row.row_index] = false
|
||||
}
|
||||
})
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-full mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-content">Upload Order List</h1>
|
||||
<p className="text-sm text-content-muted mt-1">
|
||||
Import products from Excel and create a new output job order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Step 1: Excel drop zone ─────────────────────────────────────── */}
|
||||
{step === 1 && (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`card p-16 text-center cursor-pointer border-2 border-dashed transition-colors ${
|
||||
isDragActive
|
||||
? 'border-accent bg-accent-light'
|
||||
: 'border-border-default hover:border-accent'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{uploadMut.isPending ? (
|
||||
<div className="text-content-secondary">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full mx-auto mb-3" />
|
||||
Parsing Excel file\u2026
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FileSpreadsheet size={44} className="text-content-muted mx-auto mb-3" />
|
||||
<p className="text-content-secondary font-medium text-lg">
|
||||
{isDragActive ? 'Drop the Excel file here' : 'Drag & drop an Excel order list'}
|
||||
</p>
|
||||
<p className="text-content-muted text-sm mt-1">or click to browse \u2014 .xlsx / .xls</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Product Matching Report ─────────────────────────────── */}
|
||||
{step === 2 && previewResult && (
|
||||
<div className="space-y-4">
|
||||
{/* File info header */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle size={20} className="text-green-500 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-content truncate">{previewResult.filename}</p>
|
||||
<p className="text-sm text-content-secondary">
|
||||
{previewResult.row_count} rows parsed
|
||||
{previewResult.category_key && <> · Primary category: <strong>{previewResult.category_key}</strong></>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => { setPreviewResult(null); setStep(1) }}
|
||||
title="Discard this preview and upload a different Excel file"
|
||||
>
|
||||
<X size={13} /> Replace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import summary — explained stats */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Info size={15} className="text-content-muted" />
|
||||
<h3 className="text-sm font-semibold text-content-secondary">Preview Summary</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-content-secondary mb-3 leading-relaxed">
|
||||
No products have been created yet. This is a <strong>preview</strong> of what will happen when you finalize the order.
|
||||
Each unique <strong>Produkt (Baureihe)</strong> in the Excel becomes one product in the library.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<StatCard
|
||||
icon={<PackagePlus size={18} className="text-blue-600" />}
|
||||
value={previewResult.new_product_count}
|
||||
label="new products"
|
||||
description="Will be created when you finalize the order."
|
||||
color="bg-status-info-bg border-border-default text-status-info-text"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<PackageCheck size={18} className="text-green-600" />}
|
||||
value={previewResult.existing_product_count}
|
||||
label="existing products"
|
||||
description="Already in the library from a previous import."
|
||||
color="bg-status-success-bg border-border-default text-status-success-text"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Ban size={18} className="text-amber-600" />}
|
||||
value={previewResult.no_pim_id_count}
|
||||
label="rows skipped"
|
||||
description="No PIM-ID or Baureihe found. Cannot be matched to a product."
|
||||
color="bg-status-warning-bg border-border-default text-status-warning-text"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Copy size={18} className="text-orange-600" />}
|
||||
value={previewResult.duplicate_count}
|
||||
label="duplicate Baureihe"
|
||||
description="Same Produkt-Baureihe appears multiple times. Pre-unchecked — only first occurrence imported."
|
||||
color="bg-status-warning-bg border-border-default text-status-warning-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate warning banner */}
|
||||
{previewResult.duplicate_count > 0 && (
|
||||
<div className="rounded-lg border border-border-default bg-status-warning-bg px-4 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-status-warning-text font-bold text-sm shrink-0">⚠</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-status-warning-text">
|
||||
{previewResult.duplicate_count} duplicate Produkt-Baureihe row{previewResult.duplicate_count !== 1 ? 's' : ''} detected
|
||||
</p>
|
||||
<p className="text-xs text-status-warning-text mt-0.5">
|
||||
Each product is unique — only the <strong>first occurrence</strong> of a Baureihe will be imported.
|
||||
Duplicate rows are pre-unchecked (shown in amber). You can manually re-check them to overwrite the first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row table */}
|
||||
<div className="card overflow-auto">
|
||||
<div className="px-4 py-3 border-b border-border-light bg-surface-alt">
|
||||
<h3 className="text-sm font-semibold text-content-secondary">Row Details</h3>
|
||||
<p className="text-xs text-content-secondary mt-0.5">
|
||||
Uncheck rows you don't want to include in the order.
|
||||
</p>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-light text-left">
|
||||
<th className="px-4 py-2 font-medium text-content-secondary w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={previewResult.rows.every((r) => (!r.pim_id && !r.produkt_baureihe) || includedRows[r.row_index])}
|
||||
onChange={(e) => {
|
||||
const updated: Record<number, boolean> = {}
|
||||
previewResult.rows.forEach((r) => {
|
||||
updated[r.row_index] = (r.pim_id || r.produkt_baureihe) ? e.target.checked : false
|
||||
})
|
||||
setIncludedRows(updated)
|
||||
}}
|
||||
title="Select / deselect all rows"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-2 font-medium text-content-secondary">PIM-ID</th>
|
||||
<th className="px-4 py-2 font-medium text-content-secondary">Baureihe</th>
|
||||
<th className="px-4 py-2 font-medium text-content-secondary"
|
||||
title="Gew\u00e4hltes Produkt \u2014 the specific material/coating variant from the Excel"
|
||||
>Gew. Produkt</th>
|
||||
<th className="px-4 py-2 font-medium text-content-secondary">Category</th>
|
||||
<th className="px-4 py-2 font-medium text-content-secondary">Status</th>
|
||||
<th className="px-4 py-2 font-medium text-content-secondary text-center" title="Whether a STEP/CAD file is already linked to this product">STEP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewResult.rows.map((row) => {
|
||||
const hasId = !!(row.pim_id || row.produkt_baureihe)
|
||||
return (
|
||||
<tr key={row.row_index} className={`border-b ${row.is_duplicate ? 'bg-status-warning-bg border-border-default hover:bg-status-warning-bg' : 'border-border-light hover:bg-surface-hover'}`}>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!hasId}
|
||||
checked={!!includedRows[row.row_index]}
|
||||
onChange={(e) =>
|
||||
setIncludedRows({ ...includedRows, [row.row_index]: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">
|
||||
{row.pim_id || <span className="text-content-muted">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{row.produkt_baureihe || <span className="text-content-muted">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm">
|
||||
{row.gewaehltes_produkt || '\u2014'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.category_key ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
|
||||
{row.category_key}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-content-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{!hasId ? (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted"
|
||||
title="No PIM-ID or Baureihe — this row will be skipped"
|
||||
>
|
||||
skipped
|
||||
</span>
|
||||
) : row.is_duplicate ? (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-text font-medium"
|
||||
title={`Duplicate Produkt-Baureihe — first occurrence is row ${row.duplicate_of_row}. Uncheck to exclude.`}
|
||||
>
|
||||
Duplicate of row {row.duplicate_of_row}
|
||||
</span>
|
||||
) : row.product_exists ? (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text font-medium"
|
||||
title="This product already exists in the library"
|
||||
>
|
||||
existing
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text font-medium"
|
||||
title="This product will be created when you finalize"
|
||||
>
|
||||
new (will be created)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{!hasId ? null : row.has_step ? (
|
||||
<CheckCircle size={14} className="text-green-500 mx-auto" title="STEP file linked" />
|
||||
) : (
|
||||
<X size={14} className="text-red-400 mx-auto" title="No STEP file" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={includedWithId.length === 0}
|
||||
onClick={() => setStep(3)}
|
||||
>
|
||||
Next: Select Output Types →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Upload STEP Files ────────────────────────────────────── */}
|
||||
{step === 4 && createdOrder && (
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileBox size={18} className="text-content-secondary" />
|
||||
<h2 className="font-semibold text-content">
|
||||
Upload STEP Files — {createdOrder.order_number}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-content-secondary">
|
||||
Drop one or more <strong>.stp / .step</strong> files below.
|
||||
Each file is matched to an order item by filename stem (case-insensitive).
|
||||
You can also skip this and upload STEP files later from the order detail page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<StepDropzone
|
||||
orderId={createdOrder.id}
|
||||
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => navigate(`/orders/${createdOrder.id}`)}
|
||||
>
|
||||
Skip — Go to Order
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => navigate(`/orders/${createdOrder.id}`)}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
Done — Go to Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Output Type Selection ───────────────────────────────── */}
|
||||
{step === 3 && previewResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="font-semibold text-content">Select Output Types</h2>
|
||||
<button className="text-sm text-content-muted hover:text-content-secondary" onClick={() => setStep(2)}>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-content-secondary">
|
||||
Choose which output types to request for each included product.
|
||||
Leave unchecked to create a tracking-only line.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{outputTypes.length > 0 && (
|
||||
<div className="card p-4">
|
||||
<p className="text-xs font-medium text-content-secondary mb-2">Select/deselect all rows:</p>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{outputTypes.map((ot) => (
|
||||
<div key={ot.id} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`all-ot-${ot.id}`}
|
||||
onChange={(e) => toggleAllOutputType(ot.id, e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={`all-ot-${ot.id}`} className="text-sm cursor-pointer">
|
||||
{ot.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{includedWithId.some((r) => !r.has_step) && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-red-200 text-red-700 bg-red-50 hover:bg-red-100"
|
||||
onClick={deselectWithoutStep}
|
||||
title="Uncheck all rows that have no STEP file linked"
|
||||
>
|
||||
<X size={12} />
|
||||
Deselect without STEP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-light text-left">
|
||||
<th className="px-4 py-2 font-medium text-content-secondary">PIM-ID</th>
|
||||
<th className="px-4 py-2 font-medium text-content-secondary">Product Name</th>
|
||||
<th className="px-4 py-2 font-medium text-content-secondary text-center" title="STEP file linked">STEP</th>
|
||||
{outputTypes.map((ot) => (
|
||||
<th key={ot.id} className="px-4 py-2 font-medium text-content-secondary text-center">
|
||||
{ot.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{includedWithId.map((row) => (
|
||||
<tr key={row.row_index} className="border-b border-border-light hover:bg-surface-hover">
|
||||
<td className="px-4 py-2 font-mono text-xs">{row.pim_id || '\u2014'}</td>
|
||||
<td className="px-4 py-2">{row.gewaehltes_produkt || row.produkt_baureihe || '\u2014'}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{row.has_step ? (
|
||||
<CheckCircle size={14} className="text-green-500 mx-auto" title="STEP file linked" />
|
||||
) : (
|
||||
<X size={14} className="text-red-400 mx-auto" title="No STEP file" />
|
||||
)}
|
||||
</td>
|
||||
{outputTypes.map((ot) => (
|
||||
<td key={ot.id} className="px-4 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!(rowOutputTypes[row.row_index]?.[ot.id])}
|
||||
onChange={(e) =>
|
||||
setRowOutputTypes((prev) => ({
|
||||
...prev,
|
||||
[row.row_index]: {
|
||||
...(prev[row.row_index] || {}),
|
||||
[ot.id]: e.target.checked,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rowsWithOutputType === 0 && includedWithId.length > 0 && (
|
||||
<div className="rounded-lg border border-border-default bg-status-warning-bg px-4 py-3 text-sm text-status-warning-text">
|
||||
<strong>No output types selected.</strong> This order will be created with tracking-only lines —
|
||||
no rendering will be dispatched until output types are added to the order. You can add
|
||||
output types later from the order detail page.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{excludedWithId.length > 0 && (
|
||||
<div className="rounded-lg border border-border-default bg-status-info-bg px-4 py-3">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 shrink-0"
|
||||
checked={createDraftForSkipped}
|
||||
onChange={(e) => setCreateDraftForSkipped(e.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-status-info-text">
|
||||
Also create a draft for the {excludedWithId.length} skipped product{excludedWithId.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<p className="text-xs text-status-info-text mt-0.5">
|
||||
A separate tracking-only draft order will be created for unchecked rows.
|
||||
Upload STEP files there once available, then assign output types.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">
|
||||
Order Notes <span className="text-content-muted font-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
placeholder="Add any notes for this order\u2026"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary shrink-0"
|
||||
onClick={() => finalizeMut.mutate()}
|
||||
disabled={finalizeMut.isPending || includedWithId.length === 0}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{finalizeMut.isPending
|
||||
? 'Creating\u2026'
|
||||
: createDraftForSkipped && excludedWithId.length > 0
|
||||
? `Create 2 Orders (${includedWithId.length} + ${excludedWithId.length} products)`
|
||||
: `Create Order (${includedWithId.length} products)`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw,
|
||||
ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image,
|
||||
ExternalLink, Trash2, Ban, ListOrdered,
|
||||
} from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
getWorkerActivity, reprocessCadFile, CadActivityEntry, RenderLog, RenderJobEntry,
|
||||
getQueueStatus, purgeQueue, cancelTask, QueueTask,
|
||||
} from '../api/worker'
|
||||
import LiveRenderLog from '../components/LiveRenderLog'
|
||||
|
||||
export default function WorkerActivityPage() {
|
||||
const qc = useQueryClient()
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data, isLoading, dataUpdatedAt } = useQuery({
|
||||
queryKey: ['worker-activity'],
|
||||
queryFn: getWorkerActivity,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const reprocessMut = useMutation({
|
||||
mutationFn: reprocessCadFile,
|
||||
onSuccess: () => {
|
||||
toast.success('Re-queued for full reprocessing')
|
||||
qc.invalidateQueries({ queryKey: ['worker-activity'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
|
||||
})
|
||||
|
||||
const lastUpdated = dataUpdatedAt
|
||||
? new Date(dataUpdatedAt).toLocaleTimeString('de-DE')
|
||||
: '—'
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((s) => {
|
||||
const n = new Set(s)
|
||||
n.has(id) ? n.delete(id) : n.add(id)
|
||||
return n
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity size={22} className="text-accent" />
|
||||
<h1 className="text-2xl font-bold text-content">Worker Activity</h1>
|
||||
<span className="ml-auto flex items-center gap-2 text-xs text-content-muted">
|
||||
Auto-refresh every 5 s · Last: {lastUpdated}
|
||||
<button
|
||||
onClick={() => qc.invalidateQueries({ queryKey: ['worker-activity'] })}
|
||||
className="p-1 rounded hover:bg-surface-muted"
|
||||
title="Refresh now"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Queue panel */}
|
||||
<QueuePanel />
|
||||
|
||||
{/* Summary */}
|
||||
{data && (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-4">
|
||||
<StatCard label="CAD files" value={data.cad_processing.length} color="text-content-secondary" />
|
||||
<StatCard label="CAD processing" value={data.active_count}
|
||||
color={data.active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} />
|
||||
<StatCard label="CAD failed" value={data.failed_count}
|
||||
color={data.failed_count > 0 ? 'text-red-600' : 'text-content-secondary'} />
|
||||
<StatCard label="Render jobs" value={data.render_jobs.length} color="text-content-secondary" />
|
||||
<StatCard label="Rendering" value={data.render_active_count}
|
||||
color={data.render_active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} />
|
||||
<StatCard label="Render failed" value={data.render_failed_count}
|
||||
color={data.render_failed_count > 0 ? 'text-red-600' : 'text-content-secondary'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-content-muted py-12 justify-center">
|
||||
<Loader2 size={18} className="animate-spin" /> Loading activity…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.cad_processing.length === 0 && !isLoading && (
|
||||
<div className="card p-12 text-center text-content-muted">
|
||||
<Activity size={32} className="mx-auto mb-3 text-content-muted" />
|
||||
<p className="font-medium">No recent activity</p>
|
||||
<p className="text-sm mt-1">STEP file processing jobs will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Render Jobs ─────────────────────────────────────────────────── */}
|
||||
{data && data.render_jobs.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-content-muted uppercase tracking-wide mb-2">Render Jobs</h2>
|
||||
<div className="card overflow-hidden divide-y divide-border-light">
|
||||
{data.render_jobs.map((job) => (
|
||||
<RenderJobRow key={job.order_line_id} job={job} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CAD File Processing ──────────────────────────────────────────── */}
|
||||
{data && data.cad_processing.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-content-muted uppercase tracking-wide mb-2">CAD File Processing</h2>
|
||||
</div>
|
||||
)}
|
||||
{data && data.cad_processing.length > 0 && (
|
||||
<div className="card overflow-hidden divide-y divide-border-light">
|
||||
{data.cad_processing.map((entry) => (
|
||||
<div key={entry.cad_file_id}>
|
||||
{/* ── Main row ── */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover cursor-pointer select-none"
|
||||
onClick={() => toggle(entry.cad_file_id)}
|
||||
>
|
||||
{/* Expand toggle */}
|
||||
<span className="text-content-muted shrink-0">
|
||||
{expanded.has(entry.cad_file_id)
|
||||
? <ChevronDown size={15} />
|
||||
: <ChevronRight size={15} />}
|
||||
</span>
|
||||
|
||||
{/* Status icon */}
|
||||
<StatusIcon status={entry.processing_status} />
|
||||
|
||||
{/* File name */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-sm text-content truncate" title={entry.original_name}>
|
||||
{entry.original_name}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
{entry.order_numbers.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{entry.order_numbers.map((n) => (
|
||||
<span key={n} className="text-xs font-medium bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{entry.file_size != null && (
|
||||
<span className="text-xs text-content-muted">{formatBytes(entry.file_size)}</span>
|
||||
)}
|
||||
{entry.render_log?.renderer && (
|
||||
<RendererBadge log={entry.render_log} />
|
||||
)}
|
||||
{entry.render_log?.total_duration_s != null && (
|
||||
<span className="text-xs text-content-muted flex items-center gap-1">
|
||||
<Clock size={11} />{entry.render_log.total_duration_s}s total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.error_message && (
|
||||
<p className="text-xs text-red-500 mt-0.5 truncate" title={entry.error_message}>
|
||||
{entry.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-xs text-content-muted shrink-0 text-right hidden sm:block">
|
||||
<p>{new Date(entry.updated_at).toLocaleDateString('de-DE')}</p>
|
||||
<p>{new Date(entry.updated_at).toLocaleTimeString('de-DE')}</p>
|
||||
</div>
|
||||
|
||||
{/* Re-process button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
reprocessMut.mutate(entry.cad_file_id)
|
||||
}}
|
||||
disabled={reprocessMut.isPending}
|
||||
title="Re-convert STEP + regenerate thumbnail"
|
||||
className="shrink-0 p-1.5 rounded text-content-muted hover:text-accent hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Expanded detail panel ── */}
|
||||
{expanded.has(entry.cad_file_id) && (
|
||||
<div className="bg-surface-alt border-t border-border-light px-6 py-4 space-y-4">
|
||||
<RenderDetails entry={entry} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Queue panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
function shortName(taskName: string): string {
|
||||
// "app.tasks.step_tasks.regenerate_thumbnail" → "regenerate_thumbnail"
|
||||
const parts = taskName.split('.')
|
||||
return parts[parts.length - 1] ?? taskName
|
||||
}
|
||||
|
||||
function firstArg(task: QueueTask): string {
|
||||
const a = task.args?.[0]
|
||||
if (!a) return '—'
|
||||
const s = String(a)
|
||||
return s.length > 28 ? s.slice(0, 12) + '…' + s.slice(-8) : s
|
||||
}
|
||||
|
||||
function QueuePanel() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: queue, isLoading } = useQuery({
|
||||
queryKey: ['worker-queue'],
|
||||
queryFn: getQueueStatus,
|
||||
refetchInterval: 3000,
|
||||
})
|
||||
|
||||
const purgeMut = useMutation({
|
||||
mutationFn: purgeQueue,
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message)
|
||||
qc.invalidateQueries({ queryKey: ['worker-queue'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Purge failed'),
|
||||
})
|
||||
|
||||
const cancelMut = useMutation({
|
||||
mutationFn: cancelTask,
|
||||
onSuccess: () => {
|
||||
toast.success('Task revoked')
|
||||
qc.invalidateQueries({ queryKey: ['worker-queue'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
|
||||
})
|
||||
|
||||
const totalPending = queue?.pending_count ?? 0
|
||||
const activeCount = queue?.active.length ?? 0
|
||||
const reservedCount = queue?.reserved.length ?? 0
|
||||
const isEmpty = totalPending === 0 && activeCount === 0 && reservedCount === 0
|
||||
|
||||
// Group pending by task name for summary
|
||||
const pendingGroups: Record<string, number> = {}
|
||||
for (const t of queue?.pending ?? []) {
|
||||
const name = shortName(t.task_name)
|
||||
pendingGroups[name] = (pendingGroups[name] ?? 0) + 1
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border-light">
|
||||
<ListOrdered size={15} className="text-content-muted" />
|
||||
<h2 className="text-sm font-semibold text-content-secondary flex-1">Celery Queue</h2>
|
||||
<button
|
||||
onClick={() => qc.invalidateQueries({ queryKey: ['worker-queue'] })}
|
||||
className="p-1 rounded hover:bg-surface-muted text-content-muted"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={13} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
{totalPending > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Purge all ${totalPending} pending task(s) from the queue?`)) {
|
||||
purgeMut.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={purgeMut.isPending}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded border border-red-200 text-red-600 text-xs font-medium hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Purge all ({totalPending})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{/* Summary chips */}
|
||||
<div className="flex items-center gap-3 text-xs flex-wrap">
|
||||
<span className={`font-semibold ${totalPending > 0 ? 'text-status-warning-text' : 'text-content-muted'}`}>
|
||||
{totalPending} pending
|
||||
</span>
|
||||
<span className="text-content-muted">·</span>
|
||||
<span className={`font-semibold ${activeCount > 0 ? 'text-status-info-text' : 'text-content-muted'}`}>
|
||||
{activeCount} active
|
||||
</span>
|
||||
<span className="text-content-muted">·</span>
|
||||
<span className="text-content-muted">{reservedCount} reserved</span>
|
||||
{queue?.queue_depths && Object.entries(queue.queue_depths).map(([q, n]) => n > 0 && (
|
||||
<span key={q} className="ml-1 px-1.5 py-0.5 rounded bg-status-warning-bg border border-border-default text-status-warning-text font-mono">
|
||||
{q}: {n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEmpty && !isLoading && (
|
||||
<p className="text-xs text-content-muted py-1">Queue is empty — no pending or active tasks.</p>
|
||||
)}
|
||||
|
||||
{/* Active tasks */}
|
||||
{(queue?.active.length ?? 0) > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">Active</p>
|
||||
<div className="space-y-1">
|
||||
{queue!.active.map((t) => (
|
||||
<div key={t.task_id} className="flex items-center gap-2 text-xs rounded-md bg-status-info-bg border border-border-default px-3 py-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse shrink-0" />
|
||||
<span className="font-medium text-status-info-text shrink-0">{shortName(t.task_name)}</span>
|
||||
<span className="text-status-info-text font-mono truncate flex-1">{firstArg(t)}</span>
|
||||
{t.worker && (
|
||||
<span className="text-status-info-text truncate max-w-[120px]">{t.worker.split('@')[0]}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => cancelMut.mutate(t.task_id)}
|
||||
disabled={cancelMut.isPending}
|
||||
title="Cancel (revoke + terminate)"
|
||||
className="shrink-0 p-0.5 rounded text-status-info-text hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Ban size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reserved tasks */}
|
||||
{(queue?.reserved.length ?? 0) > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">Reserved (prefetched)</p>
|
||||
<div className="space-y-1">
|
||||
{queue!.reserved.map((t) => (
|
||||
<div key={t.task_id} className="flex items-center gap-2 text-xs rounded-md bg-surface-alt border border-border-default px-3 py-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-gray-400 shrink-0" />
|
||||
<span className="font-medium text-content-secondary shrink-0">{shortName(t.task_name)}</span>
|
||||
<span className="text-content-secondary font-mono truncate flex-1">{firstArg(t)}</span>
|
||||
<button
|
||||
onClick={() => cancelMut.mutate(t.task_id)}
|
||||
disabled={cancelMut.isPending}
|
||||
title="Cancel (revoke)"
|
||||
className="shrink-0 p-0.5 rounded text-content-muted hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Ban size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending breakdown (grouped by task name) */}
|
||||
{totalPending > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">
|
||||
Pending ({totalPending}{totalPending > 100 ? ', showing first 100' : ''})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(pendingGroups).map(([name, count]) => (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border-default bg-status-warning-bg text-status-warning-text text-xs font-medium"
|
||||
>
|
||||
{name}
|
||||
<span className="bg-status-warning-bg text-status-warning-text rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none border border-border-default">
|
||||
{count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Render job row ───────────────────────────────────────────────────────────
|
||||
|
||||
function RenderJobRow({ job }: { job: RenderJobEntry }) {
|
||||
const elapsed = job.render_started_at && job.render_completed_at
|
||||
? ((new Date(job.render_completed_at).getTime() - new Date(job.render_started_at).getTime()) / 1000).toFixed(1)
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover">
|
||||
<StatusIcon status={job.render_status} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-content truncate">
|
||||
{job.product_name || 'Unknown product'}
|
||||
</span>
|
||||
{job.output_type_name && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-light text-accent font-medium">
|
||||
{job.output_type_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
{job.order_number && (
|
||||
<Link
|
||||
to={`/orders`}
|
||||
className="text-xs font-medium bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded hover:bg-surface-hover"
|
||||
>
|
||||
{job.order_number}
|
||||
</Link>
|
||||
)}
|
||||
{job.render_backend_used && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
|
||||
job.render_backend_used === 'flamenco'
|
||||
? 'bg-status-warning-bg text-status-warning-text'
|
||||
: 'bg-status-info-bg text-status-info-text'
|
||||
}`}>
|
||||
{job.render_backend_used === 'flamenco' ? 'Flamenco' : 'Celery'}
|
||||
</span>
|
||||
)}
|
||||
{job.flamenco_job_id && (
|
||||
<a
|
||||
href="http://localhost:8080"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-status-warning-text hover:text-status-warning-text flex items-center gap-0.5"
|
||||
>
|
||||
<ExternalLink size={10} /> Flamenco
|
||||
</a>
|
||||
)}
|
||||
{elapsed && (
|
||||
<span className="text-xs text-content-muted flex items-center gap-1">
|
||||
<Clock size={11} />{elapsed}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-content-muted shrink-0 text-right hidden sm:block">
|
||||
<p>{new Date(job.updated_at).toLocaleDateString('de-DE')}</p>
|
||||
<p>{new Date(job.updated_at).toLocaleTimeString('de-DE')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pb-1">
|
||||
<LiveRenderLog
|
||||
orderLineId={job.order_line_id}
|
||||
isActive={job.render_status === 'processing'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Render detail panel ──────────────────────────────────────────────────────
|
||||
|
||||
function RenderDetails({ entry }: { entry: CadActivityEntry }) {
|
||||
const log = entry.render_log
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* File info */}
|
||||
<Section icon={<Image size={13} />} title="File">
|
||||
<KVGrid>
|
||||
<KV label="Name" value={entry.original_name} mono />
|
||||
<KV label="Size" value={entry.file_size != null ? formatBytes(entry.file_size) : '—'} />
|
||||
<KV label="Status" value={entry.processing_status} />
|
||||
<KV label="Uploaded" value={new Date(entry.created_at).toLocaleString('de-DE')} />
|
||||
<KV label="Last updated" value={new Date(entry.updated_at).toLocaleString('de-DE')} />
|
||||
{entry.order_numbers.length > 0 && (
|
||||
<KV label="Orders" value={entry.order_numbers.join(', ')} />
|
||||
)}
|
||||
</KVGrid>
|
||||
{entry.error_message && (
|
||||
<div className="mt-2 rounded bg-red-50 border border-red-200 px-3 py-2">
|
||||
<p className="text-xs font-semibold text-red-600 mb-0.5">Error</p>
|
||||
<pre className="text-xs text-red-700 whitespace-pre-wrap break-words">{entry.error_message}</pre>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Render settings */}
|
||||
{log && (
|
||||
<Section icon={<Cpu size={13} />} title="Render settings">
|
||||
<KVGrid>
|
||||
<KV label="Renderer" value={log.renderer ?? '—'} />
|
||||
{log.renderer === 'blender' && <>
|
||||
<KV label="Engine" value={log.engine_used ?? log.engine ?? '—'} highlight={log.engine_used !== log.engine} />
|
||||
<KV label="Samples" value={log.samples?.toString() ?? '—'} />
|
||||
<KV label="Device" value={log.cycles_device ?? '—'} />
|
||||
<KV label="STL quality" value={log.stl_quality ?? '—'} />
|
||||
<KV label="Smooth angle" value={log.smooth_angle != null ? `${log.smooth_angle}°` : '—'} />
|
||||
<KV label="Resolution" value={log.width && log.height ? `${log.width}×${log.height}` : '—'} />
|
||||
</>}
|
||||
{log.renderer === 'threejs' && (
|
||||
<KV label="Resolution" value={log.width && log.height ? `${log.width}×${log.height}` : '—'} />
|
||||
)}
|
||||
<KV label="Output format" value={log.format ?? '—'} />
|
||||
{log.fallback && <KV label="Fallback" value="Yes (Pillow placeholder)" highlight />}
|
||||
</KVGrid>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Timing */}
|
||||
{log && (log.total_duration_s != null || log.stl_duration_s != null) && (
|
||||
<Section icon={<Clock size={13} />} title="Timing">
|
||||
<KVGrid>
|
||||
{log.total_duration_s != null && <KV label="Total" value={`${log.total_duration_s}s`} />}
|
||||
{log.stl_duration_s != null && <KV label="STEP→STL" value={`${log.stl_duration_s}s`} />}
|
||||
{log.render_duration_s != null && <KV label="Render" value={`${log.render_duration_s}s`} />}
|
||||
{log.stl_size_bytes != null && <KV label="STL size" value={formatBytes(log.stl_size_bytes)} />}
|
||||
{log.output_size_bytes != null && <KV label="PNG size" value={formatBytes(log.output_size_bytes)} />}
|
||||
{log.parts_count != null && <KV label="Mesh parts" value={log.parts_count.toString()} />}
|
||||
</KVGrid>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Blender log */}
|
||||
{log?.log_lines && log.log_lines.length > 0 && (
|
||||
<Section icon={<Terminal size={13} />} title={`Blender log (${log.log_lines.length} lines)`}>
|
||||
<BlenderLog lines={log.log_lines} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon, title, children,
|
||||
}: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 text-xs font-semibold text-content-muted uppercase tracking-wide mb-2">
|
||||
{icon}{title}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KVGrid({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-1.5">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KV({ label, value, mono, highlight }: {
|
||||
label: string; value: string; mono?: boolean; highlight?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-wide text-content-muted">{label}</span>
|
||||
<span className={`text-xs break-all ${mono ? 'font-mono' : ''} ${highlight ? 'text-status-warning-text font-medium' : 'text-content-secondary'}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlenderLog({ lines }: { lines: string[] }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-md overflow-auto max-h-64">
|
||||
<pre className="text-xs text-gray-200 p-3 leading-5 whitespace-pre-wrap">
|
||||
{lines.map((l, i) => {
|
||||
const color =
|
||||
l.includes('ERROR') || l.includes('failed') ? 'text-red-400' :
|
||||
l.includes('WARNING') || l.includes('warn') ? 'text-yellow-300' :
|
||||
l.includes('Saved:') || l.includes('render done') ? 'text-green-400' :
|
||||
l.includes('separated into') || l.includes('parts_count') ? 'text-cyan-400' :
|
||||
'text-gray-200'
|
||||
return (
|
||||
<span key={i} className={`block ${color}`}>{l}</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RendererBadge({ log }: { log: RenderLog }) {
|
||||
if (log.renderer === 'blender') {
|
||||
const eng = log.engine_used ?? log.engine ?? ''
|
||||
const label = eng.includes('fallback')
|
||||
? `Blender · Cycles (↩ fallback)`
|
||||
: `Blender · ${eng}`
|
||||
return (
|
||||
<span className="text-xs bg-status-info-bg text-status-info-text px-1.5 py-0.5 rounded font-medium">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (log.renderer === 'threejs') {
|
||||
return (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">
|
||||
Three.js
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="text-xs bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded font-medium">
|
||||
{log.renderer}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === 'completed') return <CheckCircle2 size={16} className="text-green-500 shrink-0" />
|
||||
if (status === 'failed') return <XCircle size={16} className="text-red-500 shrink-0" />
|
||||
if (status === 'processing') return <Loader2 size={16} className="animate-spin text-blue-500 shrink-0" />
|
||||
return <Clock size={16} className="text-content-muted shrink-0" />
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="card p-4 text-center">
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
<p className="text-xs text-content-muted mt-0.5">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
role: 'admin' | 'project_manager' | 'client'
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
setAuth: (token: string, user: User) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
setAuth: (token, user) => set({ token, user }),
|
||||
logout: () => set({ token: null, user: null }),
|
||||
}),
|
||||
{ name: 'schaeffler-auth' },
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
export type AccentKey = 'green' | 'blue' | 'purple' | 'amber' | 'teal'
|
||||
|
||||
export const ACCENT_PRESETS: { key: AccentKey; label: string; hex: string }[] = [
|
||||
{ key: 'green', label: 'Schaeffler Green', hex: '#00893d' },
|
||||
{ key: 'blue', label: 'Blue', hex: '#2563eb' },
|
||||
{ key: 'purple', label: 'Purple', hex: '#7c3aed' },
|
||||
{ key: 'amber', label: 'Amber', hex: '#d97706' },
|
||||
{ key: 'teal', label: 'Teal', hex: '#0d9488' },
|
||||
]
|
||||
|
||||
interface ThemeState {
|
||||
mode: ThemeMode
|
||||
accent: AccentKey
|
||||
setMode: (mode: ThemeMode) => void
|
||||
setAccent: (accent: AccentKey) => void
|
||||
}
|
||||
|
||||
/** Returns 'light' | 'dark' based on mode + system preference */
|
||||
export function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
|
||||
if (mode === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
/** Applies theme to <html> element */
|
||||
export function applyTheme(mode: ThemeMode, accent: AccentKey) {
|
||||
const resolved = resolveTheme(mode)
|
||||
const html = document.documentElement
|
||||
if (resolved === 'dark') {
|
||||
html.classList.add('dark')
|
||||
} else {
|
||||
html.classList.remove('dark')
|
||||
}
|
||||
html.setAttribute('data-accent', accent)
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
mode: 'light',
|
||||
accent: 'green',
|
||||
setMode: (mode) => {
|
||||
set({ mode })
|
||||
applyTheme(mode, get().accent)
|
||||
},
|
||||
setAccent: (accent) => {
|
||||
set({ accent })
|
||||
applyTheme(get().mode, accent)
|
||||
},
|
||||
}),
|
||||
{ name: 'schaeffler-theme' },
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MatchStatus =
|
||||
| 'unmatched' // STEP file uploaded but no order item linked yet
|
||||
| 'matched' // STEP file linked to one or more order items
|
||||
| 'processing' // Celery is currently processing the STEP file
|
||||
| 'done' // Processing complete (thumbnail + glTF available)
|
||||
| 'error' // Processing failed
|
||||
|
||||
export interface PendingUpload {
|
||||
/** UUID returned by POST /api/uploads/step */
|
||||
cad_file_id: string
|
||||
/** Original filename as shown to the user */
|
||||
original_name: string
|
||||
/** SHA-256 hash of the file content (for deduplication display) */
|
||||
file_hash: string
|
||||
/** Whether this was a fresh upload or already existed in the DB */
|
||||
upload_status: 'uploaded' | 'already_exists'
|
||||
/** Current match state with order items */
|
||||
match_status: MatchStatus
|
||||
/** IDs of order items that reference this CAD file */
|
||||
matched_item_ids: string[]
|
||||
/** ISO timestamp of the upload action */
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
interface UploadState {
|
||||
/** All STEP uploads tracked in this browser session */
|
||||
pendingUploads: PendingUpload[]
|
||||
|
||||
/** Add or update a tracked upload (keyed by cad_file_id) */
|
||||
addUpload: (upload: Omit<PendingUpload, 'matched_item_ids' | 'match_status' | 'uploaded_at'>) => void
|
||||
|
||||
/** Update the match status of a tracked upload */
|
||||
setMatchStatus: (cad_file_id: string, status: MatchStatus) => void
|
||||
|
||||
/** Record that a CAD file has been linked to an order item */
|
||||
addMatchedItem: (cad_file_id: string, item_id: string) => void
|
||||
|
||||
/** Remove a linked order item from a tracked upload */
|
||||
removeMatchedItem: (cad_file_id: string, item_id: string) => void
|
||||
|
||||
/** Remove a tracked upload entirely (e.g. after the order is submitted) */
|
||||
removeUpload: (cad_file_id: string) => void
|
||||
|
||||
/** Clear all tracked uploads */
|
||||
clearUploads: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useUploadStore = create<UploadState>((set) => ({
|
||||
pendingUploads: [],
|
||||
|
||||
addUpload: (upload) =>
|
||||
set((state) => {
|
||||
const existing = state.pendingUploads.find(
|
||||
(u) => u.cad_file_id === upload.cad_file_id,
|
||||
)
|
||||
if (existing) {
|
||||
// Refresh upload_status but keep existing match info
|
||||
return {
|
||||
pendingUploads: state.pendingUploads.map((u) =>
|
||||
u.cad_file_id === upload.cad_file_id
|
||||
? { ...u, upload_status: upload.upload_status }
|
||||
: u,
|
||||
),
|
||||
}
|
||||
}
|
||||
const newEntry: PendingUpload = {
|
||||
...upload,
|
||||
match_status: 'unmatched',
|
||||
matched_item_ids: [],
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}
|
||||
return { pendingUploads: [...state.pendingUploads, newEntry] }
|
||||
}),
|
||||
|
||||
setMatchStatus: (cad_file_id, status) =>
|
||||
set((state) => ({
|
||||
pendingUploads: state.pendingUploads.map((u) =>
|
||||
u.cad_file_id === cad_file_id ? { ...u, match_status: status } : u,
|
||||
),
|
||||
})),
|
||||
|
||||
addMatchedItem: (cad_file_id, item_id) =>
|
||||
set((state) => ({
|
||||
pendingUploads: state.pendingUploads.map((u) => {
|
||||
if (u.cad_file_id !== cad_file_id) return u
|
||||
if (u.matched_item_ids.includes(item_id)) return u
|
||||
const matched_item_ids = [...u.matched_item_ids, item_id]
|
||||
return {
|
||||
...u,
|
||||
matched_item_ids,
|
||||
match_status: 'matched' as MatchStatus,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
|
||||
removeMatchedItem: (cad_file_id, item_id) =>
|
||||
set((state) => ({
|
||||
pendingUploads: state.pendingUploads.map((u) => {
|
||||
if (u.cad_file_id !== cad_file_id) return u
|
||||
const matched_item_ids = u.matched_item_ids.filter((id) => id !== item_id)
|
||||
return {
|
||||
...u,
|
||||
matched_item_ids,
|
||||
match_status: matched_item_ids.length === 0
|
||||
? ('unmatched' as MatchStatus)
|
||||
: u.match_status,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
|
||||
removeUpload: (cad_file_id) =>
|
||||
set((state) => ({
|
||||
pendingUploads: state.pendingUploads.filter(
|
||||
(u) => u.cad_file_id !== cad_file_id,
|
||||
),
|
||||
})),
|
||||
|
||||
clearUploads: () => set({ pendingUploads: [] }),
|
||||
}))
|
||||
@@ -0,0 +1,32 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/**
|
||||
* Formats an ISO date string using German locale.
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalizes the first letter of a status string and replaces underscores
|
||||
* with spaces.
|
||||
*/
|
||||
export function formatStatus(status: string): string {
|
||||
if (!status) return '—'
|
||||
const normalized = status.replace(/_/g, ' ')
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges class names with Tailwind deduplication — thin clsx + tailwind-merge
|
||||
* wrapper.
|
||||
*/
|
||||
export function cn(...classes: ClassValue[]): string {
|
||||
return twMerge(clsx(classes))
|
||||
}
|
||||
Reference in New Issue
Block a user