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 %}
+
+
+
+{% 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
+
+
+
+
+
+
+
+
+
Order Items
+
+
+ / units shipped
+
+
+
+
+
+
+
+
+
+
+ SKU:
+ | GTIN:
+
+
+ x
+
+
+
+
+
+
+
+
+
+
+ Shipped
+
+
+
+
+ / shipped
+
+
+
+
+ Pending
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Subtotal
+
+
+
+ Tax
+
+
+
+ Shipping
+
+
+
+ Discount
+ -
+
+
+ Total
+
+
+
+
+
+
+
+
+
+
Shipping & Tracking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Shipping Address
+
+
+
+
+
+
+
+
Invoice
+
+
+
+
+
+
+
+
No invoice created yet
+
+
+
+
+
+ Confirm the order first before creating an invoice
+
+
+
+
+
+
+
+
+
Quick Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ 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'
+) }}
+
+
+
+ This will ship all remaining items and mark the order as shipped.
+
+
+
+
+
+
+
+
+
+
+
+{% 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}`;
},
/**