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:
@@ -4,42 +4,123 @@ User management endpoints for admin.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.admin_service import admin_service
|
from app.services.admin_service import admin_service
|
||||||
|
from middleware.auth import AuthManager
|
||||||
from app.services.stats_service import stats_service
|
from app.services.stats_service import stats_service
|
||||||
from models.database.user import User
|
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")
|
router = APIRouter(prefix="/users")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[UserResponse])
|
@router.get("", response_model=UserListResponse)
|
||||||
def get_all_users(
|
def get_all_users(
|
||||||
skip: int = Query(0, ge=0),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
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),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Get all users (Admin only)."""
|
"""Get paginated list of all users (Admin only)."""
|
||||||
users = admin_service.get_all_users(db=db, skip=skip, limit=limit)
|
query = db.query(User)
|
||||||
return [UserResponse.model_validate(user) for user in users]
|
|
||||||
|
# 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")
|
@router.post("", response_model=UserDetailResponse)
|
||||||
def toggle_user_status(
|
def create_user(
|
||||||
user_id: int,
|
user_data: UserCreate = Body(...),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Toggle user active status (Admin only)."""
|
"""Create a new user (Admin only)."""
|
||||||
user, message = admin_service.toggle_user_status(db, user_id, current_admin.id)
|
# Check if email exists
|
||||||
return {"message": message}
|
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")
|
@router.get("/stats")
|
||||||
@@ -66,9 +147,7 @@ def search_users(
|
|||||||
search_term = f"%{q.lower()}%"
|
search_term = f"%{q.lower()}%"
|
||||||
users = (
|
users = (
|
||||||
db.query(User)
|
db.query(User)
|
||||||
.filter(
|
.filter((User.username.ilike(search_term)) | (User.email.ilike(search_term)))
|
||||||
(User.username.ilike(search_term)) | (User.email.ilike(search_term))
|
|
||||||
)
|
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -84,3 +163,157 @@ def search_users(
|
|||||||
for user in 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"}
|
||||||
|
|||||||
@@ -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
|
# IMPORT MANAGEMENT ROUTES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
174
app/templates/admin/user-create.html
Normal file
174
app/templates/admin/user-create.html
Normal 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 %}
|
||||||
247
app/templates/admin/user-detail.html
Normal file
247
app/templates/admin/user-detail.html
Normal 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 %}
|
||||||
288
app/templates/admin/user-edit.html
Normal file
288
app/templates/admin/user-edit.html
Normal 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 %}
|
||||||
@@ -1,266 +1,329 @@
|
|||||||
|
{# app/templates/admin/users.html #}
|
||||||
{% extends "admin/base.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 %}
|
{% block content %}
|
||||||
<div x-data="adminUsers()" x-init="init()">
|
<!-- Page Header -->
|
||||||
<!-- Header Section with Search and Actions -->
|
<div class="flex items-center justify-between my-6">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
User Management
|
||||||
<!-- Search Bar -->
|
</h2>
|
||||||
<div class="flex-1 max-w-md">
|
<a
|
||||||
<div class="relative">
|
href="/admin/users/create"
|
||||||
<input
|
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"
|
||||||
type="text"
|
>
|
||||||
x-model="filters.search"
|
<span x-html="$icon('user-plus', 'w-4 h-4 mr-2')"></span>
|
||||||
@input="debouncedSearch()"
|
Create User
|
||||||
placeholder="Search users by name, email, or username..."
|
</a>
|
||||||
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>
|
||||||
>
|
|
||||||
<div class="absolute left-3 top-2.5" x-html="$icon('search', 'w-5 h-5 text-gray-400')"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter and Action Buttons -->
|
<!-- Loading State -->
|
||||||
<div class="flex gap-3">
|
<div x-show="loading" class="text-center py-12">
|
||||||
<!-- Role Filter -->
|
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||||
<select
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading users...</p>
|
||||||
x-model="filters.role"
|
</div>
|
||||||
@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>
|
|
||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Error State -->
|
||||||
<select
|
<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">
|
||||||
x-model="filters.is_active"
|
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||||
@change="loadUsers()"
|
<div>
|
||||||
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"
|
<p class="font-semibold">Error loading users</p>
|
||||||
>
|
<p class="text-sm" x-text="error"></p>
|
||||||
<option value="">All Status</option>
|
</div>
|
||||||
<option value="true">Active</option>
|
</div>
|
||||||
<option value="false">Inactive</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- Add User Button -->
|
<!-- Stats Cards -->
|
||||||
<button
|
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||||
@click="openCreateModal()"
|
<!-- Card: Total Users -->
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
<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-plus', 'w-5 h-5')"></span>
|
<span x-html="$icon('users', 'w-5 h-5')"></span>
|
||||||
<span>Add User</span>
|
</div>
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
<!-- Card: Active Users -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||||
<div class="flex items-center justify-between">
|
<span x-html="$icon('user-check', 'w-5 h-5')"></span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||||
<div class="flex items-center justify-between">
|
Active
|
||||||
<div>
|
</p>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Active Users</p>
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active_users || 0">
|
||||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="stats.active"></p>
|
0
|
||||||
</div>
|
</p>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Table -->
|
<!-- Card: Inactive Users -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
<!-- Loading State -->
|
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||||
<div x-show="loading" class="p-8 text-center">
|
<span x-html="$icon('user-x', 'w-5 h-5')"></span>
|
||||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
</div>
|
||||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading users...</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Filters -->
|
||||||
<div x-show="!loading && users.length === 0" class="p-8 text-center">
|
<div class="flex flex-wrap gap-3">
|
||||||
<div class="text-gray-400 dark:text-gray-500 mb-4" x-html="$icon('inbox', 'w-16 h-16 mx-auto')"></div>
|
<!-- Role Filter -->
|
||||||
<p class="text-gray-600 dark:text-gray-400">No users found</p>
|
<select
|
||||||
</div>
|
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 -->
|
<!-- Status Filter -->
|
||||||
<div x-show="!loading && users.length > 0">
|
<select
|
||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
x-model="filters.is_active"
|
||||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
@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>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<td colspan="7" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||||
User
|
<div class="flex flex-col items-center">
|
||||||
</th>
|
<span x-html="$icon('users', 'w-12 h-12 text-gray-400 mb-4')"></span>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<p class="text-lg font-medium">No users found</p>
|
||||||
Email
|
<p class="text-sm" x-text="filters.search ? 'Try adjusting your search or filters' : 'Create your first user to get started'"></p>
|
||||||
</th>
|
</div>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
</td>
|
||||||
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>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</template>
|
||||||
<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">
|
<!-- User Rows -->
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
<template x-for="user in users" :key="user.id">
|
||||||
<!-- User Info -->
|
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<!-- User Info -->
|
||||||
<div class="flex items-center">
|
<td class="px-4 py-3">
|
||||||
<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">
|
<div class="flex items-center text-sm">
|
||||||
<span class="text-white font-semibold" x-text="(user.full_name || user.username || 'U').charAt(0).toUpperCase()"></span>
|
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
|
||||||
</div>
|
<div class="absolute inset-0 rounded-full bg-purple-500 flex items-center justify-center text-white font-semibold text-sm"
|
||||||
<div class="ml-4">
|
x-text="(user.username || 'U').charAt(0).toUpperCase()">
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div>
|
||||||
|
<p class="font-semibold" x-text="user.username"></p>
|
||||||
<!-- Email -->
|
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="user.full_name || ''"></p>
|
||||||
<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>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Email -->
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
<td class="px-4 py-3 text-sm" x-text="user.email"></td>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
<!-- Role -->
|
||||||
Showing <span class="font-medium" x-text="((pagination.page - 1) * pagination.per_page) + 1"></span>
|
<td class="px-4 py-3 text-xs">
|
||||||
to <span class="font-medium" x-text="Math.min(pagination.page * pagination.per_page, pagination.total)"></span>
|
<span class="px-2 py-1 font-semibold leading-tight rounded-full capitalize"
|
||||||
of <span class="font-medium" x-text="pagination.total"></span> users
|
:class="{
|
||||||
</div>
|
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': user.role === 'admin',
|
||||||
<div class="flex gap-2">
|
'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
|
<button
|
||||||
@click="previousPage()"
|
@click="previousPage()"
|
||||||
:disabled="pagination.page === 1"
|
: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 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple"
|
||||||
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="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>
|
</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
|
<button
|
||||||
@click="nextPage()"
|
@click="nextPage()"
|
||||||
:disabled="pagination.page >= pagination.pages"
|
:disabled="pagination.page === totalPages"
|
||||||
:class="pagination.page >= pagination.pages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-200 dark:hover:bg-gray-700'"
|
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple"
|
||||||
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="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>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</nav>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -59,3 +59,65 @@ class LoginResponse(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
expires_in: int
|
expires_in: int
|
||||||
user: UserResponse
|
user: UserResponse
|
||||||
|
|
||||||
|
|
||||||
|
class UserDetailResponse(UserResponse):
|
||||||
|
"""Extended user response with additional details."""
|
||||||
|
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
|
full_name: str | None = None
|
||||||
|
is_email_verified: bool = False
|
||||||
|
owned_companies_count: int = 0
|
||||||
|
vendor_memberships_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""Schema for updating user information."""
|
||||||
|
|
||||||
|
username: str | None = Field(None, min_length=3, max_length=50)
|
||||||
|
email: EmailStr | None = None
|
||||||
|
first_name: str | None = Field(None, max_length=100)
|
||||||
|
last_name: str | None = Field(None, max_length=100)
|
||||||
|
role: str | None = Field(None, pattern="^(admin|vendor)$")
|
||||||
|
is_active: bool | None = None
|
||||||
|
is_email_verified: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def validate_username(cls, v):
|
||||||
|
if v and not re.match(r"^[a-zA-Z0-9_]+$", v):
|
||||||
|
raise ValueError(
|
||||||
|
"Username must contain only letters, numbers, or underscores"
|
||||||
|
)
|
||||||
|
return v.lower().strip() if v else v
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""Schema for creating a new user (admin only)."""
|
||||||
|
|
||||||
|
email: EmailStr = Field(..., description="Valid email address")
|
||||||
|
username: str = Field(..., min_length=3, max_length=50)
|
||||||
|
password: str = Field(..., min_length=6, description="Password")
|
||||||
|
first_name: str | None = Field(None, max_length=100)
|
||||||
|
last_name: str | None = Field(None, max_length=100)
|
||||||
|
role: str = Field(default="vendor", pattern="^(admin|vendor)$")
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def validate_username(cls, v):
|
||||||
|
if not re.match(r"^[a-zA-Z0-9_]+$", v):
|
||||||
|
raise ValueError(
|
||||||
|
"Username must contain only letters, numbers, or underscores"
|
||||||
|
)
|
||||||
|
return v.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
"""Schema for paginated user list."""
|
||||||
|
|
||||||
|
items: list[UserResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
pages: int
|
||||||
|
|||||||
97
static/admin/js/user-create.js
Normal file
97
static/admin/js/user-create.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// static/admin/js/user-create.js
|
||||||
|
|
||||||
|
// Create custom logger for user create
|
||||||
|
const userCreateLog = window.LogConfig.createLogger('USER-CREATE');
|
||||||
|
|
||||||
|
function adminUserCreate() {
|
||||||
|
return {
|
||||||
|
// Inherit base layout functionality from init-alpine.js
|
||||||
|
...data(),
|
||||||
|
|
||||||
|
// User create page specific state
|
||||||
|
currentPage: 'user-create',
|
||||||
|
formData: {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
role: 'vendor'
|
||||||
|
},
|
||||||
|
errors: {},
|
||||||
|
saving: false,
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
async init() {
|
||||||
|
userCreateLog.info('=== USER CREATE PAGE INITIALIZING ===');
|
||||||
|
|
||||||
|
// Prevent multiple initializations
|
||||||
|
if (window._userCreateInitialized) {
|
||||||
|
userCreateLog.warn('User create page already initialized, skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window._userCreateInitialized = true;
|
||||||
|
|
||||||
|
userCreateLog.info('=== USER CREATE PAGE INITIALIZATION COMPLETE ===');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
async handleSubmit() {
|
||||||
|
userCreateLog.info('=== CREATING USER ===');
|
||||||
|
userCreateLog.debug('Form data:', { ...this.formData, password: '[REDACTED]' });
|
||||||
|
|
||||||
|
this.errors = {};
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/admin/users`;
|
||||||
|
window.LogConfig.logApiCall('POST', url, { ...this.formData, password: '[REDACTED]' }, 'request');
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
const response = await apiClient.post(url, this.formData);
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
|
||||||
|
window.LogConfig.logApiCall('POST', url, response, 'response');
|
||||||
|
window.LogConfig.logPerformance('Create User', duration);
|
||||||
|
|
||||||
|
Utils.showToast('User created successfully', 'success');
|
||||||
|
userCreateLog.info(`User created successfully in ${duration}ms`, response);
|
||||||
|
|
||||||
|
// Redirect to the new user's detail page
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/admin/users/${response.id}`;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
window.LogConfig.logError(error, 'Create User');
|
||||||
|
|
||||||
|
// Handle validation errors
|
||||||
|
if (error.details && error.details.validation_errors) {
|
||||||
|
error.details.validation_errors.forEach(err => {
|
||||||
|
const field = err.loc?.[1] || err.loc?.[0];
|
||||||
|
if (field) {
|
||||||
|
this.errors[field] = err.msg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
userCreateLog.debug('Validation errors:', this.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific errors
|
||||||
|
if (error.message) {
|
||||||
|
if (error.message.includes('Email already registered')) {
|
||||||
|
this.errors.email = 'This email is already registered';
|
||||||
|
} else if (error.message.includes('Username already taken')) {
|
||||||
|
this.errors.username = 'This username is already taken';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Utils.showToast(error.message || 'Failed to create user', 'error');
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
userCreateLog.info('=== USER CREATION COMPLETE ===');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
userCreateLog.info('User create module loaded');
|
||||||
175
static/admin/js/user-detail.js
Normal file
175
static/admin/js/user-detail.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
// static/admin/js/user-detail.js
|
||||||
|
|
||||||
|
// Create custom logger for user detail
|
||||||
|
const userDetailLog = window.LogConfig.createLogger('USER-DETAIL');
|
||||||
|
|
||||||
|
function adminUserDetail() {
|
||||||
|
return {
|
||||||
|
// Inherit base layout functionality from init-alpine.js
|
||||||
|
...data(),
|
||||||
|
|
||||||
|
// User detail page specific state
|
||||||
|
currentPage: 'user-detail',
|
||||||
|
user: null,
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
|
userId: null,
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
async init() {
|
||||||
|
userDetailLog.info('=== USER DETAIL PAGE INITIALIZING ===');
|
||||||
|
|
||||||
|
// Prevent multiple initializations
|
||||||
|
if (window._userDetailInitialized) {
|
||||||
|
userDetailLog.warn('User detail page already initialized, skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window._userDetailInitialized = true;
|
||||||
|
|
||||||
|
// Get user ID from URL
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const match = path.match(/\/admin\/users\/(\d+)$/);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
this.userId = match[1];
|
||||||
|
userDetailLog.info('Viewing user:', this.userId);
|
||||||
|
await this.loadUser();
|
||||||
|
} else {
|
||||||
|
userDetailLog.error('No user ID in URL');
|
||||||
|
this.error = 'Invalid user URL';
|
||||||
|
Utils.showToast('Invalid user URL', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
userDetailLog.info('=== USER DETAIL PAGE INITIALIZATION COMPLETE ===');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Load user data
|
||||||
|
async loadUser() {
|
||||||
|
userDetailLog.info('Loading user details...');
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/admin/users/${this.userId}`;
|
||||||
|
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 User Details', duration);
|
||||||
|
|
||||||
|
this.user = response;
|
||||||
|
|
||||||
|
userDetailLog.info(`User loaded in ${duration}ms`, {
|
||||||
|
id: this.user.id,
|
||||||
|
username: this.user.username,
|
||||||
|
role: this.user.role,
|
||||||
|
is_active: this.user.is_active
|
||||||
|
});
|
||||||
|
userDetailLog.debug('Full user data:', this.user);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
window.LogConfig.logError(error, 'Load User Details');
|
||||||
|
this.error = error.message || 'Failed to load user details';
|
||||||
|
Utils.showToast('Failed to load user details', 'error');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return Utils.formatDate(dateString);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle user status
|
||||||
|
async toggleStatus() {
|
||||||
|
const action = this.user.is_active ? 'deactivate' : 'activate';
|
||||||
|
userDetailLog.info(`Toggle status: ${action}`);
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to ${action} ${this.user.username}?`)) {
|
||||||
|
userDetailLog.info('Status toggle cancelled by user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
const url = `/admin/users/${this.userId}/status`;
|
||||||
|
window.LogConfig.logApiCall('PUT', url, null, 'request');
|
||||||
|
|
||||||
|
const response = await apiClient.put(url);
|
||||||
|
|
||||||
|
window.LogConfig.logApiCall('PUT', url, response, 'response');
|
||||||
|
|
||||||
|
this.user.is_active = response.is_active;
|
||||||
|
Utils.showToast(`User ${action}d successfully`, 'success');
|
||||||
|
userDetailLog.info(`User ${action}d successfully`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
window.LogConfig.logError(error, `Toggle Status (${action})`);
|
||||||
|
Utils.showToast(error.message || `Failed to ${action} user`, 'error');
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
async deleteUser() {
|
||||||
|
userDetailLog.info('Delete user requested:', this.userId);
|
||||||
|
|
||||||
|
if (this.user?.owned_companies_count > 0) {
|
||||||
|
Utils.showToast(`Cannot delete user who owns ${this.user.owned_companies_count} company(ies). Transfer ownership first.`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to delete "${this.user.username}"?\n\nThis action cannot be undone.`)) {
|
||||||
|
userDetailLog.info('Delete cancelled by user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second confirmation for safety
|
||||||
|
if (!confirm(`FINAL CONFIRMATION\n\nAre you absolutely sure you want to delete "${this.user.username}"?`)) {
|
||||||
|
userDetailLog.info('Delete cancelled by user (second confirmation)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
const url = `/admin/users/${this.userId}`;
|
||||||
|
window.LogConfig.logApiCall('DELETE', url, null, 'request');
|
||||||
|
|
||||||
|
await apiClient.delete(url);
|
||||||
|
|
||||||
|
window.LogConfig.logApiCall('DELETE', url, null, 'response');
|
||||||
|
|
||||||
|
Utils.showToast('User deleted successfully', 'success');
|
||||||
|
userDetailLog.info('User deleted successfully');
|
||||||
|
|
||||||
|
// Redirect to users list
|
||||||
|
setTimeout(() => window.location.href = '/admin/users', 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
window.LogConfig.logError(error, 'Delete User');
|
||||||
|
Utils.showToast(error.message || 'Failed to delete user', 'error');
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refresh user data
|
||||||
|
async refresh() {
|
||||||
|
userDetailLog.info('=== USER REFRESH TRIGGERED ===');
|
||||||
|
await this.loadUser();
|
||||||
|
Utils.showToast('User details refreshed', 'success');
|
||||||
|
userDetailLog.info('=== USER REFRESH COMPLETE ===');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
userDetailLog.info('User detail module loaded');
|
||||||
223
static/admin/js/user-edit.js
Normal file
223
static/admin/js/user-edit.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// static/admin/js/user-edit.js
|
||||||
|
|
||||||
|
// Create custom logger for user edit
|
||||||
|
const userEditLog = window.LogConfig.createLogger('USER-EDIT');
|
||||||
|
|
||||||
|
function adminUserEdit() {
|
||||||
|
return {
|
||||||
|
// Inherit base layout functionality from init-alpine.js
|
||||||
|
...data(),
|
||||||
|
|
||||||
|
// User edit page specific state
|
||||||
|
currentPage: 'user-edit',
|
||||||
|
user: null,
|
||||||
|
formData: {},
|
||||||
|
errors: {},
|
||||||
|
loadingUser: false,
|
||||||
|
saving: false,
|
||||||
|
userId: null,
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
async init() {
|
||||||
|
userEditLog.info('=== USER EDIT PAGE INITIALIZING ===');
|
||||||
|
|
||||||
|
// Prevent multiple initializations
|
||||||
|
if (window._userEditInitialized) {
|
||||||
|
userEditLog.warn('User edit page already initialized, skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window._userEditInitialized = true;
|
||||||
|
|
||||||
|
// Get user ID from URL
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const match = path.match(/\/admin\/users\/(\d+)\/edit/);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
this.userId = parseInt(match[1], 10);
|
||||||
|
userEditLog.info('Editing user:', this.userId);
|
||||||
|
await this.loadUser();
|
||||||
|
} else {
|
||||||
|
userEditLog.error('No user ID in URL');
|
||||||
|
Utils.showToast('Invalid user URL', 'error');
|
||||||
|
setTimeout(() => window.location.href = '/admin/users', 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
userEditLog.info('=== USER EDIT PAGE INITIALIZATION COMPLETE ===');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Load user data
|
||||||
|
async loadUser() {
|
||||||
|
userEditLog.info('Loading user data...');
|
||||||
|
this.loadingUser = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/admin/users/${this.userId}`;
|
||||||
|
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 User', duration);
|
||||||
|
|
||||||
|
this.user = response;
|
||||||
|
|
||||||
|
// Initialize form data
|
||||||
|
this.formData = {
|
||||||
|
username: response.username || '',
|
||||||
|
email: response.email || '',
|
||||||
|
first_name: response.first_name || '',
|
||||||
|
last_name: response.last_name || '',
|
||||||
|
role: response.role || 'vendor',
|
||||||
|
is_email_verified: response.is_email_verified || false
|
||||||
|
};
|
||||||
|
|
||||||
|
userEditLog.info(`User loaded in ${duration}ms`, {
|
||||||
|
user_id: this.user.id,
|
||||||
|
username: this.user.username
|
||||||
|
});
|
||||||
|
userEditLog.debug('Form data initialized:', this.formData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
window.LogConfig.logError(error, 'Load User');
|
||||||
|
Utils.showToast('Failed to load user', 'error');
|
||||||
|
setTimeout(() => window.location.href = '/admin/users', 2000);
|
||||||
|
} finally {
|
||||||
|
this.loadingUser = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return Utils.formatDate(dateString);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
async handleSubmit() {
|
||||||
|
userEditLog.info('=== SUBMITTING USER UPDATE ===');
|
||||||
|
userEditLog.debug('Form data:', this.formData);
|
||||||
|
|
||||||
|
this.errors = {};
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/admin/users/${this.userId}`;
|
||||||
|
window.LogConfig.logApiCall('PUT', url, this.formData, 'request');
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
const response = await apiClient.put(url, this.formData);
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
|
||||||
|
window.LogConfig.logApiCall('PUT', url, response, 'response');
|
||||||
|
window.LogConfig.logPerformance('Update User', duration);
|
||||||
|
|
||||||
|
this.user = response;
|
||||||
|
Utils.showToast('User updated successfully', 'success');
|
||||||
|
userEditLog.info(`User updated successfully in ${duration}ms`, response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
window.LogConfig.logError(error, 'Update User');
|
||||||
|
|
||||||
|
// Handle validation errors
|
||||||
|
if (error.details && error.details.validation_errors) {
|
||||||
|
error.details.validation_errors.forEach(err => {
|
||||||
|
const field = err.loc?.[1] || err.loc?.[0];
|
||||||
|
if (field) {
|
||||||
|
this.errors[field] = err.msg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
userEditLog.debug('Validation errors:', this.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
Utils.showToast(error.message || 'Failed to update user', 'error');
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
userEditLog.info('=== USER UPDATE COMPLETE ===');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle user status
|
||||||
|
async toggleStatus() {
|
||||||
|
const action = this.user.is_active ? 'deactivate' : 'activate';
|
||||||
|
userEditLog.info(`Toggle status: ${action}`);
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to ${action} ${this.user.username}?`)) {
|
||||||
|
userEditLog.info('Status toggle cancelled by user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
const url = `/admin/users/${this.userId}/status`;
|
||||||
|
window.LogConfig.logApiCall('PUT', url, null, 'request');
|
||||||
|
|
||||||
|
const response = await apiClient.put(url);
|
||||||
|
|
||||||
|
window.LogConfig.logApiCall('PUT', url, response, 'response');
|
||||||
|
|
||||||
|
this.user.is_active = response.is_active;
|
||||||
|
Utils.showToast(`User ${action}d successfully`, 'success');
|
||||||
|
userEditLog.info(`User ${action}d successfully`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
window.LogConfig.logError(error, `Toggle Status (${action})`);
|
||||||
|
Utils.showToast(error.message || `Failed to ${action} user`, 'error');
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
async deleteUser() {
|
||||||
|
userEditLog.info('=== DELETING USER ===');
|
||||||
|
|
||||||
|
if (this.user.owned_companies_count > 0) {
|
||||||
|
Utils.showToast(`Cannot delete user who owns ${this.user.owned_companies_count} company(ies). Transfer ownership first.`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to delete user "${this.user.username}"?\n\nThis action cannot be undone.`)) {
|
||||||
|
userEditLog.info('User deletion cancelled by user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double confirmation for critical action
|
||||||
|
if (!confirm(`FINAL CONFIRMATION: Delete "${this.user.username}"?\n\nThis will permanently delete the user.`)) {
|
||||||
|
userEditLog.info('User deletion cancelled at final confirmation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
const url = `/admin/users/${this.userId}`;
|
||||||
|
|
||||||
|
window.LogConfig.logApiCall('DELETE', url, null, 'request');
|
||||||
|
|
||||||
|
await apiClient.delete(url);
|
||||||
|
|
||||||
|
window.LogConfig.logApiCall('DELETE', url, null, 'response');
|
||||||
|
|
||||||
|
Utils.showToast('User deleted successfully', 'success');
|
||||||
|
userEditLog.info('User deleted successfully');
|
||||||
|
|
||||||
|
// Redirect to users list
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/admin/users';
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
window.LogConfig.logError(error, 'Delete User');
|
||||||
|
Utils.showToast(error.message || 'Failed to delete user', 'error');
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
userEditLog.info('=== USER DELETION COMPLETE ===');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
userEditLog.info('User edit module loaded');
|
||||||
@@ -5,25 +5,26 @@ const usersLog = window.LogConfig.loggers.users;
|
|||||||
|
|
||||||
function adminUsers() {
|
function adminUsers() {
|
||||||
return {
|
return {
|
||||||
// ✅ Inherit base layout functionality
|
// Inherit base layout functionality
|
||||||
...data(),
|
...data(),
|
||||||
|
|
||||||
// ✅ Set page identifier
|
// Set page identifier
|
||||||
currentPage: 'users',
|
currentPage: 'users',
|
||||||
|
|
||||||
// State
|
// State
|
||||||
users: [],
|
users: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
error: null,
|
||||||
filters: {
|
filters: {
|
||||||
search: '',
|
search: '',
|
||||||
role: '',
|
role: '',
|
||||||
is_active: ''
|
is_active: ''
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
total: 0,
|
total_users: 0,
|
||||||
active: 0,
|
active_users: 0,
|
||||||
vendors: 0,
|
inactive_users: 0,
|
||||||
admins: 0
|
admin_users: 0
|
||||||
},
|
},
|
||||||
pagination: {
|
pagination: {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -49,23 +50,93 @@ function adminUsers() {
|
|||||||
usersLog.info('=== USERS PAGE INITIALIZATION COMPLETE ===');
|
usersLog.info('=== 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) {
|
||||||
|
// Show all pages if 7 or fewer
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Always show first page
|
||||||
|
pages.push(1);
|
||||||
|
|
||||||
|
if (current > 3) {
|
||||||
|
pages.push('...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show pages around current page
|
||||||
|
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('...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show last page
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
},
|
||||||
|
|
||||||
// Load users from API
|
// Load users from API
|
||||||
async loadUsers() {
|
async loadUsers() {
|
||||||
usersLog.info('Loading users...');
|
usersLog.info('Loading users...');
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams();
|
||||||
page: this.pagination.page,
|
params.append('page', this.pagination.page);
|
||||||
per_page: this.pagination.per_page,
|
params.append('per_page', this.pagination.per_page);
|
||||||
...this.filters
|
|
||||||
});
|
if (this.filters.search) {
|
||||||
|
params.append('search', this.filters.search);
|
||||||
|
}
|
||||||
|
if (this.filters.role) {
|
||||||
|
params.append('role', this.filters.role);
|
||||||
|
}
|
||||||
|
if (this.filters.is_active) {
|
||||||
|
params.append('is_active', this.filters.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
const url = `/admin/users?${params}`;
|
const url = `/admin/users?${params}`;
|
||||||
window.LogConfig.logApiCall('GET', url, null, 'request');
|
window.LogConfig.logApiCall('GET', url, null, 'request');
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
const response = await apiClient.get(url); // ✅ Fixed: lowercase apiClient
|
const response = await apiClient.get(url);
|
||||||
const duration = performance.now() - startTime;
|
const duration = performance.now() - startTime;
|
||||||
|
|
||||||
window.LogConfig.logApiCall('GET', url, response, 'response');
|
window.LogConfig.logApiCall('GET', url, response, 'response');
|
||||||
@@ -75,10 +146,13 @@ function adminUsers() {
|
|||||||
this.users = response.items;
|
this.users = response.items;
|
||||||
this.pagination.total = response.total;
|
this.pagination.total = response.total;
|
||||||
this.pagination.pages = response.pages;
|
this.pagination.pages = response.pages;
|
||||||
|
this.pagination.page = response.page;
|
||||||
|
this.pagination.per_page = response.per_page;
|
||||||
usersLog.info(`Loaded ${this.users.length} users`);
|
usersLog.info(`Loaded ${this.users.length} users`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.LogConfig.logError(error, 'Load Users');
|
window.LogConfig.logError(error, 'Load Users');
|
||||||
|
this.error = error.message || 'Failed to load users';
|
||||||
Utils.showToast('Failed to load users', 'error');
|
Utils.showToast('Failed to load users', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -107,11 +181,18 @@ function adminUsers() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Search with debounce
|
// Search with debounce
|
||||||
debouncedSearch: Utils.debounce(function() {
|
debouncedSearch() {
|
||||||
usersLog.info('Search triggered:', this.filters.search);
|
// Clear existing timeout
|
||||||
this.pagination.page = 1;
|
if (this._searchTimeout) {
|
||||||
this.loadUsers();
|
clearTimeout(this._searchTimeout);
|
||||||
}, 500),
|
}
|
||||||
|
// Set new timeout
|
||||||
|
this._searchTimeout = setTimeout(() => {
|
||||||
|
usersLog.info('Search triggered:', this.filters.search);
|
||||||
|
this.pagination.page = 1;
|
||||||
|
this.loadUsers();
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
nextPage() {
|
nextPage() {
|
||||||
@@ -130,15 +211,23 @@ function adminUsers() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
goToPage(pageNum) {
|
||||||
|
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
|
||||||
|
this.pagination.page = pageNum;
|
||||||
|
usersLog.info('Go to page:', this.pagination.page);
|
||||||
|
this.loadUsers();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
viewUser(user) {
|
viewUser(user) {
|
||||||
usersLog.info('View user:', user.username);
|
usersLog.info('View user:', user.username);
|
||||||
// TODO: Open view modal
|
window.location.href = `/admin/users/${user.id}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
editUser(user) {
|
editUser(user) {
|
||||||
usersLog.info('Edit user:', user.username);
|
usersLog.info('Edit user:', user.username);
|
||||||
// TODO: Open edit modal
|
window.location.href = `/admin/users/${user.id}/edit`;
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleUserStatus(user) {
|
async toggleUserStatus(user) {
|
||||||
@@ -196,7 +285,7 @@ function adminUsers() {
|
|||||||
|
|
||||||
openCreateModal() {
|
openCreateModal() {
|
||||||
usersLog.info('Open create user modal');
|
usersLog.info('Open create user modal');
|
||||||
// TODO: Open create modal
|
window.location.href = '/admin/users/create';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user