feat: consolidate media service, add merchant users page, fix metrics overlap
- Merge ImageService into MediaService with WebP variant generation, DB-backed storage stats, and module-driven media usage discovery via new MediaUsageProviderProtocol - Add merchant users admin page with scoped user listing, stats endpoint, template, JS, and i18n strings (de/en/fr/lb) - Fix merchant user metrics so Owners and Team Members are mutually exclusive (filter team_members on user_type="member" and exclude owner IDs) ensuring stat cards add up correctly - Update billing and monitoring services to use media_service - Update subscription-billing and feature-gating docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,7 @@ tenancy_module = ModuleDefinition(
|
||||
"merchants",
|
||||
"stores",
|
||||
"admin-users",
|
||||
"merchant-users",
|
||||
],
|
||||
FrontendType.STORE: [
|
||||
"team",
|
||||
@@ -95,11 +96,10 @@ tenancy_module = ModuleDefinition(
|
||||
menus={
|
||||
FrontendType.ADMIN: [
|
||||
MenuSectionDefinition(
|
||||
id="superAdmin",
|
||||
label_key="tenancy.menu.super_admin",
|
||||
icon="shield",
|
||||
id="userManagement",
|
||||
label_key="tenancy.menu.user_management",
|
||||
icon="users",
|
||||
order=10,
|
||||
is_super_admin_only=True,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="admin-users",
|
||||
@@ -108,6 +108,15 @@ tenancy_module = ModuleDefinition(
|
||||
route="/admin/admin-users",
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
is_super_admin_only=True,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="merchant-users",
|
||||
label_key="tenancy.menu.merchant_users",
|
||||
icon="user-group",
|
||||
route="/admin/merchant-users",
|
||||
order=20,
|
||||
is_mandatory=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "Benutzerverwaltung",
|
||||
"merchant_users": "Händler-Benutzer"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Mitglieder",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "User Management",
|
||||
"merchant_users": "Merchant Users"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Members",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "Gestion des utilisateurs",
|
||||
"merchant_users": "Utilisateurs marchands"
|
||||
},
|
||||
"team": {
|
||||
"title": "Équipe",
|
||||
"members": "Membres",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "Benotzerverwaltung",
|
||||
"merchant_users": "Händler-Benotzer"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Memberen",
|
||||
|
||||
@@ -38,6 +38,7 @@ def get_all_users(
|
||||
per_page: int = Query(10, ge=1, le=100),
|
||||
search: str = Query("", description="Search by username or email"),
|
||||
role: str = Query("", description="Filter by role"),
|
||||
scope: str = Query("", description="Filter scope: 'merchant' for merchant owners and team members"),
|
||||
is_active: str = Query("", description="Filter by active status"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
@@ -54,11 +55,35 @@ def get_all_users(
|
||||
per_page=per_page,
|
||||
search=search if search else None,
|
||||
role=role if role else None,
|
||||
scope=scope if scope else None,
|
||||
is_active=is_active_bool,
|
||||
)
|
||||
|
||||
if scope == "merchant":
|
||||
items = [
|
||||
UserDetailResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
last_login=user.last_login,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=user.full_name,
|
||||
is_email_verified=user.is_email_verified,
|
||||
owned_merchants_count=len(user.owned_merchants) if user.owned_merchants else 0,
|
||||
store_memberships_count=len(user.store_memberships) if user.store_memberships else 0,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
else:
|
||||
items = [UserResponse.model_validate(user) for user in users]
|
||||
|
||||
return UserListResponse(
|
||||
items=[UserResponse.model_validate(user) for user in users],
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
@@ -161,6 +186,42 @@ def get_user_statistics(
|
||||
return stats
|
||||
|
||||
|
||||
@admin_platform_users_router.get("/merchant-stats")
|
||||
def get_merchant_user_statistics(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get merchant user statistics for admin dashboard.
|
||||
|
||||
Uses the stats_aggregator to get merchant user metrics from the tenancy
|
||||
module's MetricsProvider.
|
||||
"""
|
||||
platform_id = _get_platform_id(request, current_admin)
|
||||
|
||||
metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id)
|
||||
tenancy_metrics = metrics.get("tenancy", [])
|
||||
|
||||
stats = {
|
||||
"merchant_users_total": 0,
|
||||
"merchant_users_active": 0,
|
||||
"merchant_owners": 0,
|
||||
"merchant_team_members": 0,
|
||||
}
|
||||
|
||||
for metric in tenancy_metrics:
|
||||
if metric.key == "tenancy.merchant_users_total":
|
||||
stats["merchant_users_total"] = int(metric.value)
|
||||
elif metric.key == "tenancy.merchant_users_active":
|
||||
stats["merchant_users_active"] = int(metric.value)
|
||||
elif metric.key == "tenancy.merchant_owners":
|
||||
stats["merchant_owners"] = int(metric.value)
|
||||
elif metric.key == "tenancy.merchant_team_members":
|
||||
stats["merchant_team_members"] = int(metric.value)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@admin_platform_users_router.get("/search", response_model=UserSearchResponse)
|
||||
def search_users(
|
||||
q: str = Query(..., min_length=2, description="Search query (username or email)"),
|
||||
|
||||
@@ -243,6 +243,30 @@ async def admin_store_theme_page(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MERCHANT USER MANAGEMENT ROUTES (All Admins)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/merchant-users", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_merchant_users_list_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(
|
||||
require_menu_access("merchant-users", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render merchant users management page.
|
||||
Shows list of all merchant users (owners and store team members).
|
||||
Visible to all admins.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/merchant-users.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN USER MANAGEMENT ROUTES (Super Admin Only)
|
||||
# ============================================================================
|
||||
|
||||
@@ -107,18 +107,31 @@ class AdminService:
|
||||
per_page: int = 10,
|
||||
search: str | None = None,
|
||||
role: str | None = None,
|
||||
scope: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> tuple[list[User], int, int]:
|
||||
"""
|
||||
Get paginated list of users with filtering.
|
||||
|
||||
Args:
|
||||
scope: Optional scope filter. 'merchant' returns users who are
|
||||
merchant owners or store team members.
|
||||
|
||||
Returns:
|
||||
Tuple of (users, total_count, total_pages)
|
||||
"""
|
||||
import math
|
||||
|
||||
from app.modules.tenancy.models import Merchant, StoreUser
|
||||
|
||||
query = db.query(User)
|
||||
|
||||
# Apply scope filter
|
||||
if scope == "merchant":
|
||||
owner_ids = db.query(Merchant.owner_user_id).distinct()
|
||||
team_ids = db.query(StoreUser.user_id).distinct()
|
||||
query = query.filter(User.id.in_(owner_ids.union(team_ids)))
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
search_term = f"%{search.lower()}%"
|
||||
|
||||
@@ -12,6 +12,7 @@ Provides metrics for:
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.contracts.metrics import (
|
||||
@@ -123,7 +124,7 @@ class TenancyMetricsProvider:
|
||||
- Total users
|
||||
- Active users
|
||||
"""
|
||||
from app.modules.tenancy.models import AdminPlatform, User, Store, StorePlatform
|
||||
from app.modules.tenancy.models import AdminPlatform, Merchant, StoreUser, User, Store, StorePlatform
|
||||
|
||||
try:
|
||||
# Store metrics - using StorePlatform junction table
|
||||
@@ -216,6 +217,40 @@ class TenancyMetricsProvider:
|
||||
|
||||
inactive_users = total_users - active_users
|
||||
|
||||
# Merchant user metrics
|
||||
# Owners: distinct users who own a merchant
|
||||
merchant_owners = (
|
||||
db.query(func.count(func.distinct(Merchant.owner_user_id))).scalar() or 0
|
||||
)
|
||||
|
||||
# Team members: distinct StoreUser users who are NOT merchant owners
|
||||
# Uses user_type="member" AND excludes owner user IDs to avoid overlap
|
||||
team_members = (
|
||||
db.query(func.count(func.distinct(StoreUser.user_id)))
|
||||
.filter(
|
||||
StoreUser.user_type == "member",
|
||||
~StoreUser.user_id.in_(db.query(Merchant.owner_user_id)),
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Total: union of both sets (deduplicated)
|
||||
owner_ids = db.query(Merchant.owner_user_id).distinct()
|
||||
team_ids = db.query(StoreUser.user_id).distinct()
|
||||
merchant_users_total = (
|
||||
db.query(func.count(func.distinct(User.id)))
|
||||
.filter(User.id.in_(owner_ids.union(team_ids)))
|
||||
.scalar() or 0
|
||||
)
|
||||
merchant_users_active = (
|
||||
db.query(func.count(func.distinct(User.id)))
|
||||
.filter(
|
||||
User.id.in_(owner_ids.union(team_ids)),
|
||||
User.is_active == True,
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Calculate rates
|
||||
verification_rate = (
|
||||
(verified_stores / total_stores * 100) if total_stores > 0 else 0
|
||||
@@ -317,6 +352,39 @@ class TenancyMetricsProvider:
|
||||
unit="%",
|
||||
description="Percentage of users that are active",
|
||||
),
|
||||
# Merchant user metrics
|
||||
MetricValue(
|
||||
key="tenancy.merchant_users_total",
|
||||
value=merchant_users_total,
|
||||
label="Total Merchant Users",
|
||||
category="tenancy",
|
||||
icon="users",
|
||||
description="Total merchant-related users (owners and team members)",
|
||||
),
|
||||
MetricValue(
|
||||
key="tenancy.merchant_users_active",
|
||||
value=merchant_users_active,
|
||||
label="Active Merchant Users",
|
||||
category="tenancy",
|
||||
icon="user-check",
|
||||
description="Active merchant users",
|
||||
),
|
||||
MetricValue(
|
||||
key="tenancy.merchant_owners",
|
||||
value=merchant_owners,
|
||||
label="Merchant Owners",
|
||||
category="tenancy",
|
||||
icon="briefcase",
|
||||
description="Distinct merchant owners",
|
||||
),
|
||||
MetricValue(
|
||||
key="tenancy.merchant_team_members",
|
||||
value=team_members,
|
||||
label="Team Members",
|
||||
category="tenancy",
|
||||
icon="user-group",
|
||||
description="Distinct store team members",
|
||||
),
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get tenancy platform metrics: {e}")
|
||||
|
||||
230
app/modules/tenancy/static/admin/js/merchant-users.js
Normal file
230
app/modules/tenancy/static/admin/js/merchant-users.js
Normal file
@@ -0,0 +1,230 @@
|
||||
// noqa: js-006 - async init pattern is safe, loadData has try/catch
|
||||
// static/admin/js/merchant-users.js
|
||||
|
||||
// Create custom logger for merchant users
|
||||
const merchantUsersLog = window.LogConfig.createLogger('MERCHANT-USERS');
|
||||
|
||||
function merchantUsersPage() {
|
||||
return {
|
||||
// Inherit base layout functionality
|
||||
...data(),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'merchant-users',
|
||||
|
||||
// State
|
||||
merchantUsers: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
filters: {
|
||||
search: '',
|
||||
is_active: ''
|
||||
},
|
||||
stats: {
|
||||
merchant_users_total: 0,
|
||||
merchant_owners: 0,
|
||||
merchant_team_members: 0,
|
||||
merchant_users_active: 0
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
|
||||
// Initialization
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('tenancy');
|
||||
|
||||
merchantUsersLog.info('=== MERCHANT USERS PAGE INITIALIZING ===');
|
||||
|
||||
// Prevent multiple initializations
|
||||
if (window._merchantUsersInitialized) {
|
||||
merchantUsersLog.warn('Merchant users page already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._merchantUsersInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
await this.loadMerchantUsers();
|
||||
await this.loadStats();
|
||||
|
||||
merchantUsersLog.info('=== MERCHANT USERS PAGE INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
// Format date helper
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
return Utils.formatDate(dateString);
|
||||
},
|
||||
|
||||
// Computed: Total number of pages
|
||||
get totalPages() {
|
||||
return this.pagination.pages;
|
||||
},
|
||||
|
||||
// Computed: Start index for pagination display
|
||||
get startIndex() {
|
||||
if (this.pagination.total === 0) return 0;
|
||||
return (this.pagination.page - 1) * this.pagination.per_page + 1;
|
||||
},
|
||||
|
||||
// Computed: End index for pagination display
|
||||
get endIndex() {
|
||||
const end = this.pagination.page * this.pagination.per_page;
|
||||
return end > this.pagination.total ? this.pagination.total : end;
|
||||
},
|
||||
|
||||
// Computed: Generate page numbers array with ellipsis
|
||||
get pageNumbers() {
|
||||
const pages = [];
|
||||
const totalPages = this.totalPages;
|
||||
const current = this.pagination.page;
|
||||
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
|
||||
if (current > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(totalPages - 1, current + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (current < totalPages - 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
},
|
||||
|
||||
// Load merchant users from API
|
||||
async loadMerchantUsers() {
|
||||
merchantUsersLog.info('Loading merchant users...');
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', this.pagination.page);
|
||||
params.append('per_page', this.pagination.per_page);
|
||||
params.append('scope', 'merchant');
|
||||
|
||||
if (this.filters.search) {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
if (this.filters.is_active !== '') {
|
||||
params.append('is_active', this.filters.is_active);
|
||||
}
|
||||
|
||||
const url = `/admin/users?${params}`;
|
||||
window.LogConfig.logApiCall('GET', url, null, 'request');
|
||||
|
||||
const startTime = performance.now();
|
||||
const response = await apiClient.get(url);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
window.LogConfig.logApiCall('GET', url, response, 'response');
|
||||
window.LogConfig.logPerformance('Load Merchant Users', duration);
|
||||
|
||||
this.merchantUsers = (response.items || []).map(user => ({
|
||||
...user,
|
||||
full_name: [user.first_name, user.last_name].filter(Boolean).join(' ') || null
|
||||
}));
|
||||
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = response.pages || Math.ceil(this.pagination.total / this.pagination.per_page) || 1;
|
||||
|
||||
merchantUsersLog.info(`Loaded ${this.merchantUsers.length} merchant users`);
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Load Merchant Users');
|
||||
this.error = error.message || 'Failed to load merchant users';
|
||||
Utils.showToast('Failed to load merchant users', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Load statistics from metrics provider
|
||||
async loadStats() {
|
||||
merchantUsersLog.info('Loading merchant user statistics...');
|
||||
|
||||
try {
|
||||
const url = '/admin/users/merchant-stats';
|
||||
window.LogConfig.logApiCall('GET', url, null, 'request');
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
|
||||
window.LogConfig.logApiCall('GET', url, response, 'response');
|
||||
|
||||
this.stats = {
|
||||
merchant_users_total: response.merchant_users_total || 0,
|
||||
merchant_owners: response.merchant_owners || 0,
|
||||
merchant_team_members: response.merchant_team_members || 0,
|
||||
merchant_users_active: response.merchant_users_active || 0
|
||||
};
|
||||
|
||||
merchantUsersLog.debug('Stats loaded:', this.stats);
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Load Merchant Stats');
|
||||
// Stats are non-critical, don't show error toast
|
||||
}
|
||||
},
|
||||
|
||||
// Search with debounce
|
||||
debouncedSearch() {
|
||||
if (this._searchTimeout) {
|
||||
clearTimeout(this._searchTimeout);
|
||||
}
|
||||
this._searchTimeout = setTimeout(() => {
|
||||
merchantUsersLog.info('Search triggered:', this.filters.search);
|
||||
this.pagination.page = 1;
|
||||
this.loadMerchantUsers();
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// Pagination
|
||||
nextPage() {
|
||||
if (this.pagination.page < this.pagination.pages) {
|
||||
this.pagination.page++;
|
||||
merchantUsersLog.info('Next page:', this.pagination.page);
|
||||
this.loadMerchantUsers();
|
||||
}
|
||||
},
|
||||
|
||||
previousPage() {
|
||||
if (this.pagination.page > 1) {
|
||||
this.pagination.page--;
|
||||
merchantUsersLog.info('Previous page:', this.pagination.page);
|
||||
this.loadMerchantUsers();
|
||||
}
|
||||
},
|
||||
|
||||
goToPage(pageNum) {
|
||||
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
|
||||
this.pagination.page = pageNum;
|
||||
merchantUsersLog.info('Go to page:', this.pagination.page);
|
||||
this.loadMerchantUsers();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
merchantUsersLog.info('Merchant users module loaded');
|
||||
211
app/modules/tenancy/templates/tenancy/admin/merchant-users.html
Normal file
211
app/modules/tenancy/templates/tenancy/admin/merchant-users.html
Normal file
@@ -0,0 +1,211 @@
|
||||
{# app/templates/admin/merchant-users.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
|
||||
{% block title %}Merchant Users{% endblock %}
|
||||
|
||||
{% block alpine_data %}merchantUsersPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Merchant User Management', subtitle='View merchant owners and store team members') }}
|
||||
|
||||
{{ loading_state('Loading merchant users...') }}
|
||||
|
||||
{{ error_state('Error loading merchant users') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card: Total Merchant Users -->
|
||||
<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('users', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Merchant Users
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_users_total || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Merchant Owners -->
|
||||
<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('briefcase', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Owners
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_owners || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Team Members -->
|
||||
<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">
|
||||
Team Members
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_team_members || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Active -->
|
||||
<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.merchant_users_active || 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="debouncedSearch()"
|
||||
placeholder="Search by name or email..."
|
||||
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>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.is_active"
|
||||
@change="pagination.page = 1; loadMerchantUsers()"
|
||||
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 Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="loadMerchantUsers(); loadStats()"
|
||||
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 merchant users"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merchant Users Table -->
|
||||
<div x-show="!loading">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['User', 'Email', 'Role', 'Status', 'Last Login', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<!-- Empty State -->
|
||||
<template x-if="merchantUsers.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" 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('users', 'w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-lg font-medium">No merchant users found</p>
|
||||
<p class="text-sm" x-text="filters.search ? 'Try adjusting your search or filters' : 'Merchant users will appear here when merchants are created'"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Merchant User Rows -->
|
||||
<template x-for="user in merchantUsers" :key="user.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<!-- User Info -->
|
||||
<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">
|
||||
<div class="absolute inset-0 rounded-full flex items-center justify-center text-white font-semibold text-sm bg-blue-500"
|
||||
x-text="(user.username || user.email || 'U').charAt(0).toUpperCase()">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="user.full_name || user.username || user.email"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="user.username || ''"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Email -->
|
||||
<td class="px-4 py-3 text-sm" x-text="user.email"></td>
|
||||
|
||||
<!-- Role -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="user.owned_merchants_count > 0
|
||||
? 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100'
|
||||
: 'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100'"
|
||||
x-text="user.owned_merchants_count > 0 ? 'Owner' : 'Team Member'">
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="user.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="user.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Last Login -->
|
||||
<td class="px-4 py-3 text-sm" x-text="user.last_login ? formatDate(user.last_login) : 'Never'"></td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<!-- View Button -->
|
||||
<a
|
||||
:href="'/admin/admin-users/' + user.id"
|
||||
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="View user details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('tenancy_static', path='admin/js/merchant-users.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user