feat: add complete company management UI

- Create companies list page with stats (total, verified, active, vendor count)
- Add company creation form with owner account generation
- Implement companies.js with full CRUD operations (list, create, edit, delete)
- Add Companies menu item to admin sidebar (desktop + mobile)
- Create company admin page routes (/admin/companies, /admin/companies/create)
- Register companies API router in admin __init__.py

Features:
- List all companies with pagination
- Create company with automatic owner user creation
- Display temporary password for new owner accounts
- Edit company information
- Delete company (only if no vendors)
- Toggle active/verified status
- Show vendor count per company

UI Components:
- Stats cards (total companies, verified, active, total vendors)
- Company table with status badges
- Create form with validation
- Success/error messaging
- Responsive design with dark mode support
This commit is contained in:
2025-12-01 21:50:20 +01:00
parent 4ca738dc7f
commit 801510ecc6
6 changed files with 1045 additions and 13 deletions

View File

@@ -28,8 +28,10 @@ from . import (
audit,
auth,
code_quality,
companies,
content_pages,
dashboard,
logs,
marketplace,
monitoring,
notifications,
@@ -53,9 +55,12 @@ router.include_router(auth.router, tags=["admin-auth"])
# ============================================================================
# Vendor Management
# Company & Vendor Management
# ============================================================================
# Include company management endpoints
router.include_router(companies.router, tags=["admin-companies"])
# Include vendor management endpoints
router.include_router(vendors.router, tags=["admin-vendors"])
@@ -111,6 +116,9 @@ router.include_router(settings.router, tags=["admin-settings"])
# Include notifications and alerts endpoints
router.include_router(notifications.router, tags=["admin-notifications"])
# Include log management endpoints
router.include_router(logs.router, tags=["admin-logs"])
# ============================================================================
# Code Quality & Architecture

View File

@@ -17,6 +17,7 @@ Routes:
- GET /vendors/{vendor_code} → Vendor details (auth required)
- GET /vendors/{vendor_code}/edit → Edit vendor form (auth required)
- GET /vendors/{vendor_code}/domains → Vendor domains management (auth required)
- GET /vendor-themes → Vendor themes selection page (auth required)
- GET /vendors/{vendor_code}/theme → Vendor theme editor (auth required)
- GET /users → User management page (auth required)
- GET /imports → Import history page (auth required)
@@ -109,6 +110,48 @@ async def admin_dashboard_page(
)
# ============================================================================
# COMPANY MANAGEMENT ROUTES
# ============================================================================
@router.get("/companies", response_class=HTMLResponse, include_in_schema=False)
async def admin_companies_list_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render companies management page.
Shows list of all companies with stats.
"""
return templates.TemplateResponse(
"admin/companies.html",
{
"request": request,
"user": current_user,
},
)
@router.get("/companies/create", response_class=HTMLResponse, include_in_schema=False)
async def admin_company_create_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render company creation form.
"""
return templates.TemplateResponse(
"admin/company-create.html",
{
"request": request,
"user": current_user,
},
)
# ============================================================================
# VENDOR MANAGEMENT ROUTES
# ============================================================================
@@ -231,6 +274,25 @@ async def admin_vendor_domains_page(
# ============================================================================
@router.get("/vendor-themes", response_class=HTMLResponse, include_in_schema=False)
async def admin_vendor_themes_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render vendor themes selection page.
Allows admins to select a vendor to customize their theme.
"""
return templates.TemplateResponse(
"admin/vendor-themes.html",
{
"request": request,
"user": current_user,
},
)
@router.get(
"/vendors/{vendor_code}/theme", response_class=HTMLResponse, include_in_schema=False
)
@@ -302,6 +364,25 @@ async def admin_imports_page(
)
@router.get("/marketplace", response_class=HTMLResponse, include_in_schema=False)
async def admin_marketplace_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render marketplace import management page.
Allows admins to import products for any vendor and monitor all imports.
"""
return templates.TemplateResponse(
"admin/marketplace.html",
{
"request": request,
"user": current_user,
},
)
# ============================================================================
# SETTINGS ROUTES
# ============================================================================
@@ -326,6 +407,25 @@ async def admin_settings_page(
)
@router.get("/logs", response_class=HTMLResponse, include_in_schema=False)
async def admin_logs_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render admin logs viewer page.
View database and file logs with filtering and search.
"""
return templates.TemplateResponse(
"admin/logs.html",
{
"request": request,
"user": current_user,
},
)
# ============================================================================
# CONTENT MANAGEMENT SYSTEM (CMS) ROUTES
# ============================================================================

View File

@@ -0,0 +1,272 @@
{# app/templates/admin/companies.html #}
{% extends "admin/base.html" %}
{% block title %}Companies{% endblock %}
{% block alpine_data %}adminCompanies(){% endblock %}
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Company Management
</h2>
<a
href="/admin/companies/create"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Company
</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 companies...</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 companies</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Companies -->
<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">
Total Companies
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total || 0">
0
</p>
</div>
</div>
<!-- Card: Verified Companies -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('badge-check', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Verified
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.verified || 0">
0
</p>
</div>
</div>
<!-- Card: Active Companies -->
<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('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active || 0">
0
</p>
</div>
</div>
<!-- Card: Total Vendors -->
<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('shopping-bag', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Vendors
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.totalVendors || 0">
0
</p>
</div>
</div>
</div>
<!-- Companies 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">Company</th>
<th class="px-4 py-3">Owner</th>
<th class="px-4 py-3">Vendors</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Created</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="paginatedCompanies.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('office-building', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">No companies found</p>
<p class="text-xs mt-1">Create your first company to get started</p>
</div>
</td>
</tr>
</template>
<!-- Company Rows -->
<template x-for="company in paginatedCompanies" :key="company.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- Company Info -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-blue-100 dark:bg-blue-600 flex items-center justify-center">
<span class="text-xs font-semibold text-blue-600 dark:text-blue-100"
x-text="company.name?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div>
<p class="font-semibold" x-text="company.name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="company.contact_email"></p>
</div>
</div>
</td>
<!-- Owner Email -->
<td class="px-4 py-3 text-sm">
<p x-text="company.owner_email || 'N/A'"></p>
</td>
<!-- Vendor Count -->
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 font-semibold leading-tight text-blue-700 bg-blue-100 rounded-full dark:bg-blue-700 dark:text-blue-100"
x-text="company.vendor_count || 0">
0
</span>
</td>
<!-- Status Badges -->
<td class="px-4 py-3 text-xs">
<div class="flex gap-1">
<span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
:class="company.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
<span x-text="company.is_active ? 'Active' : 'Inactive'"></span>
</span>
<span x-show="company.is_verified" class="inline-flex items-center px-2 py-1 font-semibold leading-tight text-blue-700 bg-blue-100 rounded-full dark:bg-blue-700 dark:text-blue-100">
<span x-html="$icon('badge-check', 'w-3 h-3 mr-1')"></span>
Verified
</span>
</div>
</td>
<!-- Created Date -->
<td class="px-4 py-3 text-sm" x-text="formatDate(company.created_at)"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<!-- Edit Button -->
<button
@click="editCompany(company.id)"
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 company"
>
<span x-html="$icon('edit', 'w-5 h-5')"></span>
</button>
<!-- Delete Button -->
<button
@click="deleteCompany(company)"
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 company"
:disabled="company.vendor_count > 0"
:class="company.vendor_count > 0 ? 'opacity-50 cursor-not-allowed' : ''"
>
<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="companies.length"></span>
</span>
<span class="col-span-2"></span>
<!-- Pagination Controls -->
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<!-- Previous Button -->
<li>
<button
@click="previousPage()"
:disabled="currentPage === 1"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple"
:class="currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
aria-label="Previous"
>
<svg class="w-4 h-4 fill-current" aria-hidden="true" viewBox="0 0 20 20">
<path d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" fill-rule="evenodd"></path>
</svg>
</button>
</li>
<!-- Page Numbers -->
<template x-for="page in pageNumbers" :key="page">
<li>
<button
x-show="page !== '...'"
@click="goToPage(page)"
class="px-3 py-1 rounded-md focus:outline-none focus:shadow-outline-purple"
:class="currentPage === page ? 'text-white bg-purple-600 border border-purple-600' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="page"
></button>
<span x-show="page === '...'" class="px-3 py-1">...</span>
</li>
</template>
<!-- Next Button -->
<li>
<button
@click="nextPage()"
:disabled="currentPage === totalPages"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple"
:class="currentPage === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
aria-label="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>
</li>
</ul>
</nav>
</span>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/companies.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,309 @@
{# app/templates/admin/company-create.html #}
{% extends "admin/base.html" %}
{% block title %}Create Company{% endblock %}
{% block alpine_data %}adminCompanyCreate(){% 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 Company
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Create a company account with an owner user
</p>
</div>
<a
href="/admin/companies"
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-500 focus:outline-none focus:shadow-outline-gray"
>
<span class="flex items-center">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Companies
</span>
</a>
</div>
<!-- Success Message -->
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg">
<div class="flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div class="flex-1">
<p class="font-semibold">Company Created Successfully!</p>
<div x-show="ownerCredentials" class="mt-2 p-3 bg-white rounded border border-green-300">
<p class="text-sm font-semibold mb-2">Owner Login Credentials (Save these!):</p>
<div class="space-y-1 text-sm font-mono">
<div><span class="font-bold">Email:</span> <span x-text="ownerCredentials.email"></span></div>
<div><span class="font-bold">Password:</span> <span x-text="ownerCredentials.password" class="bg-yellow-100 px-2 py-1 rounded"></span></div>
<div><span class="font-bold">Login URL:</span> <span x-text="ownerCredentials.login_url"></span></div>
</div>
<p class="mt-2 text-xs text-red-600">⚠️ The password will only be shown once. Please save it now!</p>
</div>
</div>
</div>
</div>
<!-- Error Message -->
<div x-show="errorMessage" x-cloak class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg">
<div class="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 Creating Company</p>
<p class="text-sm mt-1" x-text="errorMessage"></p>
</div>
</div>
</div>
<!-- Create Company Form -->
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<form @submit.prevent="createCompany">
<!-- Company Information Section -->
<div class="mb-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Company Information</h3>
<div class="grid gap-6 mb-4 md:grid-cols-2">
<!-- Company Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Company Name <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="formData.name"
required
class="block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
placeholder="ACME Corporation"
/>
</div>
<!-- Contact Email (Business) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Business Contact Email <span class="text-red-500">*</span>
</label>
<input
type="email"
x-model="formData.contact_email"
required
class="block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
placeholder="info@acmecorp.com"
/>
<p class="mt-1 text-xs text-gray-500">Public business contact email</p>
</div>
</div>
<!-- Description -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Description
</label>
<textarea
x-model="formData.description"
rows="3"
class="block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
placeholder="Brief description of the company..."
></textarea>
</div>
<div class="grid gap-6 mb-4 md:grid-cols-2">
<!-- Contact Phone -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Phone Number
</label>
<input
type="tel"
x-model="formData.contact_phone"
class="block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
placeholder="+352 123 456 789"
/>
</div>
<!-- Website -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Website
</label>
<input
type="url"
x-model="formData.website"
class="block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
placeholder="https://www.acmecorp.com"
/>
</div>
</div>
<div class="grid gap-6 mb-4 md:grid-cols-2">
<!-- Business Address -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Business Address
</label>
<textarea
x-model="formData.business_address"
rows="2"
class="block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
placeholder="123 Main Street, Luxembourg City"
></textarea>
</div>
<!-- Tax Number -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Tax/VAT Number
</label>
<input
type="text"
x-model="formData.tax_number"
class="block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
placeholder="LU12345678"
/>
</div>
</div>
</div>
<!-- Owner Information Section -->
<div class="mb-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Owner Account</h3>
<div class="p-4 mb-4 bg-blue-50 border border-blue-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<p class="text-sm text-blue-800 dark:text-blue-300">
<span x-html="$icon('information-circle', 'w-4 h-4 inline mr-1')"></span>
A user account will be created for the company owner. If the email already exists, that user will be assigned as owner.
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Owner Email (Login) <span class="text-red-500">*</span>
</label>
<input
type="email"
x-model="formData.owner_email"
required
class="block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
placeholder="john.smith@acmecorp.com"
/>
<p class="mt-1 text-xs text-gray-500">This email will be used for owner login. Can be different from business contact email.</p>
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="window.location.href='/admin/companies'"
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-500 focus:outline-none focus:shadow-outline-gray"
>
Cancel
</button>
<button
type="submit"
:disabled="loading"
class="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="!loading">Create Company</span>
<span x-show="loading" class="flex items-center">
<span x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Creating...
</span>
</button>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Company Create Alpine Component
function adminCompanyCreate() {
return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'companies',
// Form data
formData: {
name: '',
description: '',
owner_email: '',
contact_email: '',
contact_phone: '',
website: '',
business_address: '',
tax_number: ''
},
// UI state
loading: false,
successMessage: false,
errorMessage: '',
ownerCredentials: null,
// Initialize
init() {
console.log('Company Create page initialized');
},
// Create company
async createCompany() {
this.loading = true;
this.errorMessage = '';
this.successMessage = false;
this.ownerCredentials = null;
try {
console.log('Creating company:', this.formData);
const response = await apiClient.post('/api/v1/admin/companies', this.formData);
console.log('Company created successfully:', response);
// Store owner credentials
if (response.temporary_password && response.temporary_password !== 'N/A (Existing user)') {
this.ownerCredentials = {
email: response.owner_email,
password: response.temporary_password,
login_url: response.login_url
};
}
this.successMessage = true;
// Reset form
this.formData = {
name: '',
description: '',
owner_email: '',
contact_email: '',
contact_phone: '',
website: '',
business_address: '',
tax_number: ''
};
// Scroll to top to show success message
window.scrollTo({ top: 0, behavior: 'smooth' });
// Redirect after 10 seconds if credentials shown, or 3 seconds otherwise
const redirectDelay = this.ownerCredentials ? 10000 : 3000;
setTimeout(() => {
window.location.href = '/admin/companies';
}, redirectDelay);
} catch (error) {
console.error('Failed to create company:', error);
this.errorMessage = error.message || 'Failed to create company';
window.scrollTo({ top: 0, behavior: 'smooth' });
} finally {
this.loading = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -20,6 +20,17 @@
<!-- Main Navigation -->
<ul>
<!-- Companies -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'companies'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'companies' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/companies">
<span x-html="$icon('office-building')"></span>
<span class="ml-4">Companies</span>
</a>
</li>
<!-- Vendors -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
@@ -42,14 +53,14 @@
</a>
</li>
<!-- Import Jobs -->
<!-- Marketplace Import -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'imports'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<span x-show="currentPage === 'marketplace'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'imports' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/imports">
<span x-html="$icon('cube')"></span>
<span class="ml-4">Import Jobs</span>
:class="currentPage === 'marketplace' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/marketplace">
<span x-html="$icon('shopping-bag')"></span>
<span class="ml-4">Marketplace Import</span>
</a>
</li>
</ul>
@@ -83,6 +94,17 @@
<span class="ml-4">Content Pages</span>
</a>
</li>
<!-- Vendor Themes -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'vendor-theme'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'vendor-theme' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/vendor-themes">
<span x-html="$icon('color-swatch')"></span>
<span class="ml-4">Vendor Themes</span>
</a>
</li>
</ul>
<!-- Developer Tools Section -->
@@ -138,6 +160,37 @@
</li>
</ul>
<!-- Platform Monitoring Section -->
<div class="px-6 my-6">
<hr class="border-gray-200 dark:border-gray-700" />
</div>
<p class="px-6 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Platform Monitoring
</p>
<ul class="mt-3">
<!-- Import Jobs -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'imports'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'imports' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/imports">
<span x-html="$icon('cube')"></span>
<span class="ml-4">Import Jobs</span>
</a>
</li>
<!-- Application Logs -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'logs'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'logs' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/logs">
<span x-html="$icon('document-text')"></span>
<span class="ml-4">Application Logs</span>
</a>
</li>
</ul>
<!-- Settings Section -->
<div class="px-6 my-6">
<hr class="border-gray-200 dark:border-gray-700" />
@@ -196,6 +249,17 @@
<!-- Main Navigation -->
<ul>
<!-- Companies -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'companies'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'companies' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/companies">
<span x-html="$icon('office-building')"></span>
<span class="ml-4">Companies</span>
</a>
</li>
<!-- Vendors -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
@@ -218,14 +282,14 @@
</a>
</li>
<!-- Import Jobs -->
<!-- Marketplace Import -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'imports'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<span x-show="currentPage === 'marketplace'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'imports' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/imports">
<span x-html="$icon('cube')"></span>
<span class="ml-4">Import Jobs</span>
:class="currentPage === 'marketplace' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/marketplace">
<span x-html="$icon('shopping-bag')"></span>
<span class="ml-4">Marketplace Import</span>
</a>
</li>
</ul>
@@ -259,6 +323,17 @@
<span class="ml-4">Content Pages</span>
</a>
</li>
<!-- Vendor Themes -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'vendor-theme'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'vendor-theme' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/vendor-themes">
<span x-html="$icon('color-swatch')"></span>
<span class="ml-4">Vendor Themes</span>
</a>
</li>
</ul>
<!-- Developer Tools Section -->
@@ -314,6 +389,37 @@
</li>
</ul>
<!-- Platform Monitoring Section -->
<div class="px-6 my-6">
<hr class="border-gray-200 dark:border-gray-700" />
</div>
<p class="px-6 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Platform Monitoring
</p>
<ul class="mt-3">
<!-- Import Jobs -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'imports'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'imports' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/imports">
<span x-html="$icon('cube')"></span>
<span class="ml-4">Import Jobs</span>
</a>
</li>
<!-- Application Logs -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'logs'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'logs' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/logs">
<span x-html="$icon('document-text')"></span>
<span class="ml-4">Application Logs</span>
</a>
</li>
</ul>
<!-- Settings Section -->
<div class="px-6 my-6">
<hr class="border-gray-200 dark:border-gray-700" />

View File

@@ -0,0 +1,237 @@
// static/admin/js/companies.js
// ✅ Use centralized logger
const companiesLog = window.LogConfig.loggers.companies || window.LogConfig.createLogger('companies');
// ============================================
// COMPANY LIST FUNCTION
// ============================================
function adminCompanies() {
return {
// Inherit base layout functionality
...data(),
// ✅ Page identifier for sidebar active state
currentPage: 'companies',
// Companies page specific state
companies: [],
stats: {
total: 0,
verified: 0,
active: 0,
totalVendors: 0
},
loading: false,
error: null,
// Pagination state
page: 1,
itemsPerPage: 10,
// Initialize
async init() {
companiesLog.info('=== COMPANIES PAGE INITIALIZING ===');
// Prevent multiple initializations
if (window._companiesInitialized) {
companiesLog.warn('Companies page already initialized, skipping...');
return;
}
window._companiesInitialized = true;
companiesLog.group('Loading companies data');
await this.loadCompanies();
await this.loadStats();
companiesLog.groupEnd();
companiesLog.info('=== COMPANIES PAGE INITIALIZATION COMPLETE ===');
},
// Computed: Get paginated companies for current page
get paginatedCompanies() {
const start = (this.page - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return this.companies.slice(start, end);
},
// Computed: Total number of pages
get totalPages() {
return Math.ceil(this.companies.length / this.itemsPerPage);
},
// Computed: Start index for pagination display
get startIndex() {
if (this.companies.length === 0) return 0;
return (this.page - 1) * this.itemsPerPage + 1;
},
// Computed: End index for pagination display
get endIndex() {
const end = this.page * this.itemsPerPage;
return end > this.companies.length ? this.companies.length : end;
},
// Computed: Generate page numbers array with ellipsis
get pageNumbers() {
const pages = [];
const totalPages = this.totalPages;
const current = this.page;
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
if (current > 3) {
pages.push('...');
}
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (current < totalPages - 2) {
pages.push('...');
}
pages.push(totalPages);
}
return pages;
},
// Load all companies
async loadCompanies() {
this.loading = true;
this.error = null;
try {
companiesLog.info('Fetching companies from API...');
const response = await apiClient.get('/admin/companies');
if (response.companies) {
this.companies = response.companies;
companiesLog.info(`Loaded ${this.companies.length} companies`);
} else {
companiesLog.warn('No companies in response');
this.companies = [];
}
} catch (error) {
companiesLog.error('Failed to load companies:', error);
this.error = error.message || 'Failed to load companies';
this.companies = [];
} finally {
this.loading = false;
}
},
// Load statistics
async loadStats() {
try {
companiesLog.info('Calculating stats from companies...');
this.stats.total = this.companies.length;
this.stats.verified = this.companies.filter(c => c.is_verified).length;
this.stats.active = this.companies.filter(c => c.is_active).length;
this.stats.totalVendors = this.companies.reduce((sum, c) => sum + (c.vendor_count || 0), 0);
companiesLog.info('Stats calculated:', this.stats);
} catch (error) {
companiesLog.error('Failed to calculate stats:', error);
}
},
// Edit company
editCompany(companyId) {
companiesLog.info('Edit company:', companyId);
// TODO: Navigate to edit page
window.location.href = `/admin/companies/${companyId}/edit`;
},
// Delete company
async deleteCompany(company) {
if (company.vendor_count > 0) {
companiesLog.warn('Cannot delete company with vendors');
alert(`Cannot delete "${company.name}" because it has ${company.vendor_count} vendor(s). Please delete or reassign the vendors first.`);
return;
}
const confirmed = confirm(
`Are you sure you want to delete "${company.name}"?\n\nThis action cannot be undone.`
);
if (!confirmed) {
companiesLog.info('Delete cancelled by user');
return;
}
try {
companiesLog.info('Deleting company:', company.id);
await apiClient.delete(`/admin/companies/${company.id}?confirm=true`);
companiesLog.info('Company deleted successfully');
// Reload companies
await this.loadCompanies();
await this.loadStats();
alert(`Company "${company.name}" deleted successfully`);
} catch (error) {
companiesLog.error('Failed to delete company:', error);
alert(`Failed to delete company: ${error.message}`);
}
},
// Pagination methods
previousPage() {
if (this.page > 1) {
this.page--;
companiesLog.info('Previous page:', this.page);
}
},
nextPage() {
if (this.page < this.totalPages) {
this.page++;
companiesLog.info('Next page:', this.page);
}
},
goToPage(pageNum) {
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
this.page = pageNum;
companiesLog.info('Go to page:', this.page);
}
},
// Format date for display
formatDate(dateString) {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
companiesLog.error('Date parsing error:', e);
return dateString;
}
}
};
}
// Register logger for configuration
if (!window.LogConfig.loggers.companies) {
window.LogConfig.loggers.companies = window.LogConfig.createLogger('companies');
}
companiesLog.info('✅ Companies module loaded');