diff --git a/app/api/v1/admin/customers.py b/app/api/v1/admin/customers.py new file mode 100644 index 00000000..666c34b6 --- /dev/null +++ b/app/api/v1/admin/customers.py @@ -0,0 +1,112 @@ +# app/api/v1/admin/customers.py +""" +Customer management endpoints for admin. + +Provides admin-level access to customer data across all vendors. +""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.services.admin_customer_service import admin_customer_service +from models.database.user import User +from models.schema.customer import ( + CustomerDetailResponse, + CustomerListResponse, + CustomerMessageResponse, + CustomerStatisticsResponse, +) + +router = APIRouter(prefix="/customers") + + +# ============================================================================ +# List Customers +# ============================================================================ + + +@router.get("", response_model=CustomerListResponse) +def list_customers( + vendor_id: int | None = Query(None, description="Filter by vendor ID"), + search: str = Query("", description="Search by email, name, or customer number"), + is_active: bool | None = Query(None, description="Filter by active status"), + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> CustomerListResponse: + """ + Get paginated list of customers across all vendors. + + Admin can filter by vendor, search, and active status. + """ + customers, total = admin_customer_service.list_customers( + db=db, + vendor_id=vendor_id, + search=search if search else None, + is_active=is_active, + skip=skip, + limit=limit, + ) + + return CustomerListResponse( + customers=customers, + total=total, + skip=skip, + limit=limit, + ) + + +# ============================================================================ +# Customer Statistics +# ============================================================================ + + +@router.get("/stats", response_model=CustomerStatisticsResponse) +def get_customer_stats( + vendor_id: int | None = Query(None, description="Filter by vendor ID"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> CustomerStatisticsResponse: + """Get customer statistics.""" + stats = admin_customer_service.get_customer_stats(db=db, vendor_id=vendor_id) + return CustomerStatisticsResponse(**stats) + + +# ============================================================================ +# Get Single Customer +# ============================================================================ + + +@router.get("/{customer_id}", response_model=CustomerDetailResponse) +def get_customer( + customer_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> CustomerDetailResponse: + """Get customer details by ID.""" + customer = admin_customer_service.get_customer(db=db, customer_id=customer_id) + return CustomerDetailResponse(**customer) + + +# ============================================================================ +# Toggle Customer Status +# ============================================================================ + + +@router.patch("/{customer_id}/toggle-status", response_model=CustomerMessageResponse) +def toggle_customer_status( + customer_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> CustomerMessageResponse: + """Toggle customer active status.""" + result = admin_customer_service.toggle_customer_status( + db=db, + customer_id=customer_id, + admin_email=current_admin.email, + ) + db.commit() + return CustomerMessageResponse(message=result["message"]) diff --git a/app/services/admin_customer_service.py b/app/services/admin_customer_service.py new file mode 100644 index 00000000..31912e06 --- /dev/null +++ b/app/services/admin_customer_service.py @@ -0,0 +1,242 @@ +# app/services/admin_customer_service.py +""" +Admin customer management service. + +Handles customer operations for admin users across all vendors. +""" + +import logging +from typing import Any + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.exceptions.customer import CustomerNotFoundException +from models.database.customer import Customer +from models.database.vendor import Vendor + +logger = logging.getLogger(__name__) + + +class AdminCustomerService: + """Service for admin-level customer management across vendors.""" + + def list_customers( + self, + db: Session, + vendor_id: int | None = None, + search: str | None = None, + is_active: bool | None = None, + skip: int = 0, + limit: int = 20, + ) -> tuple[list[dict[str, Any]], int]: + """ + Get paginated list of customers across all vendors. + + Args: + db: Database session + vendor_id: Optional vendor ID filter + search: Search by email, name, or customer number + is_active: Filter by active status + skip: Number of records to skip + limit: Maximum records to return + + Returns: + Tuple of (customers list, total count) + """ + # Build query + query = db.query(Customer).join(Vendor, Customer.vendor_id == Vendor.id) + + # Apply filters + if vendor_id: + query = query.filter(Customer.vendor_id == vendor_id) + + if search: + search_term = f"%{search}%" + query = query.filter( + (Customer.email.ilike(search_term)) + | (Customer.first_name.ilike(search_term)) + | (Customer.last_name.ilike(search_term)) + | (Customer.customer_number.ilike(search_term)) + ) + + if is_active is not None: + query = query.filter(Customer.is_active == is_active) + + # Get total count + total = query.count() + + # Get paginated results with vendor info + customers = ( + query.add_columns(Vendor.name.label("vendor_name"), Vendor.vendor_code) + .order_by(Customer.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + # Format response + result = [] + for row in customers: + customer = row[0] + vendor_name = row[1] + vendor_code = row[2] + + customer_dict = { + "id": customer.id, + "vendor_id": customer.vendor_id, + "email": customer.email, + "first_name": customer.first_name, + "last_name": customer.last_name, + "phone": customer.phone, + "customer_number": customer.customer_number, + "marketing_consent": customer.marketing_consent, + "preferred_language": customer.preferred_language, + "last_order_date": customer.last_order_date, + "total_orders": customer.total_orders, + "total_spent": float(customer.total_spent) if customer.total_spent else 0, + "is_active": customer.is_active, + "created_at": customer.created_at, + "updated_at": customer.updated_at, + "vendor_name": vendor_name, + "vendor_code": vendor_code, + } + result.append(customer_dict) + + return result, total + + def get_customer_stats( + self, + db: Session, + vendor_id: int | None = None, + ) -> dict[str, Any]: + """ + Get customer statistics. + + Args: + db: Database session + vendor_id: Optional vendor ID filter + + Returns: + Dict with customer statistics + """ + query = db.query(Customer) + + if vendor_id: + query = query.filter(Customer.vendor_id == vendor_id) + + total = query.count() + active = query.filter(Customer.is_active == True).count() # noqa: E712 + inactive = query.filter(Customer.is_active == False).count() # noqa: E712 + with_orders = query.filter(Customer.total_orders > 0).count() + + # Total spent across all customers + total_spent_result = query.with_entities(func.sum(Customer.total_spent)).scalar() + total_spent = float(total_spent_result) if total_spent_result else 0 + + # Average order value + total_orders_result = query.with_entities(func.sum(Customer.total_orders)).scalar() + total_orders = int(total_orders_result) if total_orders_result else 0 + avg_order_value = total_spent / total_orders if total_orders > 0 else 0 + + return { + "total": total, + "active": active, + "inactive": inactive, + "with_orders": with_orders, + "total_spent": total_spent, + "total_orders": total_orders, + "avg_order_value": round(avg_order_value, 2), + } + + def get_customer( + self, + db: Session, + customer_id: int, + ) -> dict[str, Any]: + """ + Get customer details by ID. + + Args: + db: Database session + customer_id: Customer ID + + Returns: + Customer dict with vendor info + + Raises: + CustomerNotFoundException: If customer not found + """ + result = ( + db.query(Customer) + .join(Vendor, Customer.vendor_id == Vendor.id) + .add_columns(Vendor.name.label("vendor_name"), Vendor.vendor_code) + .filter(Customer.id == customer_id) + .first() + ) + + if not result: + raise CustomerNotFoundException(str(customer_id)) + + customer = result[0] + return { + "id": customer.id, + "vendor_id": customer.vendor_id, + "email": customer.email, + "first_name": customer.first_name, + "last_name": customer.last_name, + "phone": customer.phone, + "customer_number": customer.customer_number, + "marketing_consent": customer.marketing_consent, + "preferred_language": customer.preferred_language, + "last_order_date": customer.last_order_date, + "total_orders": customer.total_orders, + "total_spent": float(customer.total_spent) if customer.total_spent else 0, + "is_active": customer.is_active, + "created_at": customer.created_at, + "updated_at": customer.updated_at, + "vendor_name": result[1], + "vendor_code": result[2], + } + + def toggle_customer_status( + self, + db: Session, + customer_id: int, + admin_email: str, + ) -> dict[str, Any]: + """ + Toggle customer active status. + + Args: + db: Database session + customer_id: Customer ID + admin_email: Admin user email for logging + + Returns: + Dict with customer ID, new status, and message + + Raises: + CustomerNotFoundException: If customer not found + """ + customer = db.query(Customer).filter(Customer.id == customer_id).first() + + if not customer: + raise CustomerNotFoundException(str(customer_id)) + + customer.is_active = not customer.is_active + db.flush() + db.refresh(customer) + + status = "activated" if customer.is_active else "deactivated" + logger.info(f"Customer {customer.email} {status} by admin {admin_email}") + + return { + "id": customer.id, + "is_active": customer.is_active, + "message": f"Customer {status} successfully", + } + + +# Singleton instance +admin_customer_service = AdminCustomerService() diff --git a/app/templates/admin/customers.html b/app/templates/admin/customers.html index 3eff32ab..fb2d5bbc 100644 --- a/app/templates/admin/customers.html +++ b/app/templates/admin/customers.html @@ -1,42 +1,292 @@ {# app/templates/admin/customers.html #} {% extends "admin/base.html" %} {% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} {% block title %}Customers{% endblock %} {% block alpine_data %}adminCustomers(){% endblock %} {% block content %} -{{ page_header('Customers', subtitle='Manage platform customers') }} +{{ page_header('Customer Management') }} - -
-
- -

