refactor: migrate templates and static files to self-contained modules
Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,32 +2,19 @@
|
||||
"""
|
||||
Orders module exceptions.
|
||||
|
||||
Re-exports order-related exceptions from their source locations.
|
||||
This module provides exception classes for order operations including:
|
||||
- Order management (not found, validation, status)
|
||||
- Order item exceptions (unresolved, resolution)
|
||||
- Invoice operations (PDF generation, status transitions)
|
||||
"""
|
||||
|
||||
from app.exceptions.order import (
|
||||
OrderNotFoundException,
|
||||
OrderAlreadyExistsException,
|
||||
OrderValidationException,
|
||||
InvalidOrderStatusException,
|
||||
OrderCannotBeCancelledException,
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
from app.exceptions.order_item_exception import (
|
||||
OrderItemExceptionNotFoundException,
|
||||
OrderHasUnresolvedExceptionsException,
|
||||
ExceptionAlreadyResolvedException,
|
||||
InvalidProductForExceptionException,
|
||||
)
|
||||
|
||||
from app.exceptions.invoice import (
|
||||
InvoiceNotFoundException,
|
||||
InvoiceSettingsNotFoundException,
|
||||
InvoiceSettingsAlreadyExistException,
|
||||
InvoiceValidationException,
|
||||
InvoicePDFGenerationException,
|
||||
InvoicePDFNotFoundException,
|
||||
InvalidInvoiceStatusTransitionException,
|
||||
from app.exceptions.base import (
|
||||
BusinessLogicException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
WizamartException,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -50,4 +37,227 @@ __all__ = [
|
||||
"InvoicePDFGenerationException",
|
||||
"InvoicePDFNotFoundException",
|
||||
"InvalidInvoiceStatusTransitionException",
|
||||
"OrderNotFoundForInvoiceException",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Order Exceptions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrderNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when an order is not found."""
|
||||
|
||||
def __init__(self, order_identifier: str):
|
||||
super().__init__(
|
||||
resource_type="Order",
|
||||
identifier=order_identifier,
|
||||
message=f"Order '{order_identifier}' not found",
|
||||
error_code="ORDER_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class OrderAlreadyExistsException(ValidationException):
|
||||
"""Raised when trying to create a duplicate order."""
|
||||
|
||||
def __init__(self, order_number: str):
|
||||
super().__init__(
|
||||
message=f"Order with number '{order_number}' already exists",
|
||||
details={"order_number": order_number},
|
||||
)
|
||||
self.error_code = "ORDER_ALREADY_EXISTS"
|
||||
|
||||
|
||||
class OrderValidationException(ValidationException):
|
||||
"""Raised when order data validation fails."""
|
||||
|
||||
def __init__(self, message: str, details: dict | None = None):
|
||||
super().__init__(message=message, details=details)
|
||||
self.error_code = "ORDER_VALIDATION_FAILED"
|
||||
|
||||
|
||||
class InvalidOrderStatusException(BusinessLogicException):
|
||||
"""Raised when trying to set an invalid order status."""
|
||||
|
||||
def __init__(self, current_status: str, new_status: str):
|
||||
super().__init__(
|
||||
message=f"Cannot change order status from '{current_status}' to '{new_status}'",
|
||||
error_code="INVALID_ORDER_STATUS_CHANGE",
|
||||
details={"current_status": current_status, "new_status": new_status},
|
||||
)
|
||||
|
||||
|
||||
class OrderCannotBeCancelledException(BusinessLogicException):
|
||||
"""Raised when order cannot be cancelled."""
|
||||
|
||||
def __init__(self, order_number: str, reason: str):
|
||||
super().__init__(
|
||||
message=f"Order '{order_number}' cannot be cancelled: {reason}",
|
||||
error_code="ORDER_CANNOT_BE_CANCELLED",
|
||||
details={"order_number": order_number, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Order Item Exception Exceptions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrderItemExceptionNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when an order item exception is not found."""
|
||||
|
||||
def __init__(self, exception_id: int | str):
|
||||
super().__init__(
|
||||
resource_type="OrderItemException",
|
||||
identifier=str(exception_id),
|
||||
error_code="ORDER_ITEM_EXCEPTION_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class OrderHasUnresolvedExceptionsException(BusinessLogicException):
|
||||
"""Raised when trying to confirm an order with unresolved exceptions."""
|
||||
|
||||
def __init__(self, order_id: int, unresolved_count: int):
|
||||
super().__init__(
|
||||
message=(
|
||||
f"Order has {unresolved_count} unresolved product exception(s). "
|
||||
f"Please resolve all exceptions before confirming the order."
|
||||
),
|
||||
error_code="ORDER_HAS_UNRESOLVED_EXCEPTIONS",
|
||||
details={
|
||||
"order_id": order_id,
|
||||
"unresolved_count": unresolved_count,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ExceptionAlreadyResolvedException(BusinessLogicException):
|
||||
"""Raised when trying to resolve an already resolved exception."""
|
||||
|
||||
def __init__(self, exception_id: int):
|
||||
super().__init__(
|
||||
message=f"Exception {exception_id} has already been resolved",
|
||||
error_code="EXCEPTION_ALREADY_RESOLVED",
|
||||
details={"exception_id": exception_id},
|
||||
)
|
||||
|
||||
|
||||
class InvalidProductForExceptionException(BusinessLogicException):
|
||||
"""Raised when the product provided for resolution is invalid."""
|
||||
|
||||
def __init__(self, product_id: int, reason: str):
|
||||
super().__init__(
|
||||
message=f"Cannot use product {product_id} for resolution: {reason}",
|
||||
error_code="INVALID_PRODUCT_FOR_EXCEPTION",
|
||||
details={"product_id": product_id, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Invoice Exceptions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class InvoiceNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when an invoice is not found."""
|
||||
|
||||
def __init__(self, invoice_id: int | str):
|
||||
super().__init__(
|
||||
resource_type="Invoice",
|
||||
identifier=str(invoice_id),
|
||||
error_code="INVOICE_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class InvoiceSettingsNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when invoice settings are not found for a vendor."""
|
||||
|
||||
def __init__(self, vendor_id: int):
|
||||
super().__init__(
|
||||
resource_type="InvoiceSettings",
|
||||
identifier=str(vendor_id),
|
||||
message="Invoice settings not found. Create settings first.",
|
||||
error_code="INVOICE_SETTINGS_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class InvoiceSettingsAlreadyExistException(WizamartException):
|
||||
"""Raised when trying to create invoice settings that already exist."""
|
||||
|
||||
def __init__(self, vendor_id: int):
|
||||
super().__init__(
|
||||
message=f"Invoice settings already exist for vendor {vendor_id}",
|
||||
error_code="INVOICE_SETTINGS_ALREADY_EXIST",
|
||||
status_code=409,
|
||||
details={"vendor_id": vendor_id},
|
||||
)
|
||||
|
||||
|
||||
class InvoiceValidationException(BusinessLogicException):
|
||||
"""Raised when invoice data validation fails."""
|
||||
|
||||
def __init__(self, message: str, details: dict[str, Any] | None = None):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="INVOICE_VALIDATION_ERROR",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class InvoicePDFGenerationException(WizamartException):
|
||||
"""Raised when PDF generation fails."""
|
||||
|
||||
def __init__(self, invoice_id: int, reason: str):
|
||||
super().__init__(
|
||||
message=f"Failed to generate PDF for invoice {invoice_id}: {reason}",
|
||||
error_code="INVOICE_PDF_GENERATION_FAILED",
|
||||
status_code=500,
|
||||
details={"invoice_id": invoice_id, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class InvoicePDFNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when invoice PDF file is not found."""
|
||||
|
||||
def __init__(self, invoice_id: int):
|
||||
super().__init__(
|
||||
resource_type="InvoicePDF",
|
||||
identifier=str(invoice_id),
|
||||
message="PDF file not found. Generate the PDF first.",
|
||||
error_code="INVOICE_PDF_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class InvalidInvoiceStatusTransitionException(BusinessLogicException):
|
||||
"""Raised when an invalid invoice status transition is attempted."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_status: str,
|
||||
new_status: str,
|
||||
reason: str | None = None,
|
||||
):
|
||||
message = f"Cannot change invoice status from '{current_status}' to '{new_status}'"
|
||||
if reason:
|
||||
message += f": {reason}"
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="INVALID_INVOICE_STATUS_TRANSITION",
|
||||
details={
|
||||
"current_status": current_status,
|
||||
"new_status": new_status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class OrderNotFoundForInvoiceException(ResourceNotFoundException):
|
||||
"""Raised when an order for invoice creation is not found."""
|
||||
|
||||
def __init__(self, order_id: int):
|
||||
super().__init__(
|
||||
resource_type="Order",
|
||||
identifier=str(order_id),
|
||||
error_code="ORDER_NOT_FOUND_FOR_INVOICE",
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.services.order_service import order_service
|
||||
from app.modules.orders.services.order_service import order_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.orders.schemas import (
|
||||
AdminOrderItem,
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.services.order_item_exception_service import order_item_exception_service
|
||||
from app.modules.orders.services.order_item_exception_service import order_item_exception_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.orders.schemas import (
|
||||
BulkResolveRequest,
|
||||
|
||||
@@ -20,11 +20,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_customer_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import OrderNotFoundException, VendorNotFoundException
|
||||
from app.exceptions.invoice import InvoicePDFNotFoundException
|
||||
from app.modules.orders.exceptions import OrderNotFoundException
|
||||
from app.modules.tenancy.exceptions import VendorNotFoundException
|
||||
from app.modules.orders.exceptions import InvoicePDFNotFoundException
|
||||
from app.modules.customers.schemas import CustomerContext
|
||||
from app.modules.orders.services import order_service
|
||||
from app.services.invoice_service import invoice_service # noqa: MOD-004 - Core invoice service
|
||||
from app.modules.orders.services.invoice_service import invoice_service # noqa: MOD-004 - Core invoice service
|
||||
from app.modules.orders.schemas import (
|
||||
OrderDetailResponse,
|
||||
OrderListResponse,
|
||||
|
||||
@@ -16,8 +16,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.services.order_inventory_service import order_inventory_service
|
||||
from app.services.order_service import order_service
|
||||
from app.modules.orders.services.order_inventory_service import order_inventory_service
|
||||
from app.modules.orders.services.order_service import order_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.orders.schemas import (
|
||||
OrderDetailResponse,
|
||||
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.services.order_item_exception_service import order_item_exception_service
|
||||
from app.modules.orders.services.order_item_exception_service import order_item_exception_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.orders.schemas import (
|
||||
BulkResolveRequest,
|
||||
|
||||
@@ -34,10 +34,10 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.core.feature_gate import RequireFeature
|
||||
from app.exceptions.invoice import (
|
||||
from app.modules.orders.exceptions import (
|
||||
InvoicePDFNotFoundException,
|
||||
)
|
||||
from app.services.invoice_service import invoice_service
|
||||
from app.modules.orders.services.invoice_service import invoice_service
|
||||
from app.modules.billing.models import FeatureCode
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.orders.schemas import (
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
# Page routes will be added here
|
||||
# TODO: Add HTML page routes for admin/vendor dashboards
|
||||
|
||||
__all__ = []
|
||||
# app/modules/orders/routes/pages/__init__.py
|
||||
"""Orders module page routes."""
|
||||
|
||||
40
app/modules/orders/routes/pages/admin.py
Normal file
40
app/modules/orders/routes/pages/admin.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# app/modules/orders/routes/pages/admin.py
|
||||
"""
|
||||
Orders Admin Page Routes (HTML rendering).
|
||||
|
||||
Admin pages for order management:
|
||||
- Orders list
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ORDER MANAGEMENT ROUTES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/orders", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_orders_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("orders", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render orders management page.
|
||||
Shows orders across all vendors with filtering and status management.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"orders/admin/orders.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
86
app/modules/orders/routes/pages/storefront.py
Normal file
86
app/modules/orders/routes/pages/storefront.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# app/modules/orders/routes/pages/storefront.py
|
||||
"""
|
||||
Orders Storefront Page Routes (HTML rendering).
|
||||
|
||||
Storefront (customer shop) pages for order history:
|
||||
- Orders list
|
||||
- Order detail
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
|
||||
from app.modules.core.utils.page_context import get_storefront_context
|
||||
from app.modules.customers.models import Customer
|
||||
from app.templates_config import templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CUSTOMER ACCOUNT - ORDERS (Authenticated)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/account/orders", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_orders_page(
|
||||
request: Request,
|
||||
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render customer orders history page.
|
||||
Shows all past and current orders.
|
||||
Requires customer authentication.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_orders_page REACHED",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"vendor": getattr(request.state, "vendor", "NOT SET"),
|
||||
"context": getattr(request.state, "context_type", "NOT SET"),
|
||||
},
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"orders/storefront/orders.html",
|
||||
get_storefront_context(request, db=db, user=current_customer),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/account/orders/{order_id}",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def shop_order_detail_page(
|
||||
request: Request,
|
||||
order_id: int = Path(..., description="Order ID"),
|
||||
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render customer order detail page.
|
||||
Shows detailed order information and tracking.
|
||||
Requires customer authentication.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_order_detail_page REACHED",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"order_id": order_id,
|
||||
"vendor": getattr(request.state, "vendor", "NOT SET"),
|
||||
"context": getattr(request.state, "context_type", "NOT SET"),
|
||||
},
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"orders/storefront/order-detail.html",
|
||||
get_storefront_context(request, db=db, user=current_customer, order_id=order_id),
|
||||
)
|
||||
73
app/modules/orders/routes/pages/vendor.py
Normal file
73
app/modules/orders/routes/pages/vendor.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# app/modules/orders/routes/pages/vendor.py
|
||||
"""
|
||||
Orders Vendor Page Routes (HTML rendering).
|
||||
|
||||
Vendor pages for order management:
|
||||
- Orders list
|
||||
- Order detail
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
|
||||
from app.modules.core.utils.page_context import get_vendor_context
|
||||
from app.templates_config import templates
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ORDER MANAGEMENT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/orders", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def vendor_orders_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render orders management page.
|
||||
JavaScript loads order list via API.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"orders/vendor/orders.html",
|
||||
get_vendor_context(request, db, current_user, vendor_code),
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
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(
|
||||
"orders/vendor/order-detail.html",
|
||||
get_vendor_context(request, db, current_user, vendor_code, order_id=order_id),
|
||||
)
|
||||
@@ -19,9 +19,9 @@ from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import ValidationException
|
||||
from app.exceptions.invoice import (
|
||||
InvoiceNotFoundException,
|
||||
from app.modules.orders.exceptions import (
|
||||
InvoiceSettingsNotFoundException,
|
||||
InvoiceNotFoundException,
|
||||
OrderNotFoundException,
|
||||
)
|
||||
from app.modules.orders.models.invoice import (
|
||||
|
||||
@@ -13,12 +13,12 @@ All operations are logged to the inventory_transactions table for audit trail.
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
from app.exceptions import ValidationException
|
||||
from app.modules.inventory.exceptions import (
|
||||
InsufficientInventoryException,
|
||||
InventoryNotFoundException,
|
||||
OrderNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.modules.orders.exceptions import OrderNotFoundException
|
||||
from app.modules.inventory.models.inventory import Inventory
|
||||
from app.modules.inventory.models.inventory_transaction import (
|
||||
InventoryTransaction,
|
||||
|
||||
@@ -15,12 +15,12 @@ from datetime import UTC, datetime
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.exceptions import (
|
||||
from app.modules.orders.exceptions import (
|
||||
ExceptionAlreadyResolvedException,
|
||||
InvalidProductForExceptionException,
|
||||
OrderItemExceptionNotFoundException,
|
||||
ProductNotFoundException,
|
||||
)
|
||||
from app.modules.catalog.exceptions import ProductNotFoundException
|
||||
from app.modules.orders.exceptions import OrderItemExceptionNotFoundException
|
||||
from app.modules.orders.models.order import Order, OrderItem
|
||||
from app.modules.orders.models.order_item_exception import OrderItemException
|
||||
from app.modules.catalog.models import Product
|
||||
|
||||
@@ -24,12 +24,10 @@ from typing import Any
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
CustomerNotFoundException,
|
||||
InsufficientInventoryException,
|
||||
OrderNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.exceptions import ValidationException
|
||||
from app.modules.customers.exceptions import CustomerNotFoundException
|
||||
from app.modules.inventory.exceptions import InsufficientInventoryException
|
||||
from app.modules.orders.exceptions import OrderNotFoundException
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.orders.models.order import Order, OrderItem
|
||||
from app.modules.orders.schemas.order import (
|
||||
@@ -39,7 +37,7 @@ from app.modules.orders.schemas.order import (
|
||||
OrderItemCreate,
|
||||
OrderUpdate,
|
||||
)
|
||||
from app.services.subscription_service import (
|
||||
from app.modules.billing.services.subscription_service import (
|
||||
subscription_service,
|
||||
TierLimitExceededException,
|
||||
)
|
||||
@@ -1293,7 +1291,7 @@ class OrderService:
|
||||
order_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Get shipping label information for an order (admin only)."""
|
||||
from app.services.admin_settings_service import admin_settings_service # noqa: MOD-004
|
||||
from app.modules.core.services.admin_settings_service import admin_settings_service # noqa: MOD-004
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
|
||||
|
||||
645
app/modules/orders/templates/orders/admin/orders.html
Normal file
645
app/modules/orders/templates/orders/admin/orders.html
Normal file
@@ -0,0 +1,645 @@
|
||||
{# app/templates/admin/orders.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% 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 extra_head %}
|
||||
<!-- Tom Select CSS with local fallback -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
|
||||
/>
|
||||
<style>
|
||||
/* Tom Select dark mode overrides */
|
||||
.dark .ts-wrapper .ts-control {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input::placeholder {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(147 51 234);
|
||||
color: white;
|
||||
}
|
||||
.dark .ts-dropdown .option:hover {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
.dark .ts-wrapper.focus .ts-control {
|
||||
border-color: rgb(147 51 234);
|
||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header with Vendor Selector -->
|
||||
{% call page_header_flex(title='Orders', subtitle='Manage orders across all vendors') %}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Vendor Autocomplete (Tom Select) -->
|
||||
<div class="w-80">
|
||||
<select id="vendor-select" x-ref="vendorSelect" placeholder="Search vendor...">
|
||||
</select>
|
||||
</div>
|
||||
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Selected Vendor Info (optional display) -->
|
||||
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
|
||||
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearVendorFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading orders...') }}
|
||||
|
||||
{{ error_state('Error loading orders') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card: Total Orders -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('clipboard-list', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Orders
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_orders || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Pending -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Pending
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.pending_orders || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Processing -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Processing
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.processing_orders || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Revenue -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('currency-euro', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Revenue
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatPrice(stats.total_revenue || 0)">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters Bar -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="flex-1 max-w-xl">
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input="debouncedSearch()"
|
||||
placeholder="Search by order number or customer..."
|
||||
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="pagination.page = 1; loadOrders()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="refunded">Refunded</option>
|
||||
</select>
|
||||
|
||||
<!-- Channel Filter -->
|
||||
<select
|
||||
x-model="filters.channel"
|
||||
@change="pagination.page = 1; loadOrders()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Channels</option>
|
||||
<option value="direct">Direct</option>
|
||||
<option value="letzshop">Letzshop</option>
|
||||
</select>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="refresh()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="Refresh orders"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders Table with Pagination -->
|
||||
<div x-show="!loading">
|
||||
{% call table_wrapper() %}
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Order</th>
|
||||
<th class="px-4 py-3">Customer</th>
|
||||
<th class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Channel</th>
|
||||
<th class="px-4 py-3 text-right">Total</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<!-- Empty State -->
|
||||
<template x-if="orders.length === 0">
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('clipboard-list', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No orders found</p>
|
||||
<p class="text-xs mt-1" x-text="filters.search || filters.vendor_id || filters.status || filters.channel ? 'Try adjusting your filters' : 'Orders will appear here when customers place orders'"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Order Rows -->
|
||||
<template x-for="order in orders" :key="order.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<!-- Order Info -->
|
||||
<td class="px-4 py-3">
|
||||
<div>
|
||||
<p class="font-semibold text-sm font-mono" x-text="order.order_number"></p>
|
||||
<p class="text-xs text-gray-500" x-text="order.item_count + ' item(s)'"></p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Customer Info -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<p class="font-medium" x-text="order.customer_name || 'Guest'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[150px]" x-text="order.customer_email || '-'"></p>
|
||||
</td>
|
||||
|
||||
<!-- Vendor Info -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<p class="font-medium" x-text="order.vendor_name || 'Unknown'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono" x-text="order.vendor_code || ''"></p>
|
||||
</td>
|
||||
|
||||
<!-- Channel -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
:class="order.channel === 'letzshop' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
|
||||
x-text="order.channel"></span>
|
||||
</td>
|
||||
|
||||
<!-- Total -->
|
||||
<td class="px-4 py-3 text-sm text-right font-mono font-semibold">
|
||||
<span x-text="formatPrice(order.total_amount, order.currency)"></span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="getStatusClass(order.status)"
|
||||
x-text="order.status"></span>
|
||||
</td>
|
||||
|
||||
<!-- Date -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<p x-text="formatDate(order.created_at)"></p>
|
||||
<p class="text-xs text-gray-500" x-text="formatTime(order.created_at)"></p>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewOrder(order)"
|
||||
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="openStatusModal(order)"
|
||||
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-blue-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Update Status"
|
||||
>
|
||||
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
</div>
|
||||
|
||||
<!-- Update Status Modal -->
|
||||
{% call modal_simple('updateStatusModal', 'Update Order Status', show_var='showStatusModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">Order: <span x-text="selectedOrder?.order_number"></span></p>
|
||||
<p class="text-xs">Current Status: <span class="font-medium" x-text="selectedOrder?.status"></span></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Status
|
||||
</label>
|
||||
<select
|
||||
x-model="statusForm.status"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="refunded">Refunded</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div x-show="statusForm.status === 'shipped'">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tracking Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="statusForm.tracking_number"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Enter tracking number"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Reason (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="statusForm.reason"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="e.g., Customer requested cancellation"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showStatusModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="updateStatus()"
|
||||
:disabled="saving || statusForm.status === selectedOrder?.status"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-text="saving ? 'Updating...' : 'Update Status'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Order Detail Modal -->
|
||||
{% call modal_simple('orderDetailModal', 'Order Details', show_var='showDetailModal', size='lg') %}
|
||||
<div x-show="selectedOrderDetail" class="space-y-4">
|
||||
<!-- Order Header -->
|
||||
<div class="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.order_number"></p>
|
||||
<p class="text-sm text-gray-500" x-text="formatDateTime(selectedOrderDetail?.created_at)"></p>
|
||||
</div>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full"
|
||||
:class="getStatusClass(selectedOrderDetail?.status)"
|
||||
x-text="selectedOrderDetail?.status"></span>
|
||||
</div>
|
||||
|
||||
<!-- Customer & Vendor Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Customer</p>
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="(selectedOrderDetail?.customer_first_name || '') + ' ' + (selectedOrderDetail?.customer_last_name || '')"></p>
|
||||
<p class="text-xs text-gray-500" x-text="selectedOrderDetail?.customer_email"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Vendor</p>
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.vendor_name || 'Unknown'"></p>
|
||||
<p class="text-xs text-gray-500 font-mono" x-text="selectedOrderDetail?.vendor_code || ''"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Items</p>
|
||||
<span class="text-xs text-gray-400" x-text="(selectedOrderDetail?.items?.length || 0) + ' item(s)'"></span>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden max-h-48 overflow-y-auto">
|
||||
<template x-for="item in selectedOrderDetail?.items || []" :key="item.id">
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-600 last:border-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="item.product_name"></p>
|
||||
<p class="text-xs text-gray-500" x-text="'SKU: ' + (item.product_sku || '-')"></p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="formatPrice(item.total_price)"></p>
|
||||
<p class="text-xs text-gray-500" x-text="item.quantity + ' x ' + formatPrice(item.unit_price)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
|
||||
<span x-text="formatPrice(selectedOrderDetail?.subtotal)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Tax</span>
|
||||
<span x-text="formatPrice(selectedOrderDetail?.tax_amount)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Shipping</span>
|
||||
<span x-text="formatPrice(selectedOrderDetail?.shipping_amount)"></span>
|
||||
</div>
|
||||
<template x-if="selectedOrderDetail?.discount_amount > 0">
|
||||
<div class="flex justify-between text-sm mb-1 text-green-600">
|
||||
<span>Discount</span>
|
||||
<span x-text="'-' + formatPrice(selectedOrderDetail?.discount_amount)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex justify-between text-sm font-semibold pt-2 border-t border-gray-200 dark:border-gray-600">
|
||||
<span>Total</span>
|
||||
<span x-text="formatPrice(selectedOrderDetail?.total_amount, selectedOrderDetail?.currency)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Address -->
|
||||
<div x-show="selectedOrderDetail?.ship_address_line_1">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Shipping Address</p>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<p x-text="(selectedOrderDetail?.ship_first_name || '') + ' ' + (selectedOrderDetail?.ship_last_name || '')"></p>
|
||||
<p x-text="selectedOrderDetail?.ship_address_line_1"></p>
|
||||
<p x-show="selectedOrderDetail?.ship_address_line_2" x-text="selectedOrderDetail?.ship_address_line_2"></p>
|
||||
<p x-text="(selectedOrderDetail?.ship_postal_code || '') + ' ' + (selectedOrderDetail?.ship_city || '')"></p>
|
||||
<p x-text="selectedOrderDetail?.ship_country_iso"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping & Tracking Info -->
|
||||
<div x-show="selectedOrderDetail?.shipment_number || selectedOrderDetail?.tracking_number || selectedOrderDetail?.shipping_carrier">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Shipping & Tracking</p>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3 space-y-2">
|
||||
<!-- Shipment Number -->
|
||||
<div x-show="selectedOrderDetail?.shipment_number" class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Shipment #:</span>
|
||||
<span class="ml-2 text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.shipment_number"></span>
|
||||
</div>
|
||||
<!-- Download Label Button -->
|
||||
<button
|
||||
x-show="selectedOrderDetail?.shipping_carrier"
|
||||
@click="downloadShippingLabel(selectedOrderDetail)"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 bg-purple-50 dark:bg-purple-900/30 rounded"
|
||||
title="Download shipping label"
|
||||
>
|
||||
<span x-html="$icon('download', 'w-3 h-3')"></span>
|
||||
Label
|
||||
</button>
|
||||
</div>
|
||||
<!-- Carrier -->
|
||||
<div x-show="selectedOrderDetail?.shipping_carrier" class="flex items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Carrier:</span>
|
||||
<span class="ml-2 px-2 py-0.5 text-xs font-medium rounded capitalize"
|
||||
:class="{
|
||||
'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200': selectedOrderDetail?.shipping_carrier === 'greco',
|
||||
'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200': selectedOrderDetail?.shipping_carrier === 'colissimo',
|
||||
'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200': selectedOrderDetail?.shipping_carrier === 'xpresslogistics',
|
||||
'bg-gray-100 dark:bg-gray-600 text-gray-800 dark:text-gray-200': !['greco', 'colissimo', 'xpresslogistics'].includes(selectedOrderDetail?.shipping_carrier)
|
||||
}"
|
||||
x-text="selectedOrderDetail?.shipping_carrier"></span>
|
||||
</div>
|
||||
<!-- Tracking Number (if different from shipment) -->
|
||||
<div x-show="selectedOrderDetail?.tracking_number" class="flex items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Tracking #:</span>
|
||||
<span class="ml-2 text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.tracking_number"></span>
|
||||
</div>
|
||||
<!-- Tracking URL -->
|
||||
<div x-show="selectedOrderDetail?.tracking_url" class="flex items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Track:</span>
|
||||
<a :href="selectedOrderDetail?.tracking_url" target="_blank"
|
||||
class="ml-2 text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
|
||||
View tracking
|
||||
<span x-html="$icon('external-link', 'w-3 h-3')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div x-show="selectedOrderDetail?.internal_notes">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Internal Notes</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap" x-text="selectedOrderDetail?.internal_notes"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<!-- Mark as Shipped button (only for processing orders) -->
|
||||
<button
|
||||
x-show="selectedOrderDetail?.status === 'processing'"
|
||||
@click="showDetailModal = false; openMarkAsShippedModal(selectedOrderDetail)"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('truck', 'w-4 h-4')"></span>
|
||||
Mark as Shipped
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="showDetailModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
@click="showDetailModal = false; openStatusModal(selectedOrderDetail)"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Update Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Mark as Shipped Modal -->
|
||||
{% call modal_simple('markAsShippedModal', 'Mark Order as Shipped', show_var='showMarkAsShippedModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Mark order <span class="font-mono font-medium" x-text="selectedOrder?.order_number"></span> as shipped.
|
||||
</p>
|
||||
|
||||
<!-- Shipment Info (read-only if from Letzshop) -->
|
||||
<div x-show="selectedOrder?.shipment_number" class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Letzshop Shipment</p>
|
||||
<p class="text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrder?.shipment_number"></p>
|
||||
<p x-show="selectedOrder?.shipping_carrier" class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Carrier: <span class="capitalize" x-text="selectedOrder?.shipping_carrier"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tracking Number Input -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tracking Number <span class="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="shipForm.tracking_number"
|
||||
placeholder="e.g., 3XYVi85dDE8l6bov97122"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tracking URL Input -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tracking URL <span class="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
x-model="shipForm.tracking_url"
|
||||
placeholder="https://dispatchweb.fr/Tracky/Home/..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Carrier Select (if not already set) -->
|
||||
<div x-show="!selectedOrder?.shipping_carrier">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Carrier <span class="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="shipForm.shipping_carrier"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="">Select carrier...</option>
|
||||
<option value="greco">Greco</option>
|
||||
<option value="colissimo">Colissimo</option>
|
||||
<option value="xpresslogistics">XpressLogistics</option>
|
||||
<option value="dhl">DHL</option>
|
||||
<option value="ups">UPS</option>
|
||||
<option value="fedex">FedEx</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="showMarkAsShippedModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="markAsShipped()"
|
||||
:disabled="markingAsShipped"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!markingAsShipped" x-html="$icon('truck', 'w-4 h-4')"></span>
|
||||
<span x-show="markingAsShipped" class="animate-spin" x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
<span x-text="markingAsShipped ? 'Shipping...' : 'Mark as Shipped'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('orders_static', path='admin/js/orders.js') }}"></script>
|
||||
{% endblock %}
|
||||
559
app/modules/orders/templates/orders/storefront/order-detail.html
Normal file
559
app/modules/orders/templates/orders/storefront/order-detail.html
Normal file
@@ -0,0 +1,559 @@
|
||||
{# app/templates/storefront/account/order-detail.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}Order Details - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}shopOrderDetailPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<li>
|
||||
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">My Account</a>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
|
||||
<a href="{{ base_url }}shop/account/orders" class="hover:text-primary">Orders</a>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
|
||||
<span class="text-gray-900 dark:text-white" x-text="order?.order_number || 'Order Details'"></span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" style="border-color: var(--color-primary)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<div class="flex">
|
||||
<span class="h-6 w-6 text-red-400" x-html="$icon('x-circle', 'h-6 w-6')"></span>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-lg font-medium text-red-800 dark:text-red-200">Error loading order</h3>
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
|
||||
<a href="{{ base_url }}shop/account/orders"
|
||||
class="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
Back to Orders
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Content -->
|
||||
<div x-show="!loading && !error && order" x-cloak>
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" x-text="'Order ' + order.order_number"></h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Placed on <span x-text="formatDateTime(order.order_date || order.created_at)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
|
||||
:class="getStatusClass(order.status)">
|
||||
<span x-text="getStatusLabel(order.status)"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Order Tracking Timeline -->
|
||||
<div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6">Order Progress</h2>
|
||||
<div class="relative">
|
||||
<!-- Timeline Line -->
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
<!-- Timeline Steps -->
|
||||
<div class="space-y-6">
|
||||
<!-- Pending -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('pending')">
|
||||
<span class="w-4 h-4" x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium text-gray-900 dark:text-white">Order Placed</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDateTime(order.order_date || order.created_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('processing')">
|
||||
<span x-show="isStepComplete('processing')" class="w-4 h-4" x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
<span x-show="!isStepComplete('processing')" class="w-4 h-4" x-html="$icon('clock', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium" :class="isStepComplete('processing') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Processing</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.confirmed_at" x-text="formatDateTime(order.confirmed_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipped -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('shipped')">
|
||||
<span x-show="isStepComplete('shipped')" class="w-4 h-4" x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
<span x-show="!isStepComplete('shipped')" class="w-4 h-4" x-html="$icon('truck', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium" :class="isStepComplete('shipped') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Shipped</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.shipped_at" x-text="formatDateTime(order.shipped_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivered -->
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
|
||||
:class="getTimelineStepClass('delivered')">
|
||||
<span x-show="isStepComplete('delivered')" class="w-4 h-4" x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
<span x-show="!isStepComplete('delivered')" class="w-4 h-4" x-html="$icon('gift', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="font-medium" :class="isStepComplete('delivered') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Delivered</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.delivered_at" x-text="formatDateTime(order.delivered_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancelled/Refunded Notice -->
|
||||
<div x-show="order.status === 'cancelled' || order.status === 'refunded'"
|
||||
class="mt-6 p-4 rounded-lg"
|
||||
:class="order.status === 'cancelled' ? 'bg-red-50 dark:bg-red-900/20' : 'bg-gray-50 dark:bg-gray-700'">
|
||||
<p class="text-sm font-medium"
|
||||
:class="order.status === 'cancelled' ? 'text-red-800 dark:text-red-200' : 'text-gray-800 dark:text-gray-200'"
|
||||
x-text="order.status === 'cancelled' ? 'This order was cancelled' : 'This order was refunded'"></p>
|
||||
<p class="text-sm mt-1"
|
||||
:class="order.status === 'cancelled' ? 'text-red-600 dark:text-red-300' : 'text-gray-600 dark:text-gray-400'"
|
||||
x-show="order.cancelled_at"
|
||||
x-text="'on ' + formatDateTime(order.cancelled_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Main Content (Left Column - 2/3) -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
|
||||
<!-- Order Items -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Order Items</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="item in order.items" :key="item.id">
|
||||
<div class="px-6 py-4 flex items-center gap-4">
|
||||
<!-- Product Image Placeholder -->
|
||||
<div class="flex-shrink-0 w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<span class="h-8 w-8 text-gray-400" x-html="$icon('cube', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<!-- Product Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="item.product_name"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
SKU: <span x-text="item.product_sku || '-'"></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Qty: <span x-text="item.quantity"></span>
|
||||
×
|
||||
<span x-text="formatPrice(item.unit_price)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Line Total -->
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white" x-text="formatPrice(item.total_price)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Addresses -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Shipping Address -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('location-marker', 'h-5 w-5')"></span>
|
||||
Shipping Address
|
||||
</h3>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 space-y-1">
|
||||
<p class="font-medium" x-text="(order.ship_first_name || '') + ' ' + (order.ship_last_name || '')"></p>
|
||||
<p x-show="order.ship_company" x-text="order.ship_company"></p>
|
||||
<p x-text="order.ship_address_line_1"></p>
|
||||
<p x-show="order.ship_address_line_2" x-text="order.ship_address_line_2"></p>
|
||||
<p x-text="(order.ship_postal_code || '') + ' ' + (order.ship_city || '')"></p>
|
||||
<p x-text="order.ship_country_iso"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Address -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('document-text', 'h-5 w-5')"></span>
|
||||
Billing Address
|
||||
</h3>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 space-y-1">
|
||||
<p class="font-medium" x-text="(order.bill_first_name || '') + ' ' + (order.bill_last_name || '')"></p>
|
||||
<p x-show="order.bill_company" x-text="order.bill_company"></p>
|
||||
<p x-text="order.bill_address_line_1"></p>
|
||||
<p x-show="order.bill_address_line_2" x-text="order.bill_address_line_2"></p>
|
||||
<p x-text="(order.bill_postal_code || '') + ' ' + (order.bill_city || '')"></p>
|
||||
<p x-text="order.bill_country_iso"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Notes -->
|
||||
<div x-show="order.customer_notes" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('chat-bubble-left', 'h-5 w-5')"></span>
|
||||
Order Notes
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300" x-text="order.customer_notes"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar (Right Column - 1/3) -->
|
||||
<div class="space-y-6">
|
||||
<!-- Order Summary -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Order Summary</h3>
|
||||
<dl class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Subtotal</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.subtotal)"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Shipping</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.shipping_amount)"></dd>
|
||||
</div>
|
||||
<!-- VAT Breakdown -->
|
||||
<div x-show="order.tax_amount > 0 || order.vat_rate_label" class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">
|
||||
<span x-text="order.vat_rate_label || 'Tax'"></span>
|
||||
<span x-show="order.vat_rate" class="text-xs ml-1">(<span x-text="order.vat_rate"></span>%)</span>
|
||||
</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.tax_amount)"></dd>
|
||||
</div>
|
||||
<!-- VAT Regime Info (for special cases) -->
|
||||
<div x-show="order.vat_regime === 'reverse_charge'" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded p-2">
|
||||
VAT Reverse Charge applies (B2B transaction)
|
||||
</div>
|
||||
<div x-show="order.vat_regime === 'exempt'" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded p-2">
|
||||
VAT Exempt (Non-EU destination)
|
||||
</div>
|
||||
<div x-show="order.discount_amount > 0" class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Discount</dt>
|
||||
<dd class="font-medium text-green-600 dark:text-green-400" x-text="'-' + formatPrice(order.discount_amount)"></dd>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 flex justify-between">
|
||||
<dt class="text-base font-semibold text-gray-900 dark:text-white">Total</dt>
|
||||
<dd class="text-base font-bold text-gray-900 dark:text-white" x-text="formatPrice(order.total_amount)"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Download -->
|
||||
<div x-show="canDownloadInvoice()" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('document-text', 'h-5 w-5')"></span>
|
||||
Invoice
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Download your invoice for this order.
|
||||
</p>
|
||||
<button @click="downloadInvoice()"
|
||||
:disabled="downloadingInvoice"
|
||||
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-show="!downloadingInvoice" class="h-4 w-4 mr-2" x-html="$icon('arrow-down-tray', 'h-4 w-4')"></span>
|
||||
<span x-show="downloadingInvoice" class="h-4 w-4 mr-2" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
<span x-text="downloadingInvoice ? 'Generating...' : 'Download Invoice'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Info -->
|
||||
<div x-show="order.shipping_method || order.tracking_number" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('archive', 'h-5 w-5')"></span>
|
||||
Shipping
|
||||
</h3>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div x-show="order.shipping_method">
|
||||
<p class="text-gray-500 dark:text-gray-400">Method</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white" x-text="order.shipping_method"></p>
|
||||
</div>
|
||||
<div x-show="order.shipping_carrier">
|
||||
<p class="text-gray-500 dark:text-gray-400">Carrier</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white" x-text="order.shipping_carrier"></p>
|
||||
</div>
|
||||
<div x-show="order.tracking_number">
|
||||
<p class="text-gray-500 dark:text-gray-400">Tracking Number</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white" x-text="order.tracking_number"></p>
|
||||
</div>
|
||||
<!-- Track Package Button -->
|
||||
<a x-show="order.tracking_url"
|
||||
:href="order.tracking_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-3 w-full inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span class="h-4 w-4 mr-2" x-html="$icon('location-marker', 'h-4 w-4')"></span>
|
||||
Track Package
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Need Help? -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Need Help?</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
If you have any questions about your order, please contact us.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/account/messages"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span class="h-4 w-4 mr-2" x-html="$icon('chat-bubble-left', 'h-4 w-4')"></span>
|
||||
Contact Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="mt-8">
|
||||
<a href="{{ base_url }}shop/account/orders"
|
||||
class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-primary">
|
||||
<span class="h-4 w-4 mr-2" x-html="$icon('chevron-left', 'h-4 w-4')"></span>
|
||||
Back to Orders
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function shopOrderDetailPage() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
|
||||
// State
|
||||
order: null,
|
||||
loading: true,
|
||||
error: '',
|
||||
orderId: {{ order_id }},
|
||||
downloadingInvoice: false,
|
||||
|
||||
// Status mapping
|
||||
statuses: {
|
||||
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
|
||||
processing: { label: 'Processing', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
|
||||
partially_shipped: { label: 'Partially Shipped', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' },
|
||||
shipped: { label: 'Shipped', class: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200' },
|
||||
delivered: { label: 'Delivered', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
completed: { label: 'Completed', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
cancelled: { label: 'Cancelled', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
|
||||
refunded: { label: 'Refunded', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
|
||||
},
|
||||
|
||||
// Timeline step order for progress tracking
|
||||
timelineSteps: ['pending', 'processing', 'shipped', 'delivered'],
|
||||
|
||||
async init() {
|
||||
await this.loadOrder();
|
||||
},
|
||||
|
||||
async loadOrder() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/orders/${this.orderId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error('Order not found');
|
||||
}
|
||||
throw new Error('Failed to load order details');
|
||||
}
|
||||
|
||||
this.order = await response.json();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading order:', err);
|
||||
this.error = err.message || 'Failed to load order';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
getStatusLabel(status) {
|
||||
return this.statuses[status]?.label || status;
|
||||
},
|
||||
|
||||
getStatusClass(status) {
|
||||
return this.statuses[status]?.class || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
},
|
||||
|
||||
// formatPrice is inherited from shopLayoutData() via spread operator
|
||||
|
||||
formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
// ===== Timeline Functions =====
|
||||
|
||||
/**
|
||||
* Get the current step index in the order flow
|
||||
*/
|
||||
getCurrentStepIndex() {
|
||||
if (!this.order) return 0;
|
||||
const status = this.order.status;
|
||||
|
||||
// Handle special statuses
|
||||
if (status === 'cancelled' || status === 'refunded') {
|
||||
return -1; // Special case
|
||||
}
|
||||
if (status === 'completed') {
|
||||
return 4; // All steps complete
|
||||
}
|
||||
if (status === 'partially_shipped') {
|
||||
return 2; // Between processing and shipped
|
||||
}
|
||||
|
||||
return this.timelineSteps.indexOf(status) + 1;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a timeline step is complete
|
||||
*/
|
||||
isStepComplete(step) {
|
||||
if (!this.order) return false;
|
||||
|
||||
const currentIndex = this.getCurrentStepIndex();
|
||||
const stepIndex = this.timelineSteps.indexOf(step) + 1;
|
||||
|
||||
return currentIndex >= stepIndex;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get CSS classes for a timeline step
|
||||
*/
|
||||
getTimelineStepClass(step) {
|
||||
if (this.isStepComplete(step)) {
|
||||
// Completed step - green
|
||||
return 'bg-green-500 text-white';
|
||||
} else if (this.order && this.timelineSteps.indexOf(this.order.status) === this.timelineSteps.indexOf(step)) {
|
||||
// Current step - primary color with pulse
|
||||
return 'bg-blue-500 text-white animate-pulse';
|
||||
} else {
|
||||
// Future step - gray
|
||||
return 'bg-gray-200 dark:bg-gray-600 text-gray-400 dark:text-gray-500';
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Invoice Functions =====
|
||||
|
||||
/**
|
||||
* Check if invoice can be downloaded (order must be at least processing)
|
||||
*/
|
||||
canDownloadInvoice() {
|
||||
if (!this.order) return false;
|
||||
const invoiceStatuses = ['processing', 'partially_shipped', 'shipped', 'delivered', 'completed'];
|
||||
return invoiceStatuses.includes(this.order.status);
|
||||
},
|
||||
|
||||
/**
|
||||
* Download invoice PDF for this order
|
||||
*/
|
||||
async downloadInvoice() {
|
||||
if (this.downloadingInvoice) return;
|
||||
|
||||
this.downloadingInvoice = true;
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/shop/orders/${this.orderId}/invoice`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error('Invoice not yet available. Please try again later.');
|
||||
}
|
||||
throw new Error('Failed to download invoice');
|
||||
}
|
||||
|
||||
// Get filename from Content-Disposition header if available
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `invoice-${this.order.order_number}.pdf`;
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (match && match[1]) {
|
||||
filename = match[1].replace(/['"]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Download the blob
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error downloading invoice:', err);
|
||||
alert(err.message || 'Failed to download invoice');
|
||||
} finally {
|
||||
this.downloadingInvoice = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
225
app/modules/orders/templates/orders/storefront/orders.html
Normal file
225
app/modules/orders/templates/orders/storefront/orders.html
Normal file
@@ -0,0 +1,225 @@
|
||||
{# app/templates/storefront/account/orders.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}Order History - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}shopOrdersPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<li>
|
||||
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">My Account</a>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
|
||||
<span class="text-gray-900 dark:text-white">Order History</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Order History</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">View and track your orders</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" style="border-color: var(--color-primary)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<span class="h-5 w-5 text-red-400" x-html="$icon('x-circle', 'h-5 w-5')"></span>
|
||||
<p class="ml-3 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && !error && orders.length === 0"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('shopping-bag', 'h-12 w-12 mx-auto')"></span>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No orders yet</h3>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">Start shopping to see your orders here.</p>
|
||||
<a href="{{ base_url }}shop/products"
|
||||
class="mt-6 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Orders List -->
|
||||
<div x-show="!loading && !error && orders.length > 0" class="space-y-4">
|
||||
<template x-for="order in orders" :key="order.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<!-- Order Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex flex-wrap items-center gap-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Order Number</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="order.order_number"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Date</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="formatDate(order.order_date || order.created_at)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total</p>
|
||||
<p class="text-sm font-bold text-gray-900 dark:text-white" x-text="formatPrice(order.total_amount)"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Status Badge -->
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="getStatusClass(order.status)">
|
||||
<span x-text="getStatusLabel(order.status)"></span>
|
||||
</span>
|
||||
<!-- View Details Button -->
|
||||
<a :href="'{{ base_url }}shop/account/orders/' + order.id"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
|
||||
View Details
|
||||
<span class="ml-2 h-4 w-4" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items Preview -->
|
||||
<div class="px-6 py-4">
|
||||
<template x-for="item in order.items.slice(0, 3)" :key="item.id">
|
||||
<div class="flex items-center py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="item.product_name"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Qty: <span x-text="item.quantity"></span> ×
|
||||
<span x-text="formatPrice(item.unit_price)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="ml-4 text-sm font-medium text-gray-900 dark:text-white" x-text="formatPrice(item.total_price)"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="order.items.length > 3"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 mt-2"
|
||||
x-text="'+ ' + (order.items.length - 3) + ' more item(s)'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="totalPages > 1" class="flex justify-center mt-8">
|
||||
<nav class="flex items-center space-x-2">
|
||||
<button @click="loadOrders(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Previous
|
||||
</button>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
|
||||
</span>
|
||||
<button @click="loadOrders(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function shopOrdersPage() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
|
||||
// State
|
||||
orders: [],
|
||||
loading: true,
|
||||
error: '',
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
perPage: 10,
|
||||
|
||||
// Status mapping
|
||||
statuses: {
|
||||
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
|
||||
processing: { label: 'Processing', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
|
||||
partially_shipped: { label: 'Partially Shipped', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' },
|
||||
shipped: { label: 'Shipped', class: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200' },
|
||||
delivered: { label: 'Delivered', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
completed: { label: 'Completed', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
cancelled: { label: 'Cancelled', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
|
||||
refunded: { label: 'Refunded', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.loadOrders(1);
|
||||
},
|
||||
|
||||
async loadOrders(page = 1) {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const skip = (page - 1) * this.perPage;
|
||||
const response = await fetch(`/api/v1/shop/orders?skip=${skip}&limit=${this.perPage}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load orders');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.orders = data.orders || [];
|
||||
this.currentPage = page;
|
||||
this.totalPages = Math.ceil((data.total || 0) / this.perPage);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading orders:', err);
|
||||
this.error = err.message || 'Failed to load orders';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
getStatusLabel(status) {
|
||||
return this.statuses[status]?.label || status;
|
||||
},
|
||||
|
||||
getStatusClass(status) {
|
||||
return this.statuses[status]?.class || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
},
|
||||
|
||||
// formatPrice is inherited from shopLayoutData() via spread operator
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
501
app/modules/orders/templates/orders/vendor/invoices.html
vendored
Normal file
501
app/modules/orders/templates/orders/vendor/invoices.html
vendored
Normal file
@@ -0,0 +1,501 @@
|
||||
{# app/templates/vendor/invoices.html #}
|
||||
{% extends "vendor/base.html" %}
|
||||
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header, simple_pagination %}
|
||||
{% from 'shared/macros/modals.html' import form_modal %}
|
||||
|
||||
{% block title %}Invoices{% endblock %}
|
||||
|
||||
{% block alpine_data %}vendorInvoices(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/modules/billing/vendor/js/invoices.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='Invoices', subtitle='Create and manage invoices for your orders') %}
|
||||
<button
|
||||
@click="openCreateModal()"
|
||||
:disabled="!hasSettings"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="!hasSettings ? 'Configure invoice settings first' : 'Create new invoice'"
|
||||
>
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
Create Invoice
|
||||
</button>
|
||||
{{ refresh_button(loading_var='loading', onclick='refreshData()', variant='secondary') }}
|
||||
{% endcall %}
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="successMessage"></p>
|
||||
</div>
|
||||
<button @click="successMessage = ''" class="ml-auto text-green-700 dark:text-green-300 hover:text-green-900">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{# noqa: FE-003 - Uses dismissible close button not supported by error_state macro #}
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
<button @click="error = ''" class="ml-auto text-red-700 dark:text-red-300 hover:text-red-900">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Settings Warning -->
|
||||
<div x-show="!hasSettings && !loading" x-transition class="mb-6 p-4 bg-yellow-100 dark:bg-yellow-900/30 border border-yellow-400 dark:border-yellow-600 text-yellow-700 dark:text-yellow-300 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold">Invoice Settings Required</p>
|
||||
<p class="text-sm mt-1">Configure your company details and invoice preferences before creating invoices.</p>
|
||||
<button
|
||||
@click="activeTab = 'settings'"
|
||||
class="mt-3 inline-flex items-center px-3 py-1.5 text-sm font-medium text-yellow-800 bg-yellow-200 rounded-lg hover:bg-yellow-300"
|
||||
>
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
|
||||
Configure Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="activeTab = 'invoices'"
|
||||
:class="activeTab === 'invoices' ? 'border-purple-600 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<span x-html="$icon('document-text', 'w-4 h-4 mr-2')"></span>
|
||||
Invoices
|
||||
<span x-show="stats.total_invoices > 0" class="ml-2 px-2 py-0.5 text-xs bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 rounded-full" x-text="stats.total_invoices"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'settings'"
|
||||
:class="activeTab === 'settings' ? 'border-purple-600 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
|
||||
Settings
|
||||
<span x-show="!hasSettings" class="ml-2 w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoices Tab -->
|
||||
<div x-show="activeTab === 'invoices'" x-transition>
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:bg-blue-900">
|
||||
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Invoices</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_invoices"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:bg-gray-700">
|
||||
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Draft</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.draft_count"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:bg-orange-900">
|
||||
<span x-html="$icon('paper-airplane', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Issued</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.issued_count"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:bg-green-900">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Paid</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.paid_count"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex flex-wrap gap-4">
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="loadInvoices()"
|
||||
class="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="issued">Issued</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Invoices Table -->
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Invoice #', 'Customer', 'Date', 'Amount', 'Status', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loading && invoices.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
|
||||
<p>Loading invoices...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!loading && invoices.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('document-text', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No invoices yet</p>
|
||||
<p class="text-sm mt-1" x-show="hasSettings">Click "Create Invoice" to generate your first invoice</p>
|
||||
<p class="text-sm mt-1" x-show="!hasSettings">Configure invoice settings first to get started</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="invoice in invoices" :key="invoice.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div>
|
||||
<p class="font-semibold" x-text="invoice.invoice_number"></p>
|
||||
<p class="text-xs text-gray-500" x-text="'Order #' + (invoice.order_id || 'N/A')"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<p x-text="invoice.buyer_name || 'N/A'"></p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="formatDate(invoice.invoice_date)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-semibold">
|
||||
<span x-text="formatCurrency(invoice.total_cents, invoice.currency)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="{
|
||||
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-300': invoice.status === 'draft',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': invoice.status === 'issued',
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': invoice.status === 'paid',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': invoice.status === 'cancelled'
|
||||
}"
|
||||
x-text="invoice.status.toUpperCase()"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<button
|
||||
@click="downloadPDF(invoice)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-purple-600 transition-colors duration-150 rounded-md hover:bg-purple-100 dark:hover:bg-purple-900"
|
||||
title="Download PDF"
|
||||
>
|
||||
<span x-html="$icon('download', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="invoice.status === 'draft'"
|
||||
@click="updateStatus(invoice, 'issued')"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-blue-600 transition-colors duration-150 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
title="Mark as Issued"
|
||||
>
|
||||
<span x-html="$icon('paper-airplane', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="invoice.status === 'issued'"
|
||||
@click="updateStatus(invoice, 'paid')"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-green-600 transition-colors duration-150 rounded-md hover:bg-green-100 dark:hover:bg-green-900"
|
||||
title="Mark as Paid"
|
||||
>
|
||||
<span x-html="$icon('check', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="invoice.status !== 'cancelled' && invoice.status !== 'paid'"
|
||||
@click="updateStatus(invoice, 'cancelled')"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-red-600 transition-colors duration-150 rounded-md hover:bg-red-100 dark:hover:bg-red-900"
|
||||
title="Cancel Invoice"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{{ simple_pagination(page_var='page', total_var='totalInvoices', limit_var='perPage', on_change='loadInvoices()') }}
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div x-show="activeTab === 'settings'" x-transition>
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Invoice Settings
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure your company details and preferences for invoice generation.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="saveSettings()">
|
||||
<!-- Company Information -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4 pb-2 border-b dark:border-gray-700">
|
||||
Company Information
|
||||
</h4>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Company Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.company_name"
|
||||
required
|
||||
placeholder="Your Company S.A."
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.company_address"
|
||||
placeholder="123 Main Street"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.company_city"
|
||||
placeholder="Luxembourg"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Postal Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.company_postal_code"
|
||||
placeholder="L-1234"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Country
|
||||
</label>
|
||||
<select
|
||||
x-model="settingsForm.company_country"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="LU">Luxembourg</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="BE">Belgium</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
VAT Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.vat_number"
|
||||
placeholder="LU12345678"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Numbering -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4 pb-2 border-b dark:border-gray-700">
|
||||
Invoice Numbering
|
||||
</h4>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Invoice Prefix
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.invoice_prefix"
|
||||
placeholder="INV"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Example: INV-2024-00001</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Default VAT Rate (%)
|
||||
</label>
|
||||
<select
|
||||
x-model="settingsForm.default_vat_rate"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="17.00">17% (Standard)</option>
|
||||
<option value="14.00">14% (Intermediate)</option>
|
||||
<option value="8.00">8% (Reduced)</option>
|
||||
<option value="3.00">3% (Super-reduced)</option>
|
||||
<option value="0.00">0% (Exempt)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bank Details -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4 pb-2 border-b dark:border-gray-700">
|
||||
Bank Details
|
||||
</h4>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Bank Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.bank_name"
|
||||
placeholder="BCEE Luxembourg"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
IBAN
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.bank_iban"
|
||||
placeholder="LU00 0000 0000 0000 0000"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
BIC/SWIFT
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.bank_bic"
|
||||
placeholder="BCEELULL"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Payment Terms
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="settingsForm.payment_terms"
|
||||
placeholder="Net 30 days"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mb-8">
|
||||
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4 pb-2 border-b dark:border-gray-700">
|
||||
Invoice Footer
|
||||
</h4>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Footer Text
|
||||
</label>
|
||||
<textarea
|
||||
x-model="settingsForm.footer_text"
|
||||
rows="3"
|
||||
placeholder="Thank you for your business!"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="savingSettings"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!savingSettings" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="savingSettings" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="savingSettings ? 'Saving...' : 'Save Settings'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Invoice Modal -->
|
||||
{% call form_modal('createInvoiceModal', 'Create Invoice', show_var='showCreateModal', submit_action='createInvoice()', submit_text='Create Invoice', loading_var='creatingInvoice', loading_text='Creating...', size='sm') %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Order ID <span class="text-red-500">*</span>
|
||||
</label>
|
||||
{# noqa: FE-008 - Order ID is a reference field, not a quantity stepper #}
|
||||
<input
|
||||
type="number"
|
||||
x-model="createForm.order_id"
|
||||
required
|
||||
placeholder="Enter order ID"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter the order ID to generate an invoice for
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Notes (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
x-model="createForm.notes"
|
||||
rows="3"
|
||||
placeholder="Any additional notes for the invoice..."
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
455
app/modules/orders/templates/orders/vendor/order-detail.html
vendored
Normal file
455
app/modules/orders/templates/orders/vendor/order-detail.html
vendored
Normal file
@@ -0,0 +1,455 @@
|
||||
{# 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 Button and Header -->
|
||||
<div class="mb-6">
|
||||
<a :href="`/vendor/${vendorCode}/orders`"
|
||||
class="inline-flex items-center text-sm text-gray-600 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 mb-4">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-1')"></span>
|
||||
Back to Orders
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% call page_header_flex(title='Order Details', subtitle='View and manage order') %}
|
||||
<div class="flex items-center gap-2" x-show="!loading && order">
|
||||
<!-- Status Badge -->
|
||||
<span
|
||||
:class="{
|
||||
'px-3 py-1 text-sm font-semibold 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',
|
||||
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': getStatusColor(order?.status) === 'gray'
|
||||
}"
|
||||
x-text="getStatusLabel(order?.status)"
|
||||
></span>
|
||||
|
||||
<!-- Update Status Button -->
|
||||
<button
|
||||
@click="showStatusModal = true"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<span x-html="$icon('pencil-square', 'w-4 h-4 inline mr-1')"></span>
|
||||
Update Status
|
||||
</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading order details...') }}
|
||||
{{ error_state('Error loading order') }}
|
||||
|
||||
<!-- Main Content -->
|
||||
<div x-show="!loading && !error && order" class="grid gap-6 lg:grid-cols-3">
|
||||
<!-- Left Column: Order Info -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Order Summary Card -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Order <span x-text="order?.order_number"></span>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Placed on <span x-text="formatDateTime(order?.order_date)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Channel</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200 capitalize" x-text="order?.channel || 'direct'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Items</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="order?.items?.length || 0"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Subtotal</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="formatPrice(order?.subtotal_cents)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Total</p>
|
||||
<p class="text-lg font-bold text-purple-600 dark:text-purple-400" x-text="formatPrice(order?.total_amount_cents)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Order Items</h3>
|
||||
<template x-if="shipmentStatus">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="shipmentStatus.total_shipped_units"></span>/<span x-text="shipmentStatus.total_ordered_units"></span> units shipped
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="item in order?.items || []" :key="item.id">
|
||||
<div class="p-4 flex items-start gap-4">
|
||||
<!-- Product Info -->
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="item.product_name"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-show="item.product_sku">SKU: <span x-text="item.product_sku"></span></span>
|
||||
<span x-show="item.gtin"> | GTIN: <span x-text="item.gtin"></span></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mt-1">
|
||||
<span x-text="formatPrice(item.unit_price_cents)"></span> x <span x-text="item.quantity"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shipment Status -->
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-gray-700 dark:text-gray-200" x-text="formatPrice(item.total_price_cents)"></p>
|
||||
<template x-if="getItemShipmentStatus(item.id)">
|
||||
<div class="mt-1">
|
||||
<template x-if="getItemShipmentStatus(item.id).is_fully_shipped">
|
||||
<span class="px-2 py-0.5 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100">
|
||||
Shipped
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="getItemShipmentStatus(item.id).is_partially_shipped">
|
||||
<span class="px-2 py-0.5 text-xs font-medium text-orange-700 bg-orange-100 rounded-full dark:bg-orange-700 dark:text-orange-100">
|
||||
<span x-text="getItemShipmentStatus(item.id).shipped_quantity"></span>/<span x-text="getItemShipmentStatus(item.id).quantity"></span> shipped
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!getItemShipmentStatus(item.id).is_fully_shipped && !getItemShipmentStatus(item.id).is_partially_shipped">
|
||||
<span class="px-2 py-0.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100">
|
||||
Pending
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Ship Item Button -->
|
||||
<template x-if="canShipItem(item.id) && order?.status !== 'shipped'">
|
||||
<button
|
||||
@click="shipItem(item.id)"
|
||||
:disabled="saving"
|
||||
class="mt-2 px-3 py-1 text-xs font-medium text-white bg-indigo-600 rounded hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-html="$icon('truck', 'w-3 h-3 inline mr-1')"></span>
|
||||
Ship
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
|
||||
<span class="text-gray-700 dark:text-gray-200" x-text="formatPrice(order?.subtotal_cents)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.tax_amount_cents">
|
||||
<span class="text-gray-600 dark:text-gray-400">Tax</span>
|
||||
<span class="text-gray-700 dark:text-gray-200" x-text="formatPrice(order?.tax_amount_cents)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.shipping_amount_cents">
|
||||
<span class="text-gray-600 dark:text-gray-400">Shipping</span>
|
||||
<span class="text-gray-700 dark:text-gray-200" x-text="formatPrice(order?.shipping_amount_cents)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.discount_amount_cents">
|
||||
<span class="text-gray-600 dark:text-gray-400">Discount</span>
|
||||
<span class="text-green-600 dark:text-green-400">-<span x-text="formatPrice(order?.discount_amount_cents)"></span></span>
|
||||
</div>
|
||||
<div class="flex justify-between pt-2 border-t border-gray-200 dark:border-gray-600 font-semibold">
|
||||
<span class="text-gray-700 dark:text-gray-200">Total</span>
|
||||
<span class="text-purple-600 dark:text-purple-400" x-text="formatPrice(order?.total_amount_cents)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracking Info -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden" x-show="order?.tracking_number || order?.shipped_at">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Shipping & Tracking</h3>
|
||||
</div>
|
||||
<div class="p-6 grid grid-cols-2 gap-4">
|
||||
<div x-show="order?.tracking_provider">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Carrier</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="order?.tracking_provider"></p>
|
||||
</div>
|
||||
<div x-show="order?.tracking_number">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Tracking Number</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="order?.tracking_number"></p>
|
||||
</div>
|
||||
<div x-show="order?.shipped_at">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Shipped At</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="formatDateTime(order?.shipped_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Customer & Actions -->
|
||||
<div class="space-y-6">
|
||||
<!-- Customer Info -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Customer</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="`${order?.customer_first_name || ''} ${order?.customer_last_name || ''}`"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="order?.customer_email"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="order?.customer_phone" x-show="order?.customer_phone"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Address -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Shipping Address</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-200" x-text="`${order?.ship_first_name || ''} ${order?.ship_last_name || ''}`"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="order?.ship_company" x-show="order?.ship_company"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="order?.ship_address_line_1"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="order?.ship_address_line_2" x-show="order?.ship_address_line_2"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="order?.ship_postal_code"></span> <span x-text="order?.ship_city"></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="order?.ship_country_iso"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Section -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Invoice</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<template x-if="invoice">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-200">
|
||||
<span x-text="invoice.invoice_number"></span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Status: <span class="capitalize" x-text="invoice.status"></span>
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
:href="`/vendor/${vendorCode}/invoices?invoice_id=${invoice.id}`"
|
||||
class="px-3 py-1.5 text-xs font-medium text-purple-600 bg-purple-100 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300"
|
||||
>
|
||||
View Invoice
|
||||
</a>
|
||||
<button
|
||||
@click="downloadInvoicePdf()"
|
||||
:disabled="downloadingPdf"
|
||||
class="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!downloadingPdf">Download PDF</span>
|
||||
<span x-show="downloadingPdf">Downloading...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!invoice && order?.status !== 'pending'">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">No invoice created yet</p>
|
||||
<button
|
||||
@click="createInvoice()"
|
||||
:disabled="creatingInvoice"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!creatingInvoice">
|
||||
<span x-html="$icon('document-plus', 'w-4 h-4 inline mr-1')"></span>
|
||||
Create Invoice
|
||||
</span>
|
||||
<span x-show="creatingInvoice">Creating...</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!invoice && order?.status === 'pending'">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Confirm the order first before creating an invoice
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Quick Actions</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
<template x-if="order?.status === 'pending'">
|
||||
<button
|
||||
@click="updateOrderStatus('processing')"
|
||||
:disabled="saving"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-html="$icon('check-circle', 'w-4 h-4 inline mr-1')"></span>
|
||||
Confirm Order
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="order?.status === 'processing' || order?.status === 'partially_shipped'">
|
||||
<button
|
||||
@click="showShipAllModal = true"
|
||||
:disabled="saving"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-html="$icon('truck', 'w-4 h-4 inline mr-1')"></span>
|
||||
Ship All Remaining
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="order?.status === 'shipped'">
|
||||
<button
|
||||
@click="updateOrderStatus('delivered')"
|
||||
:disabled="saving"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-html="$icon('check', 'w-4 h-4 inline mr-1')"></span>
|
||||
Mark as Delivered
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="!['cancelled', 'refunded', 'delivered'].includes(order?.status)">
|
||||
<button
|
||||
@click="updateOrderStatus('cancelled')"
|
||||
:disabled="saving"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 disabled:opacity-50 dark:bg-red-900 dark:text-red-300 dark:hover:bg-red-800"
|
||||
>
|
||||
<span x-html="$icon('x-circle', 'w-4 h-4 inline mr-1')"></span>
|
||||
Cancel Order
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden" x-show="order?.customer_notes || order?.internal_notes">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Notes</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div x-show="order?.customer_notes">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Customer Notes</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-200" x-text="order?.customer_notes"></p>
|
||||
</div>
|
||||
<div x-show="order?.internal_notes">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Internal Notes</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-200" x-text="order?.internal_notes"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Update Modal -->
|
||||
{% call modal_simple('updateOrderStatusModal', 'Update Order Status', show_var='showStatusModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Status</label>
|
||||
<select
|
||||
x-model="newStatus"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<template x-for="status in statuses" :key="status.value">
|
||||
<option :value="status.value" x-text="status.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<!-- Tracking Info (shown when shipping) -->
|
||||
<template x-if="newStatus === 'shipped'">
|
||||
<div class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tracking Number</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="trackingNumber"
|
||||
placeholder="Enter tracking number"
|
||||
class="w-full px-3 py-2 text-sm border rounded-lg focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Carrier</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="trackingProvider"
|
||||
placeholder="e.g., DHL, PostNL"
|
||||
class="w-full px-3 py-2 text-sm border rounded-lg focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showStatusModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>Cancel</button>
|
||||
<button
|
||||
@click="confirmStatusUpdate()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>Update</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Ship All Modal -->
|
||||
{% call modal_simple('shipAllModal', 'Ship All Items', show_var='showShipAllModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
This will ship all remaining items and mark the order as shipped.
|
||||
</p>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tracking Number</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="trackingNumber"
|
||||
placeholder="Enter tracking number"
|
||||
class="w-full px-3 py-2 text-sm border rounded-lg focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Carrier</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="trackingProvider"
|
||||
placeholder="e.g., DHL, PostNL"
|
||||
class="w-full px-3 py-2 text-sm border rounded-lg focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showShipAllModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>Cancel</button>
|
||||
<button
|
||||
@click="shipAllItems()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>Ship All</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Pass order ID to JavaScript
|
||||
window.orderDetailData = {
|
||||
orderId: {{ order_id }}
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('orders_static', path='vendor/js/order-detail.js') }}"></script>
|
||||
{% endblock %}
|
||||
334
app/modules/orders/templates/orders/vendor/orders.html
vendored
Normal file
334
app/modules/orders/templates/orders/vendor/orders.html
vendored
Normal file
@@ -0,0 +1,334 @@
|
||||
{# app/templates/vendor/orders.html #}
|
||||
{% extends "vendor/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||
|
||||
{% block title %}Orders{% endblock %}
|
||||
|
||||
{% block alpine_data %}vendorOrders(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='Orders', subtitle='View and manage your orders') %}
|
||||
<div class="flex items-center gap-4">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadOrders()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading orders...') }}
|
||||
|
||||
{{ error_state('Error loading orders') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Orders -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Orders</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Pending</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.pending">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('arrow-path', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Processing</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.processing">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Completed</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.completed">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input="debouncedSearch()"
|
||||
placeholder="Search by order #, customer..."
|
||||
class="w-full pl-10 pr-4 py-2 text-sm text-gray-700 placeholder-gray-400 bg-gray-50 border border-gray-200 rounded-lg dark:placeholder-gray-500 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="applyFilter()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<template x-for="status in statuses" :key="status.value">
|
||||
<option :value="status.value" x-text="status.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<!-- Date From -->
|
||||
<input
|
||||
type="date"
|
||||
x-model="filters.date_from"
|
||||
@change="applyFilter()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
|
||||
<!-- Date To -->
|
||||
<input
|
||||
type="date"
|
||||
x-model="filters.date_to"
|
||||
@change="applyFilter()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<button
|
||||
x-show="filters.search || filters.status || filters.date_from || filters.date_to"
|
||||
@click="clearFilters()"
|
||||
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div x-show="!loading && selectedOrders.length > 0"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
class="mb-4 p-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
|
||||
<span x-text="selectedOrders.length"></span> order(s) selected
|
||||
</span>
|
||||
<button @click="clearSelection()" class="text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="openBulkStatusModal()"
|
||||
:disabled="saving"
|
||||
class="px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-100 rounded-lg hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800 disabled:opacity-50"
|
||||
>
|
||||
<span x-html="$icon('pencil-square', 'w-4 h-4 inline mr-1')"></span>
|
||||
Update Status
|
||||
</button>
|
||||
<button
|
||||
@click="exportSelectedOrders()"
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
<span x-html="$icon('arrow-down-tray', 'w-4 h-4 inline mr-1')"></span>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders Table -->
|
||||
<div x-show="!loading && !error" class="mb-8">
|
||||
{% call table_wrapper() %}
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
@click="toggleSelectAll()"
|
||||
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-4 py-3">Order #</th>
|
||||
<th class="px-4 py-3">Customer</th>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">Total</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="order in orders" :key="order.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(order.id)}">
|
||||
<!-- Checkbox -->
|
||||
<td class="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(order.id)"
|
||||
@click="toggleSelect(order.id)"
|
||||
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</td>
|
||||
<!-- Order Number -->
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono font-semibold" x-text="order.order_number || `#${order.id}`"></span>
|
||||
</td>
|
||||
<!-- Customer -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm">
|
||||
<p class="font-medium" x-text="order.customer_name || 'Guest'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="order.customer_email || ''"></p>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Date -->
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(order.created_at)"></td>
|
||||
<!-- Total -->
|
||||
<td class="px-4 py-3 text-sm font-semibold" x-text="formatPrice(order.total)"></td>
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
:class="{
|
||||
'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',
|
||||
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': getStatusColor(order.status) === 'gray'
|
||||
}"
|
||||
x-text="getStatusLabel(order.status)"
|
||||
></span>
|
||||
</td>
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<button
|
||||
@click="viewOrder(order)"
|
||||
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="openStatusModal(order)"
|
||||
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
title="Update Status"
|
||||
>
|
||||
<span x-html="$icon('pencil-square', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<!-- Empty State -->
|
||||
<tr x-show="orders.length === 0">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('document-text', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
||||
<p class="text-lg font-medium">No orders found</p>
|
||||
<p class="text-sm">Orders will appear here when customers make purchases</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
|
||||
<!-- Status Update Modal -->
|
||||
{% call modal_simple('updateStatusModal', 'Update Order Status', show_var='showStatusModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<template x-if="selectedOrder">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Update status for order <span class="font-semibold" x-text="selectedOrder.order_number || `#${selectedOrder.id}`"></span>
|
||||
</p>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Status</label>
|
||||
<select
|
||||
x-model="newStatus"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<template x-for="status in statuses" :key="status.value">
|
||||
<option :value="status.value" x-text="status.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showStatusModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>Cancel</button>
|
||||
<button
|
||||
@click="updateStatus()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>Update</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Bulk Status Update Modal -->
|
||||
{% call modal_simple('bulkUpdateStatusModal', 'Bulk Update Status', show_var='showBulkStatusModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Update status for <span class="font-semibold" x-text="selectedOrders.length"></span> selected order(s)
|
||||
</p>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Status</label>
|
||||
<select
|
||||
x-model="bulkStatus"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<option value="">Select a status...</option>
|
||||
<template x-for="status in statuses" :key="status.value">
|
||||
<option :value="status.value" x-text="status.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showBulkStatusModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>Cancel</button>
|
||||
<button
|
||||
@click="bulkUpdateStatus()"
|
||||
:disabled="saving || !bulkStatus"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>Update All</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('orders_static', path='vendor/js/orders.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user