feat(users): implement full user management CRUD

API endpoints (app/api/v1/admin/users.py):
- GET /users: Paginated list with search and filters
- POST /users: Create new user
- GET /users/{id}: Get user details with related counts
- PUT /users/{id}: Update user information
- PUT /users/{id}/status: Toggle active status
- DELETE /users/{id}: Delete user (with ownership check)

Pydantic schemas (models/schema/auth.py):
- UserCreate: For creating new users
- UserUpdate: For updating user information
- UserDetailResponse: Extended user details with counts
- UserListResponse: Paginated list response

Frontend:
- Updated users.html with server-side pagination and filters
- New user-create.html/js for user creation form
- New user-detail.html/js for viewing user details
- New user-edit.html/js for editing users

Routes added for user create, detail, and edit pages.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-03 21:37:23 +01:00
parent 901471bb76
commit be9c892739
11 changed files with 1987 additions and 274 deletions

View File

@@ -4,42 +4,123 @@ User management endpoints for admin.
"""
import logging
import math
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy.orm import Session, joinedload
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_service import admin_service
from middleware.auth import AuthManager
from app.services.stats_service import stats_service
from models.database.user import User
from models.schema.auth import UserResponse
from models.schema.auth import (
UserCreate,
UserDetailResponse,
UserListResponse,
UserResponse,
UserUpdate,
)
router = APIRouter(prefix="/users")
logger = logging.getLogger(__name__)
@router.get("", response_model=list[UserResponse])
@router.get("", response_model=UserListResponse)
def get_all_users(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
page: int = Query(1, ge=1),
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"),
is_active: str = Query("", description="Filter by active status"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get all users (Admin only)."""
users = admin_service.get_all_users(db=db, skip=skip, limit=limit)
return [UserResponse.model_validate(user) for user in users]
"""Get paginated list of all users (Admin only)."""
query = db.query(User)
# Apply filters
if search:
search_term = f"%{search.lower()}%"
query = query.filter(
(User.username.ilike(search_term))
| (User.email.ilike(search_term))
| (User.first_name.ilike(search_term))
| (User.last_name.ilike(search_term))
)
if role:
query = query.filter(User.role == role)
if is_active:
query = query.filter(User.is_active == (is_active.lower() == "true"))
# Get total count
total = query.count()
pages = math.ceil(total / per_page) if total > 0 else 1
# Apply pagination
skip = (page - 1) * per_page
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
return UserListResponse(
items=[UserResponse.model_validate(user) for user in users],
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.put("/{user_id}/status")
def toggle_user_status(
user_id: int,
@router.post("", response_model=UserDetailResponse)
def create_user(
user_data: UserCreate = Body(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Toggle user active status (Admin only)."""
user, message = admin_service.toggle_user_status(db, user_id, current_admin.id)
return {"message": message}
"""Create a new user (Admin only)."""
# Check if email exists
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
# Check if username exists
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
# Create user
auth_manager = AuthManager()
user = User(
email=user_data.email,
username=user_data.username,
hashed_password=auth_manager.hash_password(user_data.password),
first_name=user_data.first_name,
last_name=user_data.last_name,
role=user_data.role,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_admin.username} created user {user.username}")
return 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_companies_count=len(user.owned_companies),
vendor_memberships_count=len(user.vendor_memberships),
)
@router.get("/stats")
@@ -66,9 +147,7 @@ def search_users(
search_term = f"%{q.lower()}%"
users = (
db.query(User)
.filter(
(User.username.ilike(search_term)) | (User.email.ilike(search_term))
)
.filter((User.username.ilike(search_term)) | (User.email.ilike(search_term)))
.limit(limit)
.all()
)
@@ -84,3 +163,157 @@ def search_users(
for user in users
]
}
@router.get("/{user_id}", response_model=UserDetailResponse)
def get_user_details(
user_id: int = Path(..., description="User ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get detailed user information (Admin only)."""
user = (
db.query(User)
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
.filter(User.id == user_id)
.first()
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return 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_companies_count=len(user.owned_companies),
vendor_memberships_count=len(user.vendor_memberships),
)
@router.put("/{user_id}", response_model=UserDetailResponse)
def update_user(
user_id: int = Path(..., description="User ID"),
user_update: UserUpdate = Body(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Update user information (Admin only)."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent changing own admin status
if user.id == current_admin.id and user_update.role and user_update.role != "admin":
raise HTTPException(
status_code=400, detail="Cannot change your own admin role"
)
# Check email uniqueness if changing
if user_update.email and user_update.email != user.email:
if db.query(User).filter(User.email == user_update.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
# Check username uniqueness if changing
if user_update.username and user_update.username != user.username:
if db.query(User).filter(User.username == user_update.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
# Update fields
update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_admin.username} updated user {user.username}")
return 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_companies_count=len(user.owned_companies),
vendor_memberships_count=len(user.vendor_memberships),
)
@router.put("/{user_id}/status")
def toggle_user_status(
user_id: int = Path(..., description="User ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Toggle user active status (Admin only)."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent deactivating yourself
if user.id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
user.is_active = not user.is_active
db.commit()
action = "activated" if user.is_active else "deactivated"
logger.info(f"Admin {current_admin.username} {action} user {user.username}")
return {"message": f"User {action} successfully", "is_active": user.is_active}
@router.delete("/{user_id}")
def delete_user(
user_id: int = Path(..., description="User ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Delete a user (Admin only)."""
user = (
db.query(User)
.options(joinedload(User.owned_companies))
.filter(User.id == user_id)
.first()
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting yourself
if user.id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Prevent deleting users who own companies
if user.owned_companies:
raise HTTPException(
status_code=400,
detail=f"Cannot delete user who owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
)
username = user.username
db.delete(user)
db.commit()
logger.info(f"Admin {current_admin.username} deleted user {username}")
return {"message": "User deleted successfully"}

View File

@@ -387,6 +387,68 @@ async def admin_users_page(
)
@router.get("/users/create", response_class=HTMLResponse, include_in_schema=False)
async def admin_user_create_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render user creation form.
"""
return templates.TemplateResponse(
"admin/user-create.html",
{
"request": request,
"user": current_user,
},
)
@router.get(
"/users/{user_id}", response_class=HTMLResponse, include_in_schema=False
)
async def admin_user_detail_page(
request: Request,
user_id: int = Path(..., description="User ID"),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render user detail view.
"""
return templates.TemplateResponse(
"admin/user-detail.html",
{
"request": request,
"user": current_user,
"user_id": user_id,
},
)
@router.get(
"/users/{user_id}/edit", response_class=HTMLResponse, include_in_schema=False
)
async def admin_user_edit_page(
request: Request,
user_id: int = Path(..., description="User ID"),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render user edit form.
"""
return templates.TemplateResponse(
"admin/user-edit.html",
{
"request": request,
"user": current_user,
"user_id": user_id,
},
)
# ============================================================================
# IMPORT MANAGEMENT ROUTES
# ============================================================================

View File

@@ -0,0 +1,174 @@
{# app/templates/admin/user-create.html #}
{% extends "admin/base.html" %}
{% block title %}Create User{% endblock %}
{% block alpine_data %}adminUserCreate(){% endblock %}
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Create New User
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Add a new user to the platform
</p>
</div>
<a href="/admin/users"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Users
</a>
</div>
<!-- Create Form -->
<form @submit.prevent="handleSubmit" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Left Column: Account Info -->
<div>
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Account Information
</h3>
<!-- Username -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Username <span class="text-red-600">*</span>
</span>
<input
type="text"
x-model="formData.username"
required
maxlength="50"
:disabled="saving"
placeholder="johndoe"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.username }"
>
<span x-show="errors.username" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.username"></span>
<span x-show="!errors.username" class="text-xs text-gray-600 dark:text-gray-400 mt-1">
Letters, numbers, and underscores only
</span>
</label>
<!-- Email -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Email <span class="text-red-600">*</span>
</span>
<input
type="email"
x-model="formData.email"
required
:disabled="saving"
placeholder="john@example.com"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.email }"
>
<span x-show="errors.email" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.email"></span>
</label>
<!-- Password -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Password <span class="text-red-600">*</span>
</span>
<input
type="password"
x-model="formData.password"
required
minlength="6"
:disabled="saving"
placeholder="Minimum 6 characters"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.password }"
>
<span x-show="errors.password" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.password"></span>
</label>
<!-- Role -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Role <span class="text-red-600">*</span>
</span>
<select
x-model="formData.role"
:disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select"
>
<option value="vendor">Vendor</option>
<option value="admin">Admin</option>
</select>
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
Vendor: Can own companies and manage stores. Admin: Full platform access.
</span>
</label>
</div>
<!-- Right Column: Personal Info -->
<div>
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Personal Information
</h3>
<!-- First Name -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
First Name
</span>
<input
type="text"
x-model="formData.first_name"
maxlength="100"
:disabled="saving"
placeholder="John"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
>
</label>
<!-- Last Name -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Last Name
</span>
<input
type="text"
x-model="formData.last_name"
maxlength="100"
:disabled="saving"
placeholder="Doe"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
>
</label>
</div>
</div>
<!-- Submit Button -->
<div class="flex items-center justify-end gap-3 pt-6 border-t dark:border-gray-700">
<a
href="/admin/users"
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
Cancel
</a>
<button
type="submit"
:disabled="saving"
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">
<span x-show="!saving">
<span x-html="$icon('user-plus', 'w-4 h-4 mr-2 inline')"></span>
Create User
</span>
<span x-show="saving" class="flex items-center">
<span x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Creating...
</span>
</button>
</div>
</form>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/user-create.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,247 @@
{# app/templates/admin/user-detail.html #}
{% extends "admin/base.html" %}
{% block title %}User Details{% endblock %}
{% block alpine_data %}adminUserDetail(){% endblock %}
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="user?.full_name || user?.username || 'User Details'">
User Details
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-show="user">
@<span x-text="user?.username"></span>
<span class="text-gray-400 mx-2">|</span>
<span x-text="user?.email"></span>
</p>
</div>
<a href="/admin/users"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back
</a>
</div>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading user details...</p>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 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 loading user</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- User Details -->
<div x-show="!loading && user">
<!-- Quick Actions Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Quick Actions
</h3>
<div class="flex flex-wrap items-center gap-3">
<a
:href="`/admin/users/${userId}/edit`"
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">
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
Edit User
</a>
<button
@click="toggleStatus()"
:disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none disabled:opacity-50"
:class="user?.is_active ? 'bg-orange-600 hover:bg-orange-700' : 'bg-green-600 hover:bg-green-700'">
<span x-html="$icon(user?.is_active ? 'user-x' : 'user-check', 'w-4 h-4 mr-2')"></span>
<span x-text="user?.is_active ? 'Deactivate' : 'Activate'"></span>
</button>
<button
@click="deleteUser()"
:disabled="saving || user?.owned_companies_count > 0"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none focus:shadow-outline-red disabled:opacity-50 disabled:cursor-not-allowed"
:title="user?.owned_companies_count > 0 ? 'Cannot delete user who owns companies' : 'Delete user'">
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
Delete User
</button>
</div>
</div>
<!-- Status Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Role -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full"
:class="{
'text-orange-500 bg-orange-100 dark:text-orange-100 dark:bg-orange-500': user?.role === 'admin',
'text-purple-500 bg-purple-100 dark:text-purple-100 dark:bg-purple-500': user?.role === 'vendor'
}">
<span x-html="$icon(user?.role === 'admin' ? 'shield' : 'user', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Role
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200 capitalize" x-text="user?.role">
-
</p>
</div>
</div>
<!-- Active Status -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full"
:class="user?.is_active ? 'text-green-500 bg-green-100 dark:text-green-100 dark:bg-green-500' : 'text-red-500 bg-red-100 dark:text-red-100 dark:bg-red-500'">
<span x-html="$icon(user?.is_active ? 'check-circle' : 'x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Status
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="user?.is_active ? 'Active' : 'Inactive'">
-
</p>
</div>
</div>
<!-- Companies Owned -->
<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('office-building', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Companies
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="user?.owned_companies_count || 0">
0
</p>
</div>
</div>
<!-- Created Date -->
<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('calendar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Registered
</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(user?.created_at)">
-
</p>
</div>
</div>
</div>
<!-- Main Info Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Account Information -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Account Information
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Username</p>
<p class="text-sm text-gray-700 dark:text-gray-300">@<span x-text="user?.username || '-'"></span></p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Email</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="user?.email || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Email Verified</p>
<span
class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full"
:class="user?.is_email_verified ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100'"
x-text="user?.is_email_verified ? 'Verified' : 'Not Verified'">
</span>
</div>
</div>
</div>
<!-- Personal Information -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Personal Information
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Full Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="user?.full_name || 'Not provided'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">First Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="user?.first_name || 'Not provided'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Last Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="user?.last_name || 'Not provided'">-</p>
</div>
</div>
</div>
</div>
<!-- Activity Information -->
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Activity Information
</h3>
<div class="grid gap-6 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Last Login</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="user?.last_login ? formatDate(user.last_login) : 'Never'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Created At</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(user?.created_at)">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Last Updated</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(user?.updated_at)">-</p>
</div>
</div>
</div>
<!-- Ownership Information -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Ownership & Memberships
</h3>
<div class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<div class="flex items-center mb-2">
<span x-html="$icon('office-building', 'w-5 h-5 mr-2 text-blue-500')"></span>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200">Companies Owned</p>
</div>
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="user?.owned_companies_count || 0"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Companies where this user is the owner
</p>
</div>
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<div class="flex items-center mb-2">
<span x-html="$icon('users', 'w-5 h-5 mr-2 text-purple-500')"></span>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200">Vendor Memberships</p>
</div>
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="user?.vendor_memberships_count || 0"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Vendors where this user is a team member
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/user-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,288 @@
{# app/templates/admin/user-edit.html #}
{% extends "admin/base.html" %}
{% block title %}Edit User{% endblock %}
{% block alpine_data %}adminUserEdit(){% endblock %}
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Edit User
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-show="user">
@<span x-text="user?.username"></span>
</p>
</div>
<a href="/admin/users"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Users
</a>
</div>
<!-- Loading State -->
<div x-show="loadingUser" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading user...</p>
</div>
<!-- Edit Form -->
<div x-show="!loadingUser && user">
<!-- Quick Actions Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Quick Actions
</h3>
<div class="flex flex-wrap items-center gap-3">
<button
@click="toggleStatus()"
:disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 rounded-lg focus:outline-none disabled:opacity-50"
:class="user?.is_active ? 'bg-orange-600 hover:bg-orange-700' : 'bg-green-600 hover:bg-green-700'">
<span x-html="$icon(user?.is_active ? 'user-x' : 'user-check', 'w-4 h-4 mr-2')"></span>
<span x-text="user?.is_active ? 'Deactivate' : 'Activate'"></span>
</button>
<!-- Status Badges -->
<div class="ml-auto flex items-center gap-2">
<span
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight rounded-full capitalize"
:class="{
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': user?.role === 'admin',
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': user?.role === 'vendor'
}"
x-text="user?.role">
</span>
<span
x-show="user?.is_active"
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100">
Active
</span>
<span
x-show="!user?.is_active"
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-red-700 bg-red-100 rounded-full dark:bg-red-700 dark:text-red-100">
Inactive
</span>
</div>
</div>
</div>
<!-- Form Card -->
<form @submit.prevent="handleSubmit" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Left Column: Account Info -->
<div>
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Account Information
</h3>
<!-- User ID (readonly) -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
User ID
</span>
<input
type="text"
:value="user?.id"
disabled
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
>
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
System-generated identifier
</span>
</label>
<!-- Username -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Username <span class="text-red-600">*</span>
</span>
<input
type="text"
x-model="formData.username"
required
maxlength="50"
:disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.username }"
>
<span x-show="errors.username" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.username"></span>
</label>
<!-- Email -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Email <span class="text-red-600">*</span>
</span>
<input
type="email"
x-model="formData.email"
required
:disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.email }"
>
<span x-show="errors.email" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.email"></span>
</label>
<!-- Role -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Role <span class="text-red-600">*</span>
</span>
<select
x-model="formData.role"
:disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select"
>
<option value="vendor">Vendor</option>
<option value="admin">Admin</option>
</select>
</label>
</div>
<!-- Right Column: Personal Info -->
<div>
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Personal Information
</h3>
<!-- First Name -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
First Name
</span>
<input
type="text"
x-model="formData.first_name"
maxlength="100"
:disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
>
</label>
<!-- Last Name -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Last Name
</span>
<input
type="text"
x-model="formData.last_name"
maxlength="100"
:disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
>
</label>
<!-- Email Verified -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Email Verification
</span>
<div class="flex items-center mt-2">
<input
type="checkbox"
x-model="formData.is_email_verified"
:disabled="saving"
class="form-checkbox text-purple-600"
>
<span class="ml-2 text-gray-700 dark:text-gray-400">
Email is verified
</span>
</div>
</label>
</div>
</div>
<!-- User Statistics (readonly) -->
<template x-if="user?.owned_companies_count !== undefined">
<div class="mb-8">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
User Statistics
</h3>
<div class="grid gap-4 md:grid-cols-3">
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">Companies Owned</p>
<p class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="user.owned_companies_count || 0"></p>
</div>
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">Vendor Memberships</p>
<p class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="user.vendor_memberships_count || 0"></p>
</div>
<div class="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">Last Login</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="user.last_login ? formatDate(user.last_login) : 'Never'"></p>
</div>
</div>
</div>
</template>
<!-- Save Button -->
<div class="flex items-center justify-end gap-3 pt-6 border-t dark:border-gray-700">
<a
:href="`/admin/users/${userId}`"
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
Cancel
</a>
<button
type="submit"
:disabled="saving"
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">
<span x-show="!saving">
<span x-html="$icon('check', 'w-4 h-4 mr-2 inline')"></span>
Save Changes
</span>
<span x-show="saving" class="flex items-center">
<span x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Saving...
</span>
</button>
</div>
</form>
<!-- More Actions Section -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
More Actions
</h3>
<div class="flex flex-wrap gap-3">
<!-- View User Button -->
<a
:href="`/admin/users/${userId}`"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none focus:shadow-outline-blue"
>
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
View User
</a>
<!-- Delete User Button -->
<button
@click="deleteUser()"
:disabled="saving || (user?.owned_companies_count > 0)"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none focus:shadow-outline-red disabled:opacity-50"
:title="user?.owned_companies_count > 0 ? 'Cannot delete user who owns companies' : 'Delete this user'"
>
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
Delete User
</button>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
<span x-html="$icon('information-circle', 'w-4 h-4 inline mr-1')"></span>
<span x-show="user?.owned_companies_count > 0" class="text-orange-600 dark:text-orange-400">
User cannot be deleted while they own companies (<span x-text="user?.owned_companies_count"></span> companies).
</span>
<span x-show="!user?.owned_companies_count">
User can be deleted.
</span>
</p>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/user-edit.js') }}"></script>
{% endblock %}

View File

@@ -1,270 +1,333 @@
{# app/templates/admin/users.html #}
{% extends "admin/base.html" %}
{% block title %}Users Management - Wizamart Admin{% endblock %}
{% block title %}Users{% endblock %}
{% block page_title %}Users Management{% endblock %}
{% block alpine_data %}adminUsers(){% endblock %}
{% block content %}
<div x-data="adminUsers()" x-init="init()">
<!-- Header Section with Search and Actions -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<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 users by name, email, or username..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<div class="absolute left-3 top-2.5" x-html="$icon('search', 'w-5 h-5 text-gray-400')"></div>
</div>
</div>
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
User Management
</h2>
<a
href="/admin/users/create"
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"
>
<span x-html="$icon('user-plus', 'w-4 h-4 mr-2')"></span>
Create User
</a>
</div>
<!-- Filter and Action Buttons -->
<div class="flex gap-3">
<!-- Role Filter -->
<select
x-model="filters.role"
@change="loadUsers()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">All Roles</option>
<option value="customer">Customer</option>
<option value="vendor">Vendor</option>
<option value="admin">Admin</option>
</select>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading users...</p>
</div>
<!-- Status Filter -->
<select
x-model="filters.is_active"
@change="loadUsers()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<!-- Error State -->
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 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 loading users</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- Add User Button -->
<button
@click="openCreateModal()"
class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<span x-html="$icon('user-plus', 'w-5 h-5')"></span>
<span>Add User</span>
</button>
</div>
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total 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-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-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 Users
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_users || 0">
0
</p>
</div>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Users</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="stats.total"></p>
</div>
<div class="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg" x-html="$icon('users', 'w-8 h-8 text-blue-600 dark:text-blue-400')"></div>
</div>
<!-- Card: Active 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-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 class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Active Users</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="stats.active"></p>
</div>
<div class="p-3 bg-green-100 dark:bg-green-900 rounded-lg" x-html="$icon('user-check', 'w-8 h-8 text-green-600 dark:text-green-400')"></div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Vendors</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400" x-text="stats.vendors"></p>
</div>
<div class="p-3 bg-purple-100 dark:bg-purple-900 rounded-lg" x-html="$icon('store', 'w-8 h-8 text-purple-600 dark:text-purple-400')"></div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Admins</p>
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400" x-text="stats.admins"></p>
</div>
<div class="p-3 bg-orange-100 dark:bg-orange-900 rounded-lg" x-html="$icon('shield', 'w-8 h-8 text-orange-600 dark:text-orange-400')"></div>
</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_users || 0">
0
</p>
</div>
</div>
<!-- Users Table -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-8 text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading users...</p>
<!-- Card: Inactive 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-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('user-x', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Inactive
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.inactive_users || 0">
0
</p>
</div>
</div>
<!-- Card: Admin 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-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('shield', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Admins
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.admin_users || 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, email, or username..."
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>
<!-- Empty State -->
<div x-show="!loading && users.length === 0" class="p-8 text-center">
<div class="text-gray-400 dark:text-gray-500 mb-4" x-html="$icon('inbox', 'w-16 h-16 mx-auto')"></div>
<p class="text-gray-600 dark:text-gray-400">No users found</p>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Role Filter -->
<select
x-model="filters.role"
@change="pagination.page = 1; loadUsers()"
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 Roles</option>
<option value="vendor">Vendor</option>
<option value="admin">Admin</option>
</select>
<!-- Users Table -->
<div x-show="!loading && users.length > 0">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<!-- Status Filter -->
<select
x-model="filters.is_active"
@change="pagination.page = 1; loadUsers()"
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="loadUsers(); 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 users"
>
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
</div>
<!-- Users 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">User</th>
<th class="px-4 py-3">Email</th>
<th class="px-4 py-3">Role</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Registered</th>
<th class="px-4 py-3">Last Login</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="users.length === 0">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Email
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Registered
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Last Login
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
<td colspan="7" 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 users found</p>
<p class="text-sm" x-text="filters.search ? 'Try adjusting your search or filters' : 'Create your first user to get started'"></p>
</div>
</td>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="user in users" :key="user.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- User Info -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
<span class="text-white font-semibold" x-text="(user.full_name || user.username || 'U').charAt(0).toUpperCase()"></span>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="user.full_name || 'N/A'"></div>
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="'@' + user.username"></div>
</template>
<!-- User Rows -->
<template x-for="user in users" :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 bg-purple-500 flex items-center justify-center text-white font-semibold text-sm"
x-text="(user.username || 'U').charAt(0).toUpperCase()">
</div>
</div>
</td>
<!-- Email -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900 dark:text-white" x-text="user.email"></div>
</td>
<!-- Role -->
<td class="px-6 py-4 whitespace-nowrap">
<span
class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="{
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': user.role === 'customer',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200': user.role === 'vendor',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': user.role === 'admin'
}"
x-text="user.role"
></span>
</td>
<!-- Status -->
<td class="px-6 py-4 whitespace-nowrap">
<span
class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="user.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'"
x-text="user.is_active ? 'Active' : 'Inactive'"
></span>
</td>
<!-- Registered -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span x-text="Utils.formatDate(user.created_at)"></span>
</td>
<!-- Last Login -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span x-text="user.last_login ? Utils.formatDate(user.last_login) : 'Never'"></span>
</td>
<!-- Actions -->
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end gap-2">
<button
@click="viewUser(user)"
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
title="View Details"
x-html="$icon('eye', 'w-5 h-5')"
></button>
<button
@click="editUser(user)"
class="text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300"
title="Edit"
x-html="$icon('edit', 'w-5 h-5')"
></button>
<button
@click="toggleUserStatus(user)"
:class="user.is_active ? 'text-orange-600 hover:text-orange-900 dark:text-orange-400 dark:hover:text-orange-300' : 'text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300'"
:title="user.is_active ? 'Deactivate' : 'Activate'"
x-html="$icon(user.is_active ? 'user-x' : 'user-check', 'w-5 h-5')"
></button>
<button
@click="deleteUser(user)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="Delete"
x-html="$icon('delete', 'w-5 h-5')"
></button>
<div>
<p class="font-semibold" x-text="user.username"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="user.full_name || ''"></p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</td>
<!-- Pagination -->
<div class="bg-gray-50 dark:bg-gray-900 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-700 dark:text-gray-300">
Showing <span class="font-medium" x-text="((pagination.page - 1) * pagination.per_page) + 1"></span>
to <span class="font-medium" x-text="Math.min(pagination.page * pagination.per_page, pagination.total)"></span>
of <span class="font-medium" x-text="pagination.total"></span> users
</div>
<div class="flex gap-2">
<!-- 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 capitalize"
:class="{
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': user.role === 'admin',
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': user.role === 'vendor'
}"
x-text="user.role">
</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>
<!-- Registered -->
<td class="px-4 py-3 text-sm" x-text="formatDate(user.created_at)"></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/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"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<!-- Edit Button -->
<a
:href="'/admin/users/' + user.id + '/edit'"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Edit user"
>
<span x-html="$icon('edit', 'w-5 h-5')"></span>
</a>
<!-- Delete Button -->
<button
@click="deleteUser(user)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Delete user"
>
<span x-html="$icon('delete', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination Footer -->
<div class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<!-- Results Info -->
<span class="flex items-center col-span-3">
Showing <span class="mx-1 font-bold" x-text="startIndex"></span>-<span class="mx-1 font-bold" x-text="endIndex"></span> of <span class="mx-1 font-bold" x-text="pagination.total"></span>
</span>
<span class="col-span-2"></span>
<!-- Pagination Controls -->
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<!-- Previous Button -->
<li>
<button
@click="previousPage()"
:disabled="pagination.page === 1"
:class="pagination.page === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-200 dark:hover:bg-gray-700'"
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-300"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple"
:class="pagination.page === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
aria-label="Previous"
>
Previous
<svg class="w-4 h-4 fill-current" aria-hidden="true" viewBox="0 0 20 20">
<path d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" fill-rule="evenodd"></path>
</svg>
</button>
</li>
<!-- Page Numbers -->
<template x-for="pageNum in pageNumbers" :key="pageNum">
<li>
<button
x-show="pageNum !== '...'"
@click="goToPage(pageNum)"
class="px-3 py-1 rounded-md focus:outline-none focus:shadow-outline-purple"
:class="pagination.page === pageNum ? 'text-white bg-purple-600 border border-purple-600' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="pageNum"
></button>
<span x-show="pageNum === '...'" class="px-3 py-1">...</span>
</li>
</template>
<!-- Next Button -->
<li>
<button
@click="nextPage()"
:disabled="pagination.page >= pagination.pages"
:class="pagination.page >= pagination.pages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-200 dark:hover:bg-gray-700'"
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-300"
:disabled="pagination.page === totalPages"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple"
:class="pagination.page === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
aria-label="Next"
>
Next
<svg class="w-4 h-4 fill-current" aria-hidden="true" viewBox="0 0 20 20">
<path d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" fill-rule="evenodd"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</li>
</ul>
</nav>
</span>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/users.js') }}"></script>
{% endblock %}
{% endblock %}