feat: add admin customer management

- Add admin customers API endpoints
- Add AdminCustomerService for customer operations
- Enhance customers.html template with management features
- Add customers.js Alpine component
- Add customer list schemas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-21 14:12:02 +01:00
parent 02edea7cb3
commit 5c0c92e94b
5 changed files with 917 additions and 30 deletions

View File

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

View File

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

View File

@@ -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') }}
<!-- Empty State -->
<div class="px-4 py-12 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="text-center">
<span x-html="$icon('user-group', 'mx-auto h-12 w-12 text-gray-400')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">
Customers Management
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Customer management features coming soon.
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
This section will allow you to view and manage customers across all vendors.
</p>
{{ loading_state('Loading customers...') }}
{{ error_state('Error loading customers') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Customers -->
<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('user-group', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Customers
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total || 0">
0
</p>
</div>
</div>
<!-- Card: Active Customers -->
<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('user-check', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active || 0">
0
</p>
</div>
</div>
<!-- Card: With 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('shopping-bag', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
With Orders
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.with_orders || 0">
0
</p>
</div>
</div>
<!-- Card: Total Spent -->
<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('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="formatCurrency(stats.total_spent || 0)">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters -->
<div x-show="!loading" class="mb-6 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Search Bar -->
<div class="flex-1 max-w-md">
<div class="relative">
<input
type="text"
x-model="filters.search"
@input.debounce.300ms="resetAndLoad()"
placeholder="Search by name, email, or customer number..."
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 focus:shadow-outline-purple dark:bg-gray-700 dark:text-gray-300"
>
<div 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>
</div>
</div>
</div>
<!-- Status Filter -->
<div class="flex items-center gap-4">
<select
x-model="filters.is_active"
@change="resetAndLoad()"
class="px-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"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<select
x-model="filters.vendor_id"
@change="resetAndLoad()"
class="px-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"
>
<option value="">All Vendors</option>
<template x-for="vendor in vendors" :key="vendor.id">
<option :value="vendor.id" x-text="vendor.name"></option>
</template>
</select>
</div>
</div>
</div>
<!-- Customers Table -->
<div x-show="!loading" class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<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">Customer</th>
<th class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">Customer #</th>
<th class="px-4 py-3">Orders</th>
<th class="px-4 py-3">Total Spent</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Joined</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">
<!-- Loading state -->
<template x-if="loadingCustomers && customers.length === 0">
<tr>
<td colspan="8" 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 customers...</p>
</td>
</tr>
</template>
<!-- Empty state -->
<template x-if="!loadingCustomers && customers.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('user-group', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
<p class="font-medium">No customers found</p>
<p class="text-sm mt-1">Try adjusting your search or filters</p>
</td>
</tr>
</template>
<!-- Customer rows -->
<template x-for="customer in customers" :key="customer.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<!-- Customer -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span x-html="$icon('user', 'w-4 h-4 text-gray-500')"></span>
</div>
<div>
<p class="font-semibold" x-text="customer.first_name && customer.last_name ? customer.first_name + ' ' + customer.last_name : customer.email"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="customer.email"></p>
</div>
</div>
</td>
<!-- Vendor -->
<td class="px-4 py-3 text-sm">
<span x-text="customer.vendor_code || customer.vendor_name || '-'"></span>
</td>
<!-- Customer Number -->
<td class="px-4 py-3 text-sm">
<span class="font-mono text-xs" x-text="customer.customer_number"></span>
</td>
<!-- Orders -->
<td class="px-4 py-3 text-sm">
<span x-text="customer.total_orders || 0"></span>
</td>
<!-- Total Spent -->
<td class="px-4 py-3 text-sm">
<span x-text="formatCurrency(customer.total_spent || 0)"></span>
</td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span
class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="customer.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="customer.is_active ? 'Active' : 'Inactive'"
></span>
</td>
<!-- Joined -->
<td class="px-4 py-3 text-sm">
<span x-text="formatDate(customer.created_at)"></span>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="toggleStatus(customer)"
class="px-2 py-1 text-xs font-medium leading-5 rounded-lg"
:class="customer.is_active
? 'text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-red-700/50'
: 'text-green-600 hover:bg-green-100 dark:text-green-400 dark:hover:bg-green-700/50'"
:title="customer.is_active ? 'Deactivate' : 'Activate'"
>
<span x-html="customer.is_active ? $icon('x-circle', 'w-4 h-4') : $icon('check-circle', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Numbered Pagination -->
<div x-show="total > limit" class="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-3 border-t dark:border-gray-700">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing <span x-text="skip + 1"></span>-<span x-text="Math.min(skip + limit, total)"></span> of <span x-text="total"></span> customers
</span>
<div class="flex items-center gap-1">
<button
@click="page = 1; loadCustomers()"
:disabled="page <= 1"
class="px-2 py-1 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-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
title="First page"
>
<span x-html="$icon('chevron-double-left', 'w-4 h-4')"></span>
</button>
<button
@click="page--; loadCustomers()"
:disabled="page <= 1"
class="px-2 py-1 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-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
title="Previous page"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
<template x-for="p in getPageNumbers()" :key="p">
<button
@click="goToPage(p)"
class="px-3 py-1 text-sm font-medium rounded-md border transition-colors"
:class="p === page
? 'bg-purple-600 text-white border-purple-600 dark:bg-purple-500 dark:border-purple-500'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
x-text="p"
></button>
</template>
<button
@click="page++; loadCustomers()"
:disabled="page >= totalPages"
class="px-2 py-1 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-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
title="Next page"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
<button
@click="page = totalPages; loadCustomers()"
:disabled="page >= totalPages"
class="px-2 py-1 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-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
title="Last page"
>
<span x-html="$icon('chevron-double-right', 'w-4 h-4')"></span>
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function adminCustomers() {
return {
...data(),
currentPage: 'customers',
init() {
console.log('Customers page initialized');
}
};
}
</script>
{% block page_scripts %}
<script src="{{ url_for('static', path='admin/js/customers.js') }}"></script>
{% endblock %}

View File

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

View File

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