Some checks failed
- Add redis-exporter container to docker-compose (oliver006/redis_exporter, 32MB) - Add Redis scrape target to Prometheus config - Add 4 Redis alert rules: RedisDown, HighMemory, HighConnections, RejectedConnections - Document Step 19b (Sentry Error Tracking) in Hetzner deployment guide - Document Step 19c (Redis Monitoring) in Hetzner deployment guide - Update resource budget and port reference tables Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1707 lines
55 KiB
Markdown
1707 lines
55 KiB
Markdown
# Admin Frontend - Alpine.js/Jinja2 Page Template Guide
|
|
|
|
## 📋 Overview
|
|
|
|
This guide provides complete templates for creating new admin pages using the established Alpine.js + Jinja2 architecture. Follow these patterns to ensure consistency, proper initialization, and optimal performance across the admin portal.
|
|
|
|
---
|
|
|
|
## 🎯 Quick Reference
|
|
|
|
### File Structure for New Page
|
|
```
|
|
app/
|
|
├── templates/admin/
|
|
│ └── [page-name].html # Jinja2 template
|
|
├── static/admin/js/
|
|
│ └── [page-name].js # Alpine.js component
|
|
└── api/v1/admin/
|
|
└── pages.py # Route registration
|
|
```
|
|
|
|
### Checklist for New Page
|
|
- [ ] Create Jinja2 template extending admin/base.html
|
|
- [ ] Create Alpine.js JavaScript component
|
|
- [ ] Use centralized logger (one line!)
|
|
- [ ] Add ...data() for base inheritance
|
|
- [ ] Set currentPage identifier
|
|
- [ ] Add initialization guard
|
|
- [ ] Use lowercase apiClient
|
|
- [ ] Register route in pages.py
|
|
- [ ] Add sidebar navigation link
|
|
- [ ] Test authentication
|
|
- [ ] Test dark mode
|
|
- [ ] Verify no duplicate initialization
|
|
|
|
---
|
|
|
|
## 📄 Template Structure
|
|
|
|
### 1. Jinja2 Template
|
|
|
|
**File:** `app/templates/admin/[page-name].html`
|
|
|
|
```jinja2
|
|
{# app/templates/admin/[page-name].html #}
|
|
{% extends "admin/base.html" %}
|
|
|
|
{# Page title for browser tab #}
|
|
{% block title %}[Page Name]{% endblock %}
|
|
|
|
{# ✅ CRITICAL: Alpine.js component name #}
|
|
{% block alpine_data %}admin[PageName](){% endblock %}
|
|
|
|
{# Page content #}
|
|
{% 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">
|
|
[Page Name]
|
|
</h2>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="flex items-center space-x-3">
|
|
<button
|
|
@click="refresh()"
|
|
:disabled="loading"
|
|
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"
|
|
>
|
|
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
|
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
|
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
|
</button>
|
|
|
|
<button
|
|
@click="openCreateModal()"
|
|
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>
|
|
<span>Add New</span>
|
|
</button>
|
|
</div>
|
|
</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 data...</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 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200">
|
|
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
|
<div>
|
|
<p class="font-semibold">Error</p>
|
|
<p class="text-sm" x-text="error"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<!-- FILTERS & SEARCH -->
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<div x-show="!loading" class="mb-6 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<!-- Search -->
|
|
<div>
|
|
<label class="block text-sm">
|
|
<span class="text-gray-700 dark:text-gray-400">Search</span>
|
|
<input
|
|
x-model="filters.search"
|
|
@input.debounce.300ms="applyFilters()"
|
|
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
|
placeholder="Search..."
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Status Filter -->
|
|
<div>
|
|
<label class="block text-sm">
|
|
<span class="text-gray-700 dark:text-gray-400">Status</span>
|
|
<select
|
|
x-model="filters.status"
|
|
@change="applyFilters()"
|
|
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 form-select focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray"
|
|
>
|
|
<option value="">All</option>
|
|
<option value="active">Active</option>
|
|
<option value="inactive">Inactive</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Sort -->
|
|
<div>
|
|
<label class="block text-sm">
|
|
<span class="text-gray-700 dark:text-gray-400">Sort By</span>
|
|
<select
|
|
x-model="filters.sortBy"
|
|
@change="applyFilters()"
|
|
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 form-select focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray"
|
|
>
|
|
<option value="created_at:desc">Newest First</option>
|
|
<option value="created_at:asc">Oldest First</option>
|
|
<option value="name:asc">Name (A-Z)</option>
|
|
<option value="name:desc">Name (Z-A)</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<!-- DATA 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">Name</th>
|
|
<th class="px-4 py-3">Status</th>
|
|
<th class="px-4 py-3">Date</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="items.length === 0">
|
|
<tr>
|
|
<td colspan="4" 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('inbox', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
|
<p>No items found.</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
|
|
<!-- Data Rows -->
|
|
<template x-for="item in items" :key="item.id">
|
|
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center text-sm">
|
|
<div>
|
|
<p class="font-semibold" x-text="item.name"></p>
|
|
<p class="text-xs text-gray-600 dark:text-gray-400"
|
|
x-text="item.description"></p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3 text-xs">
|
|
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
|
:class="item.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'"
|
|
x-text="item.is_active ? 'Active' : 'Inactive'"></span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm" x-text="formatDate(item.created_at)">
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center space-x-2 text-sm">
|
|
<button
|
|
@click="viewItem(item.id)"
|
|
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-gray-400 dark:hover:bg-gray-700"
|
|
title="View"
|
|
>
|
|
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
|
</button>
|
|
<button
|
|
@click="editItem(item.id)"
|
|
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-gray-400 dark:hover:bg-gray-700"
|
|
title="Edit"
|
|
>
|
|
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
|
</button>
|
|
<button
|
|
@click="deleteItem(item.id)"
|
|
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-gray-400 dark:hover:bg-gray-700"
|
|
title="Delete"
|
|
>
|
|
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div x-show="pagination.totalPages > 1"
|
|
class="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">
|
|
<span class="flex items-center col-span-3">
|
|
Showing
|
|
<span x-text="pagination.from"></span>
|
|
-
|
|
<span x-text="pagination.to"></span>
|
|
of
|
|
<span x-text="pagination.total"></span>
|
|
</span>
|
|
<span class="col-span-2"></span>
|
|
<!-- Pagination buttons -->
|
|
<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">
|
|
<li>
|
|
<button
|
|
@click="previousPage()"
|
|
:disabled="pagination.currentPage === 1"
|
|
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
|
aria-label="Previous"
|
|
>
|
|
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
|
|
</button>
|
|
</li>
|
|
|
|
<template x-for="page in paginationRange" :key="page">
|
|
<li>
|
|
<button
|
|
@click="goToPage(page)"
|
|
:class="page === pagination.currentPage
|
|
? 'text-white bg-purple-600'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700'"
|
|
class="px-3 py-1 rounded-md focus:outline-none focus:shadow-outline-purple"
|
|
x-text="page"
|
|
></button>
|
|
</li>
|
|
</template>
|
|
|
|
<li>
|
|
<button
|
|
@click="nextPage()"
|
|
:disabled="pagination.currentPage === pagination.totalPages"
|
|
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
|
aria-label="Next"
|
|
>
|
|
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<!-- CREATE/EDIT MODAL -->
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<div x-show="showModal"
|
|
x-cloak
|
|
class="fixed inset-0 z-50 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
|
@click.away="closeModal()"
|
|
@keydown.escape.window="closeModal()">
|
|
|
|
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl">
|
|
|
|
<!-- Modal header -->
|
|
<div class="flex items-center justify-between pb-4 border-b dark:border-gray-700">
|
|
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
|
|
x-text="modalMode === 'create' ? 'Create New Item' : 'Edit Item'"></h3>
|
|
<button
|
|
@click="closeModal()"
|
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
|
>
|
|
<span x-html="$icon('x', 'w-6 h-6')"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Modal body -->
|
|
<div class="mt-4">
|
|
<form @submit.prevent="saveItem()">
|
|
|
|
<!-- Name Field -->
|
|
<label class="block text-sm mb-4">
|
|
<span class="text-gray-700 dark:text-gray-400">Name</span>
|
|
<input
|
|
x-model="formData.name"
|
|
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
|
:class="errors.name ? 'border-red-600' : ''"
|
|
placeholder="Enter name"
|
|
/>
|
|
<span x-show="errors.name"
|
|
class="text-xs text-red-600 dark:text-red-400"
|
|
x-text="errors.name"></span>
|
|
</label>
|
|
|
|
<!-- Description Field -->
|
|
<label class="block text-sm mb-4">
|
|
<span class="text-gray-700 dark:text-gray-400">Description</span>
|
|
<textarea
|
|
x-model="formData.description"
|
|
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 form-textarea focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray"
|
|
rows="3"
|
|
placeholder="Enter description"
|
|
></textarea>
|
|
</label>
|
|
|
|
<!-- Status Field -->
|
|
<label class="block text-sm mb-4">
|
|
<span class="text-gray-700 dark:text-gray-400">Status</span>
|
|
<select
|
|
x-model="formData.is_active"
|
|
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 form-select focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray"
|
|
>
|
|
<option :value="true">Active</option>
|
|
<option :value="false">Inactive</option>
|
|
</select>
|
|
</label>
|
|
|
|
<!-- Modal footer -->
|
|
<div class="flex flex-col items-center justify-end px-6 py-3 -mx-6 -mb-4 space-y-4 sm:space-y-0 sm:space-x-6 sm:flex-row bg-gray-50 dark:bg-gray-800">
|
|
<button
|
|
@click="closeModal()"
|
|
type="button"
|
|
class="w-full px-5 py-3 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 border border-gray-300 rounded-lg dark:text-gray-400 sm:px-4 sm:py-2 sm:w-auto active:bg-transparent hover:border-gray-500 focus:border-gray-500 active:text-gray-500 focus:outline-none focus:shadow-outline-gray"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
:disabled="saving"
|
|
class="w-full px-5 py-3 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg sm:w-auto sm:px-4 sm:py-2 active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
|
>
|
|
<span x-show="!saving" x-text="modalMode === 'create' ? 'Create' : 'Save'"></span>
|
|
<span x-show="saving">Saving...</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{# Page-specific JavaScript #}
|
|
{% block extra_scripts %}
|
|
<script src="{{ url_for('static', path='admin/js/[page-name].js') }}"></script>
|
|
{% endblock %}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Alpine.js Component
|
|
|
|
**File:** `app/static/admin/js/[page-name].js`
|
|
|
|
```javascript
|
|
// static/admin/js/[page-name].js
|
|
/**
|
|
* [Page Name] Component
|
|
* Handles [describe functionality]
|
|
*/
|
|
|
|
// ✅ CRITICAL: Use centralized logger (ONE LINE!)
|
|
const pageLog = window.LogConfig.loggers.[pageName];
|
|
// OR create custom logger if not pre-configured:
|
|
// const pageLog = window.LogConfig.createLogger('[PAGE-NAME]', window.LogConfig.logLevel);
|
|
|
|
/**
|
|
* Main Alpine.js component for [page name]
|
|
*/
|
|
function admin[PageName]() {
|
|
return {
|
|
// ─────────────────────────────────────────────────────
|
|
// ✅ CRITICAL: INHERIT BASE LAYOUT
|
|
// ─────────────────────────────────────────────────────
|
|
...data(),
|
|
|
|
// ✅ CRITICAL: SET PAGE IDENTIFIER
|
|
currentPage: '[page-name]',
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// STATE
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
// Loading states
|
|
loading: false,
|
|
saving: false,
|
|
|
|
// Error handling
|
|
error: null,
|
|
errors: {},
|
|
|
|
// Data
|
|
items: [],
|
|
currentItem: null,
|
|
|
|
// Pagination
|
|
pagination: {
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
perPage: 10,
|
|
total: 0,
|
|
from: 0,
|
|
to: 0
|
|
},
|
|
|
|
// Filters
|
|
filters: {
|
|
search: '',
|
|
status: '',
|
|
sortBy: 'created_at:desc'
|
|
},
|
|
|
|
// Modal
|
|
showModal: false,
|
|
modalMode: 'create', // 'create' or 'edit'
|
|
formData: {
|
|
name: '',
|
|
description: '',
|
|
is_active: true
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// LIFECYCLE
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* ✅ CRITICAL: Initialize component with guard
|
|
*/
|
|
async init() {
|
|
pageLog.info('=== [PAGE NAME] INITIALIZING ===');
|
|
|
|
// ✅ CRITICAL: Initialization guard
|
|
if (window._[pageName]Initialized) {
|
|
pageLog.warn('Already initialized, skipping...');
|
|
return;
|
|
}
|
|
window._[pageName]Initialized = true;
|
|
|
|
// Track performance
|
|
const startTime = performance.now();
|
|
|
|
try {
|
|
// Load initial data
|
|
await this.loadData();
|
|
|
|
// Log performance
|
|
const duration = performance.now() - startTime;
|
|
window.LogConfig.logPerformance('[Page Name] Init', duration);
|
|
|
|
pageLog.info('=== [PAGE NAME] INITIALIZATION COMPLETE ===');
|
|
|
|
} catch (error) {
|
|
window.LogConfig.logError(error, '[Page Name] Init');
|
|
this.error = 'Failed to initialize page';
|
|
}
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// DATA LOADING
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Load main data from API
|
|
*/
|
|
async loadData() {
|
|
pageLog.info('Loading data...');
|
|
this.loading = true;
|
|
this.error = null;
|
|
|
|
try {
|
|
const startTime = performance.now();
|
|
|
|
// Build query params
|
|
const params = new URLSearchParams({
|
|
page: this.pagination.currentPage,
|
|
per_page: this.pagination.perPage,
|
|
search: this.filters.search,
|
|
status: this.filters.status,
|
|
sort_by: this.filters.sortBy
|
|
});
|
|
|
|
const url = `/api/v1/admin/items?${params}`;
|
|
|
|
// Log API request
|
|
window.LogConfig.logApiCall('GET', url, null, 'request');
|
|
|
|
// ✅ CRITICAL: Use lowercase apiClient
|
|
const response = await apiClient.get(url);
|
|
|
|
// Log API response
|
|
window.LogConfig.logApiCall('GET', url, response, 'response');
|
|
|
|
// Update state
|
|
this.items = response.items || [];
|
|
this.pagination.total = response.total || 0;
|
|
this.pagination.totalPages = Math.ceil(
|
|
this.pagination.total / this.pagination.perPage
|
|
);
|
|
this.pagination.from = (this.pagination.currentPage - 1) * this.pagination.perPage + 1;
|
|
this.pagination.to = Math.min(
|
|
this.pagination.currentPage * this.pagination.perPage,
|
|
this.pagination.total
|
|
);
|
|
|
|
// Log performance
|
|
const duration = performance.now() - startTime;
|
|
window.LogConfig.logPerformance('Load Data', duration);
|
|
|
|
pageLog.info(`Data loaded successfully`, {
|
|
count: this.items.length,
|
|
duration: `${duration}ms`,
|
|
page: this.pagination.currentPage
|
|
});
|
|
|
|
} catch (error) {
|
|
window.LogConfig.logError(error, 'Load Data');
|
|
this.error = error.message || 'Failed to load data';
|
|
Utils.showToast('Failed to load data', 'error');
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Refresh data
|
|
*/
|
|
async refresh() {
|
|
pageLog.info('Refreshing data...');
|
|
this.error = null;
|
|
await this.loadData();
|
|
Utils.showToast('Data refreshed', 'success');
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// FILTERS & SEARCH
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Apply filters and reload data
|
|
*/
|
|
async applyFilters() {
|
|
pageLog.debug('Applying filters:', this.filters);
|
|
this.pagination.currentPage = 1; // Reset to first page
|
|
await this.loadData();
|
|
},
|
|
|
|
/**
|
|
* Reset filters to default
|
|
*/
|
|
async resetFilters() {
|
|
this.filters = {
|
|
search: '',
|
|
status: '',
|
|
sortBy: 'created_at:desc'
|
|
};
|
|
await this.applyFilters();
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// PAGINATION
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Navigate to specific page
|
|
*/
|
|
async goToPage(page) {
|
|
if (page < 1 || page > this.pagination.totalPages) return;
|
|
this.pagination.currentPage = page;
|
|
await this.loadData();
|
|
},
|
|
|
|
/**
|
|
* Go to previous page
|
|
*/
|
|
async previousPage() {
|
|
await this.goToPage(this.pagination.currentPage - 1);
|
|
},
|
|
|
|
/**
|
|
* Go to next page
|
|
*/
|
|
async nextPage() {
|
|
await this.goToPage(this.pagination.currentPage + 1);
|
|
},
|
|
|
|
/**
|
|
* Get pagination range for display
|
|
*/
|
|
get paginationRange() {
|
|
const current = this.pagination.currentPage;
|
|
const total = this.pagination.totalPages;
|
|
const range = [];
|
|
|
|
// Show max 5 page numbers
|
|
let start = Math.max(1, current - 2);
|
|
let end = Math.min(total, start + 4);
|
|
|
|
// Adjust start if we're near the end
|
|
if (end - start < 4) {
|
|
start = Math.max(1, end - 4);
|
|
}
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
range.push(i);
|
|
}
|
|
|
|
return range;
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// CRUD OPERATIONS
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Open create modal
|
|
*/
|
|
openCreateModal() {
|
|
pageLog.info('Opening create modal');
|
|
this.modalMode = 'create';
|
|
this.formData = {
|
|
name: '',
|
|
description: '',
|
|
is_active: true
|
|
};
|
|
this.errors = {};
|
|
this.showModal = true;
|
|
},
|
|
|
|
/**
|
|
* Open edit modal
|
|
*/
|
|
async editItem(itemId) {
|
|
pageLog.info('Opening edit modal for item:', itemId);
|
|
|
|
try {
|
|
this.modalMode = 'edit';
|
|
|
|
// Load item details
|
|
const url = `/api/v1/admin/items/${itemId}`;
|
|
window.LogConfig.logApiCall('GET', url, null, 'request');
|
|
|
|
const item = await apiClient.get(url);
|
|
|
|
window.LogConfig.logApiCall('GET', url, item, 'response');
|
|
|
|
// Populate form
|
|
this.formData = { ...item };
|
|
this.currentItem = item;
|
|
this.errors = {};
|
|
this.showModal = true;
|
|
|
|
} catch (error) {
|
|
window.LogConfig.logError(error, 'Edit Item');
|
|
Utils.showToast('Failed to load item', 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* View item details
|
|
*/
|
|
async viewItem(itemId) {
|
|
pageLog.info('Viewing item:', itemId);
|
|
// Navigate to detail page or open view modal
|
|
window.location.href = `/admin/items/${itemId}`;
|
|
},
|
|
|
|
/**
|
|
* Save item (create or update)
|
|
*/
|
|
async saveItem() {
|
|
pageLog.info('Saving item...');
|
|
|
|
// Validate form
|
|
if (!this.validateForm()) {
|
|
pageLog.warn('Form validation failed');
|
|
return;
|
|
}
|
|
|
|
this.saving = true;
|
|
this.errors = {};
|
|
|
|
try {
|
|
let response;
|
|
|
|
if (this.modalMode === 'create') {
|
|
// Create new item
|
|
const url = '/api/v1/admin/items';
|
|
window.LogConfig.logApiCall('POST', url, this.formData, 'request');
|
|
|
|
response = await apiClient.post(url, this.formData);
|
|
|
|
window.LogConfig.logApiCall('POST', url, response, 'response');
|
|
|
|
pageLog.info('Item created successfully');
|
|
Utils.showToast('Item created successfully', 'success');
|
|
|
|
} else {
|
|
// Update existing item
|
|
const url = `/api/v1/admin/items/${this.currentItem.id}`;
|
|
window.LogConfig.logApiCall('PUT', url, this.formData, 'request');
|
|
|
|
response = await apiClient.put(url, this.formData);
|
|
|
|
window.LogConfig.logApiCall('PUT', url, response, 'response');
|
|
|
|
pageLog.info('Item updated successfully');
|
|
Utils.showToast('Item updated successfully', 'success');
|
|
}
|
|
|
|
// Close modal and reload data
|
|
this.closeModal();
|
|
await this.loadData();
|
|
|
|
} catch (error) {
|
|
window.LogConfig.logError(error, 'Save Item');
|
|
|
|
// Handle validation errors
|
|
if (error.details && error.details.validation_errors) {
|
|
this.errors = error.details.validation_errors;
|
|
} else {
|
|
Utils.showToast('Failed to save item', 'error');
|
|
}
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Delete item
|
|
*/
|
|
async deleteItem(itemId) {
|
|
pageLog.info('Deleting item:', itemId);
|
|
|
|
// Confirm deletion
|
|
if (!confirm('Are you sure you want to delete this item?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const url = `/api/v1/admin/items/${itemId}`;
|
|
window.LogConfig.logApiCall('DELETE', url, null, 'request');
|
|
|
|
await apiClient.delete(url);
|
|
|
|
window.LogConfig.logApiCall('DELETE', url, null, 'response');
|
|
|
|
pageLog.info('Item deleted successfully');
|
|
Utils.showToast('Item deleted successfully', 'success');
|
|
|
|
// Reload data
|
|
await this.loadData();
|
|
|
|
} catch (error) {
|
|
window.LogConfig.logError(error, 'Delete Item');
|
|
Utils.showToast('Failed to delete item', 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Close modal
|
|
*/
|
|
closeModal() {
|
|
this.showModal = false;
|
|
this.formData = {};
|
|
this.errors = {};
|
|
this.currentItem = null;
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// VALIDATION
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Validate form data
|
|
*/
|
|
validateForm() {
|
|
this.errors = {};
|
|
|
|
if (!this.formData.name || this.formData.name.trim() === '') {
|
|
this.errors.name = 'Name is required';
|
|
}
|
|
|
|
// Add more validation rules as needed
|
|
|
|
return Object.keys(this.errors).length === 0;
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// HELPERS
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Format date for display
|
|
*/
|
|
formatDate(dateString) {
|
|
if (!dateString) return '-';
|
|
return Utils.formatDate(dateString);
|
|
},
|
|
|
|
/**
|
|
* Truncate text
|
|
*/
|
|
truncate(text, length = 50) {
|
|
if (!text) return '';
|
|
if (text.length <= length) return text;
|
|
return text.substring(0, length) + '...';
|
|
}
|
|
};
|
|
}
|
|
|
|
// Make available globally
|
|
window.admin[PageName] = admin[PageName];
|
|
|
|
pageLog.info('[Page Name] module loaded');
|
|
```
|
|
|
|
---
|
|
|
|
### 3. Route Registration
|
|
|
|
**File:** `app/api/v1/admin/pages.py`
|
|
|
|
```python
|
|
from fastapi import APIRouter, Request, Depends
|
|
from app.api.deps import get_current_admin_from_cookie_or_header
|
|
from app.models.database.user import User
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/admin/[page-route]")
|
|
async def [page_name]_page(
|
|
request: Request,
|
|
current_user: User = Depends(get_current_admin_from_cookie_or_header)
|
|
):
|
|
"""
|
|
[Page Name] page
|
|
Displays [description]
|
|
|
|
Requires admin authentication.
|
|
"""
|
|
return templates.TemplateResponse(
|
|
"admin/[page-name].html",
|
|
{
|
|
"request": request,
|
|
"user": current_user,
|
|
}
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
### 4. Sidebar Navigation
|
|
|
|
**File:** `app/templates/admin/partials/sidebar.html`
|
|
|
|
```jinja2
|
|
<li class="relative px-6 py-3">
|
|
<span x-show="currentPage === '[page-name]'"
|
|
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 === '[page-name]' ? 'text-gray-800 dark:text-gray-100' : ''"
|
|
href="/admin/[page-route]">
|
|
<span x-html="$icon('[icon-name]', 'w-5 h-5')"></span>
|
|
<span class="ml-4">[Page Display Name]</span>
|
|
</a>
|
|
</li>
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 Common Page Patterns
|
|
|
|
### Pattern 1: Simple Data List (GET)
|
|
|
|
**Use for:** Store list, user list, product list
|
|
|
|
```javascript
|
|
async init() {
|
|
if (window._myPageInitialized) return;
|
|
window._myPageInitialized = true;
|
|
await this.loadData();
|
|
}
|
|
|
|
async loadData() {
|
|
this.loading = true;
|
|
try {
|
|
const response = await apiClient.get('/api/v1/admin/items');
|
|
this.items = response.items || [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Pattern 2: Dashboard with Stats
|
|
|
|
**Use for:** Dashboard, analytics pages
|
|
|
|
```javascript
|
|
async init() {
|
|
if (window._dashboardInitialized) return;
|
|
window._dashboardInitialized = true;
|
|
|
|
await Promise.all([
|
|
this.loadStats(),
|
|
this.loadRecentActivity()
|
|
]);
|
|
}
|
|
|
|
async loadStats() {
|
|
this.stats = await apiClient.get('/api/v1/admin/stats');
|
|
}
|
|
|
|
async loadRecentActivity() {
|
|
this.activity = await apiClient.get('/api/v1/admin/recent-activity');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Pattern 3: Single Item Detail/Edit
|
|
|
|
**Use for:** Store edit, user edit
|
|
|
|
```javascript
|
|
async init() {
|
|
if (window._editPageInitialized) return;
|
|
window._editPageInitialized = true;
|
|
|
|
const itemId = this.getItemIdFromUrl();
|
|
await this.loadItem(itemId);
|
|
}
|
|
|
|
async loadItem(id) {
|
|
this.item = await apiClient.get(`/api/v1/admin/items/${id}`);
|
|
// Populate form with item data
|
|
this.formData = { ...this.item };
|
|
}
|
|
|
|
async saveItem() {
|
|
if (!this.validateForm()) return;
|
|
|
|
await apiClient.put(
|
|
`/api/v1/admin/items/${this.item.id}`,
|
|
this.formData
|
|
);
|
|
|
|
Utils.showToast('Saved successfully', 'success');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Pattern 4: With Filters and Pagination
|
|
|
|
**Use for:** Large lists with filtering
|
|
|
|
```javascript
|
|
filters: {
|
|
search: '',
|
|
status: '',
|
|
sortBy: 'created_at:desc'
|
|
},
|
|
|
|
pagination: {
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
perPage: 10
|
|
},
|
|
|
|
async loadData() {
|
|
const params = new URLSearchParams({
|
|
page: this.pagination.currentPage,
|
|
per_page: this.pagination.perPage,
|
|
...this.filters
|
|
});
|
|
|
|
const response = await apiClient.get(
|
|
`/api/v1/admin/items?${params}`
|
|
);
|
|
|
|
this.items = response.items;
|
|
this.pagination.total = response.total;
|
|
this.pagination.totalPages = Math.ceil(
|
|
response.total / this.pagination.perPage
|
|
);
|
|
}
|
|
|
|
async applyFilters() {
|
|
this.pagination.currentPage = 1; // Reset to page 1
|
|
await this.loadData();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 Best Practices
|
|
|
|
### 1. Base Layout Inheritance
|
|
|
|
**✅ ALWAYS include ...data():**
|
|
```javascript
|
|
function adminMyPage() {
|
|
return {
|
|
...data(), // ← Must be first!
|
|
currentPage: 'my-page',
|
|
// Your state here
|
|
};
|
|
}
|
|
```
|
|
|
|
### 2. Initialization Guards
|
|
|
|
**✅ ALWAYS use initialization guard:**
|
|
```javascript
|
|
async init() {
|
|
if (window._myPageInitialized) {
|
|
log.warn('Already initialized');
|
|
return;
|
|
}
|
|
window._myPageInitialized = true;
|
|
|
|
await this.loadData();
|
|
}
|
|
```
|
|
|
|
### 3. API Client Usage
|
|
|
|
**✅ Use lowercase apiClient:**
|
|
```javascript
|
|
// ✅ CORRECT
|
|
await apiClient.get('/endpoint');
|
|
|
|
// ❌ WRONG
|
|
await ApiClient.get('/endpoint');
|
|
await API_CLIENT.get('/endpoint');
|
|
```
|
|
|
|
### 4. Centralized Logging
|
|
|
|
**✅ Use pre-configured logger:**
|
|
```javascript
|
|
// ✅ CORRECT - One line!
|
|
const myLog = window.LogConfig.loggers.myPage;
|
|
|
|
// ❌ WRONG - 15+ lines
|
|
const myLog = {
|
|
error: (...args) => console.error(...),
|
|
// ... etc
|
|
};
|
|
```
|
|
|
|
### 5. Error Handling
|
|
|
|
**✅ Handle errors gracefully:**
|
|
```javascript
|
|
try {
|
|
await this.loadData();
|
|
} catch (error) {
|
|
window.LogConfig.logError(error, 'Load Data');
|
|
this.error = error.message;
|
|
Utils.showToast('Failed to load data', 'error');
|
|
// Don't throw - let UI handle it
|
|
}
|
|
```
|
|
|
|
### 6. Loading States
|
|
|
|
**✅ Always set loading state:**
|
|
```javascript
|
|
this.loading = true;
|
|
try {
|
|
// ... operations
|
|
} finally {
|
|
this.loading = false; // Always executes
|
|
}
|
|
```
|
|
|
|
### 7. Performance Tracking
|
|
|
|
**✅ Track performance for slow operations:**
|
|
```javascript
|
|
const startTime = performance.now();
|
|
await this.loadData();
|
|
const duration = performance.now() - startTime;
|
|
window.LogConfig.logPerformance('Load Data', duration);
|
|
```
|
|
|
|
---
|
|
|
|
## 📱 Responsive Design Checklist
|
|
|
|
- [ ] Table scrolls horizontally on mobile
|
|
- [ ] Modal is scrollable on small screens
|
|
- [ ] Filters stack vertically on mobile
|
|
- [ ] Action buttons adapt to screen size
|
|
- [ ] Text truncates appropriately
|
|
- [ ] Icons remain visible
|
|
- [ ] Sidebar collapses on mobile
|
|
- [ ] Stats cards stack on mobile
|
|
|
|
---
|
|
|
|
## ✅ Testing Checklist
|
|
|
|
### Functionality
|
|
- [ ] Page loads without errors
|
|
- [ ] Data loads correctly
|
|
- [ ] Loading state displays
|
|
- [ ] Error state handles failures
|
|
- [ ] Empty state shows when no data
|
|
- [ ] Filters work correctly
|
|
- [ ] Pagination works
|
|
- [ ] Create operation works
|
|
- [ ] Edit operation works
|
|
- [ ] Delete operation works
|
|
- [ ] Modal opens/closes
|
|
- [ ] Form validation works
|
|
|
|
### Architecture
|
|
- [ ] Initialization guard works (no duplicate init)
|
|
- [ ] ...data() properly inherited
|
|
- [ ] currentPage set correctly
|
|
- [ ] Lowercase apiClient used
|
|
- [ ] Centralized logging used
|
|
- [ ] No console errors
|
|
- [ ] Dark mode works
|
|
- [ ] Mobile responsive
|
|
|
|
---
|
|
|
|
## 🚀 Quick Start Commands
|
|
|
|
```bash
|
|
# Create new page files
|
|
touch app/templates/admin/new-page.html
|
|
touch app/static/admin/js/new-page.js
|
|
|
|
# Copy templates (start from dashboard.js as base)
|
|
cp app/static/admin/js/dashboard.js app/static/admin/js/new-page.js
|
|
|
|
# Update files:
|
|
# 1. Change logger: dashLog → newPageLog
|
|
# 2. Change function: adminDashboard() → adminNewPage()
|
|
# 3. Change init flag: _dashboardInitialized → _newPageInitialized
|
|
# 4. Change currentPage: 'dashboard' → 'new-page'
|
|
# 5. Replace data loading logic
|
|
# 6. Update HTML template
|
|
# 7. Add route in pages.py
|
|
# 8. Add sidebar link
|
|
# 9. Test!
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 Additional Resources
|
|
|
|
### Pre-configured Loggers
|
|
```javascript
|
|
window.LogConfig.loggers.dashboard
|
|
window.LogConfig.loggers.merchants
|
|
window.LogConfig.loggers.stores
|
|
window.LogConfig.loggers.storeTheme
|
|
window.LogConfig.loggers.users
|
|
window.LogConfig.loggers.customers
|
|
window.LogConfig.loggers.products
|
|
window.LogConfig.loggers.orders
|
|
window.LogConfig.loggers.imports
|
|
window.LogConfig.loggers.audit
|
|
```
|
|
|
|
### From Base Data (via ...data())
|
|
- `this.dark` - Dark mode state
|
|
- `this.toggleTheme()` - Toggle theme
|
|
- `this.isSideMenuOpen` - Side menu state
|
|
- `this.toggleSideMenu()` - Toggle side menu
|
|
- `this.isProfileMenuOpen` - Profile menu state
|
|
- `this.toggleProfileMenu()` - Toggle profile menu
|
|
|
|
### Global Utilities
|
|
- `Utils.showToast(message, type, duration)`
|
|
- `Utils.formatDate(dateString)`
|
|
- `apiClient.get(url)`
|
|
- `apiClient.post(url, data)`
|
|
- `apiClient.put(url, data)`
|
|
- `apiClient.delete(url)`
|
|
|
|
### Icon Helper
|
|
```html
|
|
<span x-html="$icon('name', 'classes')"></span>
|
|
```
|
|
|
|
---
|
|
|
|
## ⚠️ Common Mistakes to Avoid
|
|
|
|
### 1. Missing ...data()
|
|
```javascript
|
|
// ❌ WRONG - No base inheritance
|
|
function myPage() {
|
|
return {
|
|
items: []
|
|
};
|
|
}
|
|
|
|
// ✅ CORRECT
|
|
function myPage() {
|
|
return {
|
|
...data(), // Must be first!
|
|
items: []
|
|
};
|
|
}
|
|
```
|
|
|
|
### 2. Wrong API Client Case
|
|
```javascript
|
|
// ❌ WRONG
|
|
await ApiClient.get('/url');
|
|
|
|
// ✅ CORRECT
|
|
await apiClient.get('/url');
|
|
```
|
|
|
|
### 3. Missing Init Guard
|
|
```javascript
|
|
// ❌ WRONG
|
|
async init() {
|
|
await this.loadData();
|
|
}
|
|
|
|
// ✅ CORRECT
|
|
async init() {
|
|
if (window._myPageInitialized) return;
|
|
window._myPageInitialized = true;
|
|
await this.loadData();
|
|
}
|
|
```
|
|
|
|
### 4. Old Logging Pattern
|
|
```javascript
|
|
// ❌ WRONG - 15+ lines
|
|
const log = {
|
|
error: (...args) => console.error(...),
|
|
// ...
|
|
};
|
|
|
|
// ✅ CORRECT - 1 line
|
|
const log = window.LogConfig.loggers.myPage;
|
|
```
|
|
|
|
### 5. Missing currentPage
|
|
```javascript
|
|
// ❌ WRONG
|
|
return {
|
|
...data(),
|
|
items: []
|
|
};
|
|
|
|
// ✅ CORRECT
|
|
return {
|
|
...data(),
|
|
currentPage: 'my-page', // Must set!
|
|
items: []
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
This template provides a complete, production-ready pattern for building admin pages with consistent structure, proper initialization, comprehensive logging, and excellent maintainability.
|
|
|
|
---
|
|
|
|
## 🎯 Real-World Examples: Marketplace Import Pages
|
|
|
|
The marketplace import system provides two comprehensive real-world implementations demonstrating all best practices.
|
|
|
|
### 1. Self-Service Import (`/admin/marketplace`)
|
|
|
|
**Purpose**: Admin tool for triggering imports for any store
|
|
|
|
**Files**:
|
|
- **Template**: `app/templates/admin/marketplace.html`
|
|
- **JavaScript**: `static/admin/js/marketplace.js`
|
|
- **Route**: `app/routes/admin_pages.py` - `admin_marketplace_page()`
|
|
|
|
#### Key Features
|
|
|
|
##### Store Selection with Auto-Load
|
|
```javascript
|
|
// Load all stores
|
|
async loadStores() {
|
|
const response = await apiClient.get('/admin/stores?limit=1000');
|
|
this.stores = response.items || [];
|
|
}
|
|
|
|
// Handle store selection change
|
|
onStoreChange() {
|
|
const storeId = parseInt(this.importForm.store_id);
|
|
this.selectedStore = this.stores.find(v => v.id === storeId) || null;
|
|
}
|
|
|
|
// Quick fill from selected store's settings
|
|
quickFill(language) {
|
|
if (!this.selectedStore) return;
|
|
|
|
const urlMap = {
|
|
'fr': this.selectedStore.letzshop_csv_url_fr,
|
|
'en': this.selectedStore.letzshop_csv_url_en,
|
|
'de': this.selectedStore.letzshop_csv_url_de
|
|
};
|
|
|
|
if (urlMap[language]) {
|
|
this.importForm.csv_url = urlMap[language];
|
|
this.importForm.language = language;
|
|
}
|
|
}
|
|
```
|
|
|
|
##### Filter by Current User
|
|
```javascript
|
|
async loadJobs() {
|
|
const params = new URLSearchParams({
|
|
page: this.page,
|
|
limit: this.limit,
|
|
created_by_me: 'true' // Only show jobs I triggered
|
|
});
|
|
|
|
const response = await apiClient.get(
|
|
`/admin/marketplace-import-jobs?${params.toString()}`
|
|
);
|
|
|
|
this.jobs = response.items || [];
|
|
}
|
|
```
|
|
|
|
##### Store Name Helper
|
|
```javascript
|
|
getStoreName(storeId) {
|
|
const store = this.stores.find(v => v.id === storeId);
|
|
return store ? `${store.name} (${store.store_code})` : `Store #${storeId}`;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Platform Monitoring (`/admin/imports`)
|
|
|
|
**Purpose**: System-wide oversight of all import jobs
|
|
|
|
**Files**:
|
|
- **Template**: `app/templates/admin/imports.html`
|
|
- **JavaScript**: `static/admin/js/imports.js`
|
|
- **Route**: `app/routes/admin_pages.py` - `admin_imports_page()`
|
|
|
|
#### Key Features
|
|
|
|
##### Statistics Dashboard
|
|
```javascript
|
|
async loadStats() {
|
|
const response = await apiClient.get('/admin/marketplace-import-jobs/stats');
|
|
this.stats = {
|
|
total: response.total || 0,
|
|
active: (response.pending || 0) + (response.processing || 0),
|
|
completed: response.completed || 0,
|
|
failed: response.failed || 0
|
|
};
|
|
}
|
|
```
|
|
|
|
**Template**:
|
|
```html
|
|
<!-- Stats Cards -->
|
|
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
|
<!-- Total Jobs -->
|
|
<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">
|
|
<span x-html="$icon('cube', 'w-5 h-5')"></span>
|
|
</div>
|
|
<div>
|
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
|
Total Jobs
|
|
</p>
|
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">
|
|
0
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<!-- Repeat for active, completed, failed -->
|
|
</div>
|
|
```
|
|
|
|
##### Advanced Filtering
|
|
```javascript
|
|
filters: {
|
|
store_id: '',
|
|
status: '',
|
|
marketplace: '',
|
|
created_by: '' // 'me' or empty for all
|
|
},
|
|
|
|
async applyFilters() {
|
|
this.page = 1; // Reset to first page
|
|
|
|
const params = new URLSearchParams({
|
|
page: this.page,
|
|
limit: this.limit
|
|
});
|
|
|
|
// Add filters
|
|
if (this.filters.store_id) {
|
|
params.append('store_id', this.filters.store_id);
|
|
}
|
|
if (this.filters.status) {
|
|
params.append('status', this.filters.status);
|
|
}
|
|
if (this.filters.created_by === 'me') {
|
|
params.append('created_by_me', 'true');
|
|
}
|
|
|
|
await this.loadJobs();
|
|
await this.loadStats(); // Update stats based on filters
|
|
}
|
|
```
|
|
|
|
**Template**:
|
|
```html
|
|
<div class="grid gap-4 md:grid-cols-5">
|
|
<!-- Store Filter -->
|
|
<select x-model="filters.store_id" @change="applyFilters()">
|
|
<option value="">All Stores</option>
|
|
<template x-for="store in stores" :key="store.id">
|
|
<option :value="store.id"
|
|
x-text="`${store.name} (${store.store_code})`">
|
|
</option>
|
|
</template>
|
|
</select>
|
|
|
|
<!-- Status Filter -->
|
|
<select x-model="filters.status" @change="applyFilters()">
|
|
<option value="">All Statuses</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="processing">Processing</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="failed">Failed</option>
|
|
</select>
|
|
|
|
<!-- Creator Filter -->
|
|
<select x-model="filters.created_by" @change="applyFilters()">
|
|
<option value="">All Users</option>
|
|
<option value="me">My Jobs Only</option>
|
|
</select>
|
|
</div>
|
|
```
|
|
|
|
##### Enhanced Job Table
|
|
```html
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Job ID</th>
|
|
<th>Store</th>
|
|
<th>Status</th>
|
|
<th>Progress</th>
|
|
<th>Created By</th> <!-- Extra column for platform monitoring -->
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="job in jobs" :key="job.id">
|
|
<tr>
|
|
<td>#<span x-text="job.id"></span></td>
|
|
<td><span x-text="getStoreName(job.store_id)"></span></td>
|
|
<td><!-- Status badge --></td>
|
|
<td><!-- Progress metrics --></td>
|
|
<td><span x-text="job.created_by_name || 'System'"></span></td>
|
|
<td><!-- Action buttons --></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
```
|
|
|
|
---
|
|
|
|
## 🔄 Comparison: Two Admin Interfaces
|
|
|
|
| Feature | Self-Service (`/marketplace`) | Platform Monitoring (`/imports`) |
|
|
|---------|-------------------------------|----------------------------------|
|
|
| **Purpose** | Import products for stores | Monitor all system imports |
|
|
| **Scope** | Personal (my jobs) | System-wide (all jobs) |
|
|
| **Primary Action** | Trigger new imports | View and analyze |
|
|
| **Jobs Shown** | Only jobs I triggered | All jobs (with filtering) |
|
|
| **Store Selection** | Required (select store to import for) | Optional (filter view) |
|
|
| **Statistics** | No | Yes (dashboard cards) |
|
|
| **Auto-Refresh** | 10 seconds | 15 seconds |
|
|
| **Filter Options** | Store, Status, Marketplace | Store, Status, Marketplace, Creator |
|
|
| **Use Case** | "I need to import for Store X" | "What's happening system-wide?" |
|
|
|
|
---
|
|
|
|
## 📋 Navigation Structure
|
|
|
|
### Sidebar Organization
|
|
|
|
```javascript
|
|
// Admin sidebar sections
|
|
{
|
|
"Dashboard": [
|
|
"Dashboard"
|
|
],
|
|
"Platform Administration": [
|
|
"Merchants",
|
|
"Stores",
|
|
"Users",
|
|
"Customers",
|
|
"Marketplace"
|
|
],
|
|
"Content Management": [
|
|
"Platform Homepage",
|
|
"Content Pages",
|
|
"Store Themes"
|
|
],
|
|
"Developer Tools": [
|
|
"Components",
|
|
"Icons",
|
|
"Testing Hub",
|
|
"Code Quality"
|
|
],
|
|
"Platform Monitoring": [
|
|
"Import Jobs",
|
|
"Application Logs"
|
|
],
|
|
"Settings": [
|
|
"Settings"
|
|
]
|
|
}
|
|
```
|
|
|
|
### Setting currentPage
|
|
|
|
```javascript
|
|
// marketplace.js
|
|
return {
|
|
...data(),
|
|
currentPage: 'marketplace', // Highlights "Marketplace" in sidebar
|
|
// ...
|
|
};
|
|
|
|
// imports.js
|
|
return {
|
|
...data(),
|
|
currentPage: 'imports', // Highlights "Import Jobs" in sidebar
|
|
// ...
|
|
};
|
|
```
|
|
|
|
### Collapsible Sections
|
|
|
|
Sidebar sections are collapsible with state persisted to localStorage:
|
|
|
|
```javascript
|
|
// Section keys used in openSections state
|
|
{
|
|
platformAdmin: true, // Platform Administration (default open)
|
|
contentMgmt: false, // Content Management
|
|
devTools: false, // Developer Tools
|
|
monitoring: false // Platform Monitoring
|
|
}
|
|
|
|
// Toggle a section
|
|
toggleSection('devTools');
|
|
|
|
// Check if section is open
|
|
if (openSections.devTools) { ... }
|
|
```
|
|
|
|
See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
|
|
|
|
---
|
|
|
|
## 🎨 UI Patterns
|
|
|
|
### Success/Error Messages
|
|
|
|
```html
|
|
<!-- Success -->
|
|
<div x-show="successMessage" x-transition
|
|
class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg">
|
|
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3')"></span>
|
|
<p class="font-semibold" x-text="successMessage"></p>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div x-show="error" x-transition
|
|
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg dark:bg-red-900/20">
|
|
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3')"></span>
|
|
<div>
|
|
<p class="font-semibold">Error</p>
|
|
<p class="text-sm" x-text="error"></p>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### Empty States
|
|
|
|
```html
|
|
<!-- Personalized empty state -->
|
|
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
|
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
|
<p class="text-gray-600 dark:text-gray-400">
|
|
You haven't triggered any imports yet
|
|
</p>
|
|
<p class="text-sm text-gray-500">
|
|
Start a new import using the form above
|
|
</p>
|
|
</div>
|
|
```
|
|
|
|
### Loading States with Spinners
|
|
|
|
```html
|
|
<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 import jobs...</p>
|
|
</div>
|
|
```
|
|
|
|
### Modal Dialogs
|
|
|
|
```html
|
|
<div x-show="showJobModal" x-cloak @click.away="closeJobModal()"
|
|
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
|
x-transition>
|
|
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl">
|
|
<!-- Modal Header -->
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold">Import Job Details</h3>
|
|
<button @click="closeJobModal()">
|
|
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Modal Content -->
|
|
<div x-show="selectedJob">
|
|
<!-- Job details grid -->
|
|
</div>
|
|
|
|
<!-- Modal Footer -->
|
|
<div class="flex justify-end mt-6">
|
|
<button @click="closeJobModal()" class="...">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 Related Documentation
|
|
|
|
- [Marketplace Integration Guide](../../guides/marketplace-integration.md) - Complete marketplace system documentation
|
|
- [Store Page Templates](../store/page-templates.md) - Store page patterns
|
|
- [Icons Guide](../../development/icons-guide.md) - Available icons
|
|
- [Admin Integration Guide](../../backend/admin-integration-guide.md) - Backend integration
|