diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py index 2c3f0159..fe01d482 100644 --- a/app/routes/vendor_pages.py +++ b/app/routes/vendor_pages.py @@ -243,6 +243,40 @@ async def vendor_orders_page( ) +@router.get( + "/{vendor_code}/orders/{order_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_order_detail_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + order_id: int = Path(..., description="Order ID"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), +): + """ + Render order detail page. + + Shows comprehensive order information including: + - Order header and status + - Customer and shipping details + - Order items with shipment status + - Invoice creation/viewing + - Partial shipment controls + + JavaScript loads order details via API. + """ + return templates.TemplateResponse( + "vendor/order-detail.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + "order_id": order_id, + }, + ) + + # ============================================================================ # CUSTOMER MANAGEMENT # ============================================================================ diff --git a/app/templates/vendor/order-detail.html b/app/templates/vendor/order-detail.html new file mode 100644 index 00000000..96196c88 --- /dev/null +++ b/app/templates/vendor/order-detail.html @@ -0,0 +1,451 @@ +{# app/templates/vendor/order-detail.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/modals.html' import modal_simple %} + +{% block title %}Order Details{% endblock %} + +{% block alpine_data %}vendorOrderDetail(){% endblock %} + +{% block content %} + +
+ + + Back to Orders + +
+ +{% call page_header_flex(title='Order Details', subtitle='View and manage order') %} +
+ + + + + +
+{% endcall %} + +{{ loading_state('Loading order details...') }} +{{ error_state('Error loading order') }} + + +
+ +
+ +
+
+

+ Order +

+

+ Placed on +

+
+
+
+

Channel

+

+
+
+

Items

+

+
+
+

Subtotal

+

+
+
+

Total

+

+
+
+
+ + +
+
+

Order Items

+ +
+
+ +
+ + +
+
+
+ Subtotal + +
+
+ Tax + +
+
+ Shipping + +
+
+ Discount + - +
+
+ Total + +
+
+
+
+ + +
+
+

Shipping & Tracking

+
+
+
+

Carrier

+

+
+
+

Tracking Number

+

+
+
+

Shipped At

+

+
+
+
+
+ + +
+ +
+
+

Customer

+
+
+
+

+

+

+
+
+
+ + +
+
+

Shipping Address

+
+
+

+

+

+

+

+ +

+

+
+
+ + +
+
+

Invoice

+
+
+ + + +
+
+ + +
+
+

Quick Actions

+
+
+ + + + +
+
+ + +
+
+

Notes

+
+
+
+

Customer Notes

+

+
+
+

Internal Notes

+

+
+
+
+
+
+ + +{{ modal_simple( + show_var='showStatusModal', + title='Update Order Status', + icon='pencil-square', + icon_color='blue', + confirm_text='Update', + confirm_class='bg-purple-600 hover:bg-purple-700 focus:shadow-outline-purple', + confirm_fn='confirmStatusUpdate()', + loading_var='saving' +) }} + + + +{{ modal_simple( + show_var='showShipAllModal', + title='Ship All Items', + icon='truck', + icon_color='indigo', + confirm_text='Ship All', + confirm_class='bg-indigo-600 hover:bg-indigo-700 focus:shadow-outline-indigo', + confirm_fn='shipAllItems()', + loading_var='saving' +) }} + +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/templates/vendor/orders.html b/app/templates/vendor/orders.html index be2b5287..d53cf1d6 100644 --- a/app/templates/vendor/orders.html +++ b/app/templates/vendor/orders.html @@ -216,6 +216,7 @@ 'px-2 py-1 font-semibold leading-tight rounded-full': true, 'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': getStatusColor(order.status) === 'yellow', 'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': getStatusColor(order.status) === 'blue', + 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': getStatusColor(order.status) === 'orange', 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': getStatusColor(order.status) === 'green', 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': getStatusColor(order.status) === 'red', 'text-indigo-700 bg-indigo-100 dark:bg-indigo-700 dark:text-indigo-100': getStatusColor(order.status) === 'indigo', diff --git a/static/vendor/js/order-detail.js b/static/vendor/js/order-detail.js new file mode 100644 index 00000000..f59a47b3 --- /dev/null +++ b/static/vendor/js/order-detail.js @@ -0,0 +1,372 @@ +// static/vendor/js/order-detail.js +/** + * Vendor order detail page logic + * View order details, manage status, handle shipments, and invoice integration + */ + +const orderDetailLog = window.LogConfig.loggers.orderDetail || + window.LogConfig.createLogger('orderDetail', false); + +orderDetailLog.info('Loading...'); + +function vendorOrderDetail() { + orderDetailLog.info('vendorOrderDetail() called'); + + return { + // Inherit base layout state + ...data(), + + // Set page identifier + currentPage: 'orders', + + // Order ID from URL + orderId: window.orderDetailData?.orderId || null, + + // Loading states + loading: true, + error: '', + saving: false, + creatingInvoice: false, + downloadingPdf: false, + + // Order data + order: null, + shipmentStatus: null, + invoice: null, + + // Modal states + showStatusModal: false, + showShipAllModal: false, + newStatus: '', + trackingNumber: '', + trackingProvider: '', + + // Order statuses + statuses: [ + { value: 'pending', label: 'Pending', color: 'yellow' }, + { value: 'processing', label: 'Processing', color: 'blue' }, + { value: 'partially_shipped', label: 'Partially Shipped', color: 'orange' }, + { value: 'shipped', label: 'Shipped', color: 'indigo' }, + { value: 'delivered', label: 'Delivered', color: 'green' }, + { value: 'cancelled', label: 'Cancelled', color: 'red' }, + { value: 'refunded', label: 'Refunded', color: 'gray' } + ], + + async init() { + orderDetailLog.info('Order detail init() called, orderId:', this.orderId); + + // Guard against multiple initialization + if (window._orderDetailInitialized) { + orderDetailLog.warn('Already initialized, skipping'); + return; + } + window._orderDetailInitialized = true; + + if (!this.orderId) { + this.error = 'Order ID not provided'; + this.loading = false; + return; + } + + try { + await this.loadOrderDetails(); + } catch (error) { + orderDetailLog.error('Init failed:', error); + this.error = 'Failed to load order details'; + } + + orderDetailLog.info('Order detail initialization complete'); + }, + + /** + * Load order details from API + */ + async loadOrderDetails() { + this.loading = true; + this.error = ''; + + try { + // Load order details + const orderResponse = await apiClient.get( + `/vendor/${this.vendorCode}/orders/${this.orderId}` + ); + this.order = orderResponse; + this.newStatus = this.order.status; + + orderDetailLog.info('Loaded order:', this.order.order_number); + + // Load shipment status + await this.loadShipmentStatus(); + + // Load invoice if exists + await this.loadInvoice(); + + } catch (error) { + orderDetailLog.error('Failed to load order details:', error); + this.error = error.message || 'Failed to load order details'; + } finally { + this.loading = false; + } + }, + + /** + * Load shipment status for partial shipment tracking + */ + async loadShipmentStatus() { + try { + const response = await apiClient.get( + `/vendor/${this.vendorCode}/orders/${this.orderId}/shipment-status` + ); + this.shipmentStatus = response; + orderDetailLog.info('Loaded shipment status:', response); + } catch (error) { + orderDetailLog.warn('Failed to load shipment status:', error); + // Not critical - continue without shipment status + } + }, + + /** + * Load invoice for this order + */ + async loadInvoice() { + try { + // Search for invoices linked to this order + const response = await apiClient.get( + `/vendor/${this.vendorCode}/invoices?order_id=${this.orderId}&limit=1` + ); + if (response.invoices && response.invoices.length > 0) { + this.invoice = response.invoices[0]; + orderDetailLog.info('Loaded invoice:', this.invoice.invoice_number); + } + } catch (error) { + orderDetailLog.warn('Failed to load invoice:', error); + // Not critical - continue without invoice + } + }, + + /** + * Get status color class + */ + getStatusColor(status) { + const statusObj = this.statuses.find(s => s.value === status); + return statusObj ? statusObj.color : 'gray'; + }, + + /** + * Get status label + */ + getStatusLabel(status) { + const statusObj = this.statuses.find(s => s.value === status); + return statusObj ? statusObj.label : status; + }, + + /** + * Get item shipment status + */ + getItemShipmentStatus(itemId) { + if (!this.shipmentStatus?.items) return null; + return this.shipmentStatus.items.find(i => i.item_id === itemId); + }, + + /** + * Check if item can be shipped + */ + canShipItem(itemId) { + const status = this.getItemShipmentStatus(itemId); + if (!status) return true; // Assume can ship if no status + return !status.is_fully_shipped; + }, + + /** + * Format price for display + */ + formatPrice(cents) { + if (cents === null || cents === undefined) return '-'; + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR' + }).format(cents / 100); + }, + + /** + * Format date/time for display + */ + formatDateTime(dateStr) { + if (!dateStr) return '-'; + return new Date(dateStr).toLocaleDateString('de-DE', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }, + + /** + * Update order status + */ + async updateOrderStatus(status) { + this.saving = true; + try { + const payload = { status }; + + // Add tracking info if shipping + if (status === 'shipped' && this.trackingNumber) { + payload.tracking_number = this.trackingNumber; + payload.tracking_provider = this.trackingProvider; + } + + await apiClient.put( + `/vendor/${this.vendorCode}/orders/${this.orderId}/status`, + payload + ); + + Utils.showToast(`Order status updated to ${this.getStatusLabel(status)}`, 'success'); + await this.loadOrderDetails(); + + } catch (error) { + orderDetailLog.error('Failed to update status:', error); + Utils.showToast(error.message || 'Failed to update status', 'error'); + } finally { + this.saving = false; + } + }, + + /** + * Confirm status update from modal + */ + async confirmStatusUpdate() { + await this.updateOrderStatus(this.newStatus); + this.showStatusModal = false; + this.trackingNumber = ''; + this.trackingProvider = ''; + }, + + /** + * Ship a single item + */ + async shipItem(itemId) { + this.saving = true; + try { + await apiClient.post( + `/vendor/${this.vendorCode}/orders/${this.orderId}/items/${itemId}/ship`, + {} + ); + + Utils.showToast('Item shipped successfully', 'success'); + await this.loadOrderDetails(); + + } catch (error) { + orderDetailLog.error('Failed to ship item:', error); + Utils.showToast(error.message || 'Failed to ship item', 'error'); + } finally { + this.saving = false; + } + }, + + /** + * Ship all remaining items + */ + async shipAllItems() { + this.saving = true; + try { + // Ship each unshipped item + const unshippedItems = this.shipmentStatus?.items?.filter(i => !i.is_fully_shipped) || []; + + for (const item of unshippedItems) { + await apiClient.post( + `/vendor/${this.vendorCode}/orders/${this.orderId}/items/${item.item_id}/ship`, + {} + ); + } + + // Update order status to shipped with tracking + const payload = { status: 'shipped' }; + if (this.trackingNumber) { + payload.tracking_number = this.trackingNumber; + payload.tracking_provider = this.trackingProvider; + } + + await apiClient.put( + `/vendor/${this.vendorCode}/orders/${this.orderId}/status`, + payload + ); + + Utils.showToast('All items shipped', 'success'); + this.showShipAllModal = false; + this.trackingNumber = ''; + this.trackingProvider = ''; + await this.loadOrderDetails(); + + } catch (error) { + orderDetailLog.error('Failed to ship all items:', error); + Utils.showToast(error.message || 'Failed to ship items', 'error'); + } finally { + this.saving = false; + } + }, + + /** + * Create invoice for this order + */ + async createInvoice() { + this.creatingInvoice = true; + try { + const response = await apiClient.post( + `/vendor/${this.vendorCode}/invoices`, + { order_id: this.orderId } + ); + + this.invoice = response; + Utils.showToast(`Invoice ${response.invoice_number} created`, 'success'); + + } catch (error) { + orderDetailLog.error('Failed to create invoice:', error); + Utils.showToast(error.message || 'Failed to create invoice', 'error'); + } finally { + this.creatingInvoice = false; + } + }, + + /** + * Download invoice PDF + */ + async downloadInvoicePdf() { + if (!this.invoice) return; + + this.downloadingPdf = true; + try { + const response = await fetch( + `/api/v1/vendor/${this.vendorCode}/invoices/${this.invoice.id}/pdf`, + { + headers: { + 'Authorization': `Bearer ${window.Auth?.getToken()}` + } + } + ); + + if (!response.ok) { + throw new Error('Failed to download PDF'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.invoice.invoice_number}.pdf`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); + + Utils.showToast('Invoice downloaded', 'success'); + + } catch (error) { + orderDetailLog.error('Failed to download invoice PDF:', error); + Utils.showToast(error.message || 'Failed to download PDF', 'error'); + } finally { + this.downloadingPdf = false; + } + } + }; +} diff --git a/static/vendor/js/orders.js b/static/vendor/js/orders.js index b5b4b9ed..bfd09384 100644 --- a/static/vendor/js/orders.js +++ b/static/vendor/js/orders.js @@ -38,6 +38,7 @@ function vendorOrders() { statuses: [ { value: 'pending', label: 'Pending', color: 'yellow' }, { value: 'processing', label: 'Processing', color: 'blue' }, + { value: 'partially_shipped', label: 'Partially Shipped', color: 'orange' }, { value: 'shipped', label: 'Shipped', color: 'indigo' }, { value: 'delivered', label: 'Delivered', color: 'green' }, { value: 'completed', label: 'Completed', color: 'green' }, @@ -243,21 +244,10 @@ function vendorOrders() { }, /** - * View order details + * View order details - navigates to detail page */ - async viewOrder(order) { - this.loading = true; - try { - const response = await apiClient.get(`/vendor/${this.vendorCode}/orders/${order.id}`); - this.selectedOrder = response; - this.showDetailModal = true; - vendorOrdersLog.info('Loaded order details:', order.id); - } catch (error) { - vendorOrdersLog.error('Failed to load order details:', error); - Utils.showToast(error.message || 'Failed to load order details', 'error'); - } finally { - this.loading = false; - } + viewOrder(order) { + window.location.href = `/vendor/${this.vendorCode}/orders/${order.id}`; }, /**