- Customers Management -

-

- Customer management features coming soon. -

-

- This section will allow you to view and manage customers across all vendors. -

+{{ loading_state('Loading customers...') }} + +{{ error_state('Error loading customers') }} + + +
+ +
+
+ +
+
+

+ Total Customers +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Active +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ With Orders +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Total Revenue +

+

+ 0 +

+
+
+
+ + +
+
+ +
+
+ +
+ +
+
+
+ + +
+ + + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
CustomerVendorCustomer #OrdersTotal SpentStatusJoinedActions
+
+ + +
+ + Showing - of customers + +
+ + + + + +
{% endblock %} -{% block extra_scripts %} - +{% block page_scripts %} + {% endblock %} diff --git a/models/schema/customer.py b/models/schema/customer.py index fb9efbd0..018b308b 100644 --- a/models/schema/customer.py +++ b/models/schema/customer.py @@ -248,8 +248,54 @@ class CustomerOrdersResponse(BaseModel): class CustomerStatisticsResponse(BaseModel): """Response for customer statistics.""" + total: int = 0 + active: int = 0 + inactive: int = 0 + with_orders: int = 0 + total_spent: float = 0.0 + total_orders: int = 0 + avg_order_value: float = 0.0 + + +# ============================================================================ +# Admin Customer Management Response Schemas +# ============================================================================ + + +class AdminCustomerItem(BaseModel): + """Admin customer list item with vendor info.""" + + id: int + vendor_id: int + email: str + first_name: str | None = None + last_name: str | None = None + phone: str | None = None + customer_number: str + marketing_consent: bool = False + preferred_language: str | None = None + last_order_date: datetime | None = None total_orders: int = 0 total_spent: float = 0.0 - average_order_value: float = 0.0 - last_order_date: datetime | None = None - message: str | None = None + is_active: bool = True + created_at: datetime + updated_at: datetime + vendor_name: str | None = None + vendor_code: str | None = None + + model_config = {"from_attributes": True} + + +class CustomerListResponse(BaseModel): + """Admin paginated customer list with skip/limit.""" + + customers: list[AdminCustomerItem] = [] + total: int = 0 + skip: int = 0 + limit: int = 20 + + +class CustomerDetailResponse(AdminCustomerItem): + """Detailed customer response for admin.""" + + pass diff --git a/static/admin/js/customers.js b/static/admin/js/customers.js new file mode 100644 index 00000000..eec413bf --- /dev/null +++ b/static/admin/js/customers.js @@ -0,0 +1,237 @@ +// static/admin/js/customers.js +/** + * Admin customer management page logic + */ + +// Create logger for this module +const customersLog = window.LogConfig?.createLogger('CUSTOMERS') || console; + +function adminCustomers() { + return { + // Inherit base layout state + ...data(), + + // Page identifier + currentPage: 'customers', + + // Loading states + loading: true, + loadingCustomers: false, + + // Error state + error: '', + + // Data + customers: [], + vendors: [], + stats: { + total: 0, + active: 0, + inactive: 0, + with_orders: 0, + total_spent: 0, + total_orders: 0, + avg_order_value: 0 + }, + + // Pagination + page: 1, + limit: 20, + total: 0, + skip: 0, + + // Filters + filters: { + search: '', + is_active: '', + vendor_id: '' + }, + + // Computed: total pages + get totalPages() { + return Math.ceil(this.total / this.limit); + }, + + async init() { + customersLog.debug('Customers page initialized'); + + // Load vendors for filter dropdown + await this.loadVendors(); + + // Load initial data + await Promise.all([ + this.loadCustomers(), + this.loadStats() + ]); + + this.loading = false; + }, + + /** + * Load vendors for filter dropdown + */ + async loadVendors() { + try { + const response = await apiClient.get('/admin/vendors?limit=100'); + this.vendors = response.vendors || []; + } catch (error) { + customersLog.error('Failed to load vendors:', error); + this.vendors = []; + } + }, + + /** + * Load customers with current filters + */ + async loadCustomers() { + this.loadingCustomers = true; + this.error = ''; + this.skip = (this.page - 1) * this.limit; + + try { + const params = new URLSearchParams({ + skip: this.skip.toString(), + limit: this.limit.toString() + }); + + if (this.filters.search) { + params.append('search', this.filters.search); + } + + if (this.filters.is_active !== '') { + params.append('is_active', this.filters.is_active); + } + + if (this.filters.vendor_id) { + params.append('vendor_id', this.filters.vendor_id); + } + + const response = await apiClient.get(`/admin/customers?${params}`); + this.customers = response.customers || []; + this.total = response.total || 0; + } catch (error) { + customersLog.error('Failed to load customers:', error); + this.error = error.message || 'Failed to load customers'; + this.customers = []; + } finally { + this.loadingCustomers = false; + } + }, + + /** + * Load customer statistics + */ + async loadStats() { + try { + const params = new URLSearchParams(); + if (this.filters.vendor_id) { + params.append('vendor_id', this.filters.vendor_id); + } + + const response = await apiClient.get(`/admin/customers/stats?${params}`); + this.stats = response; + } catch (error) { + customersLog.error('Failed to load stats:', error); + } + }, + + /** + * Reset pagination and reload + */ + async resetAndLoad() { + this.page = 1; + await Promise.all([ + this.loadCustomers(), + this.loadStats() + ]); + }, + + /** + * Go to specific page + */ + goToPage(p) { + this.page = p; + this.loadCustomers(); + }, + + /** + * Get array of page numbers to display + */ + getPageNumbers() { + const total = this.totalPages; + const current = this.page; + const maxVisible = 5; + + if (total <= maxVisible) { + return Array.from({length: total}, (_, i) => i + 1); + } + + const half = Math.floor(maxVisible / 2); + let start = Math.max(1, current - half); + let end = Math.min(total, start + maxVisible - 1); + + if (end - start < maxVisible - 1) { + start = Math.max(1, end - maxVisible + 1); + } + + return Array.from({length: end - start + 1}, (_, i) => start + i); + }, + + /** + * Toggle customer active status + */ + async toggleStatus(customer) { + const action = customer.is_active ? 'deactivate' : 'activate'; + if (!confirm(`Are you sure you want to ${action} this customer?`)) { + return; + } + + try { + const response = await apiClient.patch(`/admin/customers/${customer.id}/toggle-status`); + customer.is_active = response.is_active; + + // Update stats + if (response.is_active) { + this.stats.active++; + this.stats.inactive--; + } else { + this.stats.active--; + this.stats.inactive++; + } + + customersLog.info(response.message); + } catch (error) { + customersLog.error('Failed to toggle status:', error); + alert(error.message || 'Failed to toggle customer status'); + } + }, + + /** + * Format currency for display + */ + formatCurrency(amount) { + if (amount == null) return '-'; + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR' + }).format(amount); + }, + + /** + * Format date for display + */ + formatDate(dateString) { + if (!dateString) return '-'; + try { + const date = new Date(dateString); + return date.toLocaleDateString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch { + return dateString; + } + } + }; +}