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:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -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",
)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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."""

View 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),
)

View 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),
)

View 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),
)

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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

View File

@@ -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()

View 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 %}

View 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>
&times;
<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 %}

View 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> &times;
<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 %}

View 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 %}

View 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 %}

View 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 %}