diff --git a/app/api/v1/admin/orders.py b/app/api/v1/admin/orders.py new file mode 100644 index 00000000..9401a7f5 --- /dev/null +++ b/app/api/v1/admin/orders.py @@ -0,0 +1,138 @@ +# app/api/v1/admin/orders.py +""" +Admin order management endpoints. + +Provides order management capabilities for administrators: +- View orders across all vendors +- View vendor-specific orders +- Update order status on behalf of vendors +- Order statistics and reporting + +Admin Context: Uses admin JWT authentication. +Vendor selection is passed as a request parameter. +""" + +import logging + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.services.order_service import order_service +from models.database.user import User +from models.schema.order import ( + AdminOrderItem, + AdminOrderListResponse, + AdminOrderStats, + AdminOrderStatusUpdate, + AdminVendorsWithOrdersResponse, + OrderDetailResponse, +) + +router = APIRouter(prefix="/orders") +logger = logging.getLogger(__name__) + + +# ============================================================================ +# List & Statistics Endpoints +# ============================================================================ + + +@router.get("", response_model=AdminOrderListResponse) +def get_all_orders( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=500), + vendor_id: int | None = Query(None, description="Filter by vendor"), + status: str | None = Query(None, description="Filter by status"), + channel: str | None = Query(None, description="Filter by channel"), + search: str | None = Query(None, description="Search by order number or customer"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Get orders across all vendors with filtering. + + Allows admins to view and filter orders across the platform. + """ + orders, total = order_service.get_all_orders_admin( + db=db, + skip=skip, + limit=limit, + vendor_id=vendor_id, + status=status, + channel=channel, + search=search, + ) + + return AdminOrderListResponse( + orders=[AdminOrderItem(**order) for order in orders], + total=total, + skip=skip, + limit=limit, + ) + + +@router.get("/stats", response_model=AdminOrderStats) +def get_order_stats( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get platform-wide order statistics.""" + return order_service.get_order_stats_admin(db) + + +@router.get("/vendors", response_model=AdminVendorsWithOrdersResponse) +def get_vendors_with_orders( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get list of vendors that have orders.""" + vendors = order_service.get_vendors_with_orders_admin(db) + return AdminVendorsWithOrdersResponse(vendors=vendors) + + +# ============================================================================ +# Order Detail & Update Endpoints +# ============================================================================ + + +@router.get("/{order_id}", response_model=OrderDetailResponse) +def get_order_detail( + order_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get order details including items and addresses.""" + order = order_service.get_order_by_id_admin(db, order_id) + return order + + +@router.patch("/{order_id}/status", response_model=OrderDetailResponse) +def update_order_status( + order_id: int, + status_update: AdminOrderStatusUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Update order status. + + Admin can update status and add tracking number. + Status changes are logged with optional reason. + """ + order = order_service.update_order_status_admin( + db=db, + order_id=order_id, + status=status_update.status, + tracking_number=status_update.tracking_number, + reason=status_update.reason, + ) + + logger.info( + f"Admin {current_admin.email} updated order {order.order_number} " + f"status to {status_update.status}" + ) + + db.commit() + return order diff --git a/app/services/order_service.py b/app/services/order_service.py index 66bf9103..05988ae5 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -366,5 +366,251 @@ class OrderService: raise ValidationException(f"Failed to update order: {str(e)}") + # ========================================================================= + # Admin Methods (cross-vendor) + # ========================================================================= + + def get_all_orders_admin( + self, + db: Session, + skip: int = 0, + limit: int = 50, + vendor_id: int | None = None, + status: str | None = None, + channel: str | None = None, + search: str | None = None, + ) -> tuple[list[dict], int]: + """ + Get orders across all vendors for admin. + + Args: + db: Database session + skip: Pagination offset + limit: Pagination limit + vendor_id: Filter by vendor + status: Filter by status + channel: Filter by channel + search: Search by order number or customer + + Returns: + Tuple of (orders with vendor info, total_count) + """ + from models.database.customer import Customer + from models.database.vendor import Vendor + + query = db.query(Order).join(Vendor).join(Customer) + + if vendor_id: + query = query.filter(Order.vendor_id == vendor_id) + + if status: + query = query.filter(Order.status == status) + + if channel: + query = query.filter(Order.channel == channel) + + if search: + search_term = f"%{search}%" + query = query.filter( + (Order.order_number.ilike(search_term)) + | (Customer.email.ilike(search_term)) + | (Customer.first_name.ilike(search_term)) + | (Customer.last_name.ilike(search_term)) + ) + + # Order by most recent first + query = query.order_by(Order.created_at.desc()) + + total = query.count() + orders = query.offset(skip).limit(limit).all() + + # Build response with vendor/customer info + result = [] + for order in orders: + item_count = len(order.items) if order.items else 0 + customer_name = None + customer_email = None + + if order.customer: + customer_name = ( + f"{order.customer.first_name} {order.customer.last_name}".strip() + ) + customer_email = order.customer.email + + result.append( + { + "id": order.id, + "vendor_id": order.vendor_id, + "vendor_name": order.vendor.name if order.vendor else None, + "vendor_code": order.vendor.vendor_code if order.vendor else None, + "customer_id": order.customer_id, + "customer_name": customer_name, + "customer_email": customer_email, + "order_number": order.order_number, + "channel": order.channel, + "status": order.status, + "subtotal": order.subtotal, + "tax_amount": order.tax_amount, + "shipping_amount": order.shipping_amount, + "discount_amount": order.discount_amount, + "total_amount": order.total_amount, + "currency": order.currency, + "shipping_method": order.shipping_method, + "tracking_number": order.tracking_number, + "item_count": item_count, + "created_at": order.created_at, + "updated_at": order.updated_at, + "paid_at": order.paid_at, + "shipped_at": order.shipped_at, + "delivered_at": order.delivered_at, + "cancelled_at": order.cancelled_at, + } + ) + + return result, total + + def get_order_stats_admin(self, db: Session) -> dict: + """Get platform-wide order statistics.""" + from sqlalchemy import func + + from models.database.vendor import Vendor + + # Get status counts + status_counts = ( + db.query(Order.status, func.count(Order.id)).group_by(Order.status).all() + ) + + stats = { + "total_orders": 0, + "pending_orders": 0, + "processing_orders": 0, + "shipped_orders": 0, + "delivered_orders": 0, + "cancelled_orders": 0, + "refunded_orders": 0, + "total_revenue": 0.0, + "vendors_with_orders": 0, + } + + for status, count in status_counts: + stats["total_orders"] += count + key = f"{status}_orders" + if key in stats: + stats[key] = count + + # Get total revenue (from delivered orders only) + revenue = ( + db.query(func.sum(Order.total_amount)) + .filter(Order.status == "delivered") + .scalar() + ) + stats["total_revenue"] = float(revenue) if revenue else 0.0 + + # Count vendors with orders + vendors_count = ( + db.query(func.count(func.distinct(Order.vendor_id))).scalar() or 0 + ) + stats["vendors_with_orders"] = vendors_count + + return stats + + def get_vendors_with_orders_admin(self, db: Session) -> list[dict]: + """Get list of vendors that have orders.""" + from sqlalchemy import func + + from models.database.vendor import Vendor + + results = ( + db.query( + Vendor.id, + Vendor.name, + Vendor.vendor_code, + func.count(Order.id).label("order_count"), + ) + .join(Order) + .group_by(Vendor.id, Vendor.name, Vendor.vendor_code) + .order_by(Vendor.name) + .all() + ) + + return [ + { + "id": r.id, + "name": r.name, + "vendor_code": r.vendor_code, + "order_count": r.order_count, + } + for r in results + ] + + def get_order_by_id_admin(self, db: Session, order_id: int) -> Order: + """Get order by ID without vendor scope (admin only).""" + order = db.query(Order).filter(Order.id == order_id).first() + + if not order: + raise OrderNotFoundException(str(order_id)) + + return order + + def update_order_status_admin( + self, + db: Session, + order_id: int, + status: str, + tracking_number: str | None = None, + reason: str | None = None, + ) -> Order: + """ + Update order status as admin. + + Args: + db: Database session + order_id: Order ID + status: New status + tracking_number: Optional tracking number + reason: Optional reason for change + + Returns: + Updated Order object + """ + order = self.get_order_by_id_admin(db, order_id) + + # Update status with timestamps + old_status = order.status + order.status = status + + now = datetime.now(UTC) + if status == "shipped" and not order.shipped_at: + order.shipped_at = now + elif status == "delivered" and not order.delivered_at: + order.delivered_at = now + elif status == "cancelled" and not order.cancelled_at: + order.cancelled_at = now + + # Update tracking number + if tracking_number: + order.tracking_number = tracking_number + + # Add reason to internal notes if provided + if reason: + note = f"[{now.isoformat()}] Status changed from {old_status} to {status}: {reason}" + if order.internal_notes: + order.internal_notes = f"{order.internal_notes}\n{note}" + else: + order.internal_notes = note + + order.updated_at = now + db.flush() + db.refresh(order) + + logger.info( + f"Admin updated order {order.order_number}: " + f"{old_status} -> {status}" + f"{f' (reason: {reason})' if reason else ''}" + ) + + return order + + # Create service instance order_service = OrderService() diff --git a/app/templates/admin/orders.html b/app/templates/admin/orders.html new file mode 100644 index 00000000..7b951588 --- /dev/null +++ b/app/templates/admin/orders.html @@ -0,0 +1,440 @@ +{# app/templates/admin/orders.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper %} +{% from 'shared/macros/modals.html' import modal_simple %} +{% from 'shared/macros/inputs.html' import vendor_selector %} + +{% block title %}Orders{% endblock %} + +{% block alpine_data %}adminOrders(){% endblock %} + +{% block content %} +{{ page_header('Orders', subtitle='Manage orders across all vendors') }} + +{{ loading_state('Loading orders...') }} + +{{ error_state('Error loading orders') }} + + +
+ +
+
+ +
+
+

+ Total Orders +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Pending +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Processing +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Total Revenue +

+

+ 0 +

+
+
+
+ + +
+
+ +
+
+ + + + +
+
+ + +
+ + {{ vendor_selector( + ref_name='vendorSelect', + id='orders-vendor-select', + placeholder='Filter by vendor...', + width='w-64' + ) }} + + + + + + + + + +
+
+
+ + +
+ {% call table_wrapper() %} + + + Order + Customer + Vendor + Channel + Total + Status + Date + Actions + + + + + + + + + + {% endcall %} + + {{ pagination(show_condition="!loading && pagination.total > 0") }} +
+ + +{% call modal_simple('updateStatusModal', 'Update Order Status', show_var='showStatusModal', size='sm') %} +
+
+

Order:

+

Current Status:

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+{% endcall %} + + +{% call modal_simple('orderDetailModal', 'Order Details', show_var='showDetailModal', size='lg') %} +
+ +
+
+

+

+
+ +
+ + +
+
+

Customer

+

+

+
+
+

Vendor

+

+

+
+
+ + +
+

Items

+
+ +
+
+ + +
+
+ Subtotal + +
+
+ Tax + +
+
+ Shipping + +
+ +
+ Total + +
+
+ + +
+

Shipping Address

+
+

+

+

+

+

+
+
+ + +
+

Tracking Number

+

+
+ + +
+

Internal Notes

+

+
+ +
+ + +
+
+{% endcall %} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/models/schema/order.py b/models/schema/order.py index f64db5b2..b28bc87c 100644 --- a/models/schema/order.py +++ b/models/schema/order.py @@ -162,3 +162,96 @@ class OrderListResponse(BaseModel): total: int skip: int limit: int + + +# ============================================================================ +# Admin Order Schemas +# ============================================================================ + + +class AdminOrderItem(BaseModel): + """Order item with vendor info for admin list view.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + vendor_id: int + vendor_name: str | None = None + vendor_code: str | None = None + customer_id: int + customer_name: str | None = None + customer_email: str | None = None + order_number: str + channel: str + status: str + + # Financial + subtotal: float + tax_amount: float + shipping_amount: float + discount_amount: float + total_amount: float + currency: str + + # Shipping + shipping_method: str | None + tracking_number: str | None + + # Item count + item_count: int = 0 + + # Timestamps + created_at: datetime + updated_at: datetime + paid_at: datetime | None + shipped_at: datetime | None + delivered_at: datetime | None + cancelled_at: datetime | None + + +class AdminOrderListResponse(BaseModel): + """Cross-vendor order list for admin.""" + + orders: list[AdminOrderItem] + total: int + skip: int + limit: int + + +class AdminOrderStats(BaseModel): + """Order statistics for admin dashboard.""" + + total_orders: int + pending_orders: int + processing_orders: int + shipped_orders: int + delivered_orders: int + cancelled_orders: int + refunded_orders: int + total_revenue: float + vendors_with_orders: int + + +class AdminOrderStatusUpdate(BaseModel): + """Admin version of status update with reason.""" + + status: str = Field( + ..., pattern="^(pending|processing|shipped|delivered|cancelled|refunded)$" + ) + tracking_number: str | None = None + reason: str | None = Field(None, description="Reason for status change") + + +class AdminVendorWithOrders(BaseModel): + """Vendor with order count.""" + + id: int + name: str + vendor_code: str + order_count: int = 0 + + +class AdminVendorsWithOrdersResponse(BaseModel): + """Response for vendors with orders list.""" + + vendors: list[AdminVendorWithOrders] diff --git a/static/admin/js/orders.js b/static/admin/js/orders.js new file mode 100644 index 00000000..8056bc9e --- /dev/null +++ b/static/admin/js/orders.js @@ -0,0 +1,392 @@ +// static/admin/js/orders.js +/** + * Admin orders management page logic + * View and manage orders across all vendors + */ + +const adminOrdersLog = window.LogConfig.loggers.adminOrders || + window.LogConfig.createLogger('adminOrders', false); + +adminOrdersLog.info('Loading...'); + +function adminOrders() { + adminOrdersLog.info('adminOrders() called'); + + return { + // Inherit base layout state + ...data(), + + // Set page identifier + currentPage: 'orders', + + // Loading states + loading: true, + error: '', + saving: false, + + // Orders data + orders: [], + stats: { + total_orders: 0, + pending_orders: 0, + processing_orders: 0, + shipped_orders: 0, + delivered_orders: 0, + cancelled_orders: 0, + refunded_orders: 0, + total_revenue: 0, + vendors_with_orders: 0 + }, + + // Filters + filters: { + search: '', + vendor_id: '', + status: '', + channel: '' + }, + + // Available vendors for filter dropdown + vendors: [], + + // Pagination + pagination: { + page: 1, + per_page: 50, + total: 0, + pages: 0 + }, + + // Modal states + showStatusModal: false, + showDetailModal: false, + selectedOrder: null, + selectedOrderDetail: null, + + // Status update form + statusForm: { + status: '', + tracking_number: '', + reason: '' + }, + + // Debounce timer + searchTimeout: null, + + // Computed: Total pages + get totalPages() { + return this.pagination.pages; + }, + + // Computed: Start index for pagination display + get startIndex() { + if (this.pagination.total === 0) return 0; + return (this.pagination.page - 1) * this.pagination.per_page + 1; + }, + + // Computed: End index for pagination display + get endIndex() { + const end = this.pagination.page * this.pagination.per_page; + return end > this.pagination.total ? this.pagination.total : end; + }, + + // Computed: Page numbers for pagination + get pageNumbers() { + const pages = []; + const totalPages = this.totalPages; + const current = this.pagination.page; + + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + if (current > 3) { + pages.push('...'); + } + const start = Math.max(2, current - 1); + const end = Math.min(totalPages - 1, current + 1); + for (let i = start; i <= end; i++) { + pages.push(i); + } + if (current < totalPages - 2) { + pages.push('...'); + } + pages.push(totalPages); + } + return pages; + }, + + async init() { + adminOrdersLog.info('Orders init() called'); + + // Guard against multiple initialization + if (window._adminOrdersInitialized) { + adminOrdersLog.warn('Already initialized, skipping'); + return; + } + window._adminOrdersInitialized = true; + + // Load data in parallel + await Promise.all([ + this.loadStats(), + this.loadVendors(), + this.loadOrders() + ]); + + adminOrdersLog.info('Orders initialization complete'); + }, + + /** + * Load order statistics + */ + async loadStats() { + try { + const response = await apiClient.get('/admin/orders/stats'); + this.stats = response; + adminOrdersLog.info('Loaded stats:', this.stats); + } catch (error) { + adminOrdersLog.error('Failed to load stats:', error); + } + }, + + /** + * Load available vendors for filter + */ + async loadVendors() { + try { + const response = await apiClient.get('/admin/orders/vendors'); + this.vendors = response.vendors || []; + adminOrdersLog.info('Loaded vendors:', this.vendors.length); + } catch (error) { + adminOrdersLog.error('Failed to load vendors:', error); + } + }, + + /** + * Load orders with filtering and pagination + */ + async loadOrders() { + this.loading = true; + this.error = ''; + + try { + const params = new URLSearchParams({ + skip: (this.pagination.page - 1) * this.pagination.per_page, + limit: this.pagination.per_page + }); + + // Add filters + if (this.filters.search) { + params.append('search', this.filters.search); + } + if (this.filters.vendor_id) { + params.append('vendor_id', this.filters.vendor_id); + } + if (this.filters.status) { + params.append('status', this.filters.status); + } + if (this.filters.channel) { + params.append('channel', this.filters.channel); + } + + const response = await apiClient.get(`/admin/orders?${params.toString()}`); + + this.orders = response.orders || []; + this.pagination.total = response.total || 0; + this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); + + adminOrdersLog.info('Loaded orders:', this.orders.length, 'of', this.pagination.total); + } catch (error) { + adminOrdersLog.error('Failed to load orders:', error); + this.error = error.message || 'Failed to load orders'; + } finally { + this.loading = false; + } + }, + + /** + * Debounced search handler + */ + debouncedSearch() { + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => { + this.pagination.page = 1; + this.loadOrders(); + }, 300); + }, + + /** + * Refresh orders list + */ + async refresh() { + await Promise.all([ + this.loadStats(), + this.loadVendors(), + this.loadOrders() + ]); + }, + + /** + * View order details + */ + async viewOrder(order) { + try { + const response = await apiClient.get(`/admin/orders/${order.id}`); + this.selectedOrderDetail = response; + this.showDetailModal = true; + } catch (error) { + adminOrdersLog.error('Failed to load order details:', error); + Utils.showToast('Failed to load order details.', 'error'); + } + }, + + /** + * Open status update modal + */ + openStatusModal(order) { + this.selectedOrder = order; + this.statusForm = { + status: order.status, + tracking_number: order.tracking_number || '', + reason: '' + }; + this.showStatusModal = true; + }, + + /** + * Update order status + */ + async updateStatus() { + if (!this.selectedOrder || this.statusForm.status === this.selectedOrder.status) return; + + this.saving = true; + try { + const payload = { + status: this.statusForm.status + }; + + if (this.statusForm.tracking_number) { + payload.tracking_number = this.statusForm.tracking_number; + } + + if (this.statusForm.reason) { + payload.reason = this.statusForm.reason; + } + + await apiClient.patch(`/admin/orders/${this.selectedOrder.id}/status`, payload); + + adminOrdersLog.info('Updated order status:', this.selectedOrder.id); + + this.showStatusModal = false; + this.selectedOrder = null; + + Utils.showToast('Order status updated successfully.', 'success'); + + await this.refresh(); + } catch (error) { + adminOrdersLog.error('Failed to update order status:', error); + Utils.showToast(error.message || 'Failed to update status.', 'error'); + } finally { + this.saving = false; + } + }, + + /** + * Get CSS class for status badge + */ + getStatusClass(status) { + const classes = { + pending: 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100', + processing: 'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100', + shipped: 'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100', + delivered: 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100', + cancelled: 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100', + refunded: 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100' + }; + return classes[status] || 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'; + }, + + /** + * Format price for display + */ + formatPrice(price, currency = 'EUR') { + if (price === null || price === undefined) return '-'; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency || 'EUR' + }).format(price); + }, + + /** + * Format date for display + */ + formatDate(dateString) { + if (!dateString) return '-'; + const date = new Date(dateString); + return date.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric' + }); + }, + + /** + * Format time for display + */ + formatTime(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit' + }); + }, + + /** + * Format full date and time + */ + formatDateTime(dateString) { + if (!dateString) return '-'; + const date = new Date(dateString); + return date.toLocaleString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }, + + /** + * Pagination: Previous page + */ + previousPage() { + if (this.pagination.page > 1) { + this.pagination.page--; + this.loadOrders(); + } + }, + + /** + * Pagination: Next page + */ + nextPage() { + if (this.pagination.page < this.totalPages) { + this.pagination.page++; + this.loadOrders(); + } + }, + + /** + * Pagination: Go to specific page + */ + goToPage(pageNum) { + if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) { + this.pagination.page = pageNum; + this.loadOrders(); + } + } + }; +}