feat: add PlatformSettings for pagination and vendor filter improvements
Platform Settings: - Add PlatformSettings utility in init-alpine.js with 5-min cache - Add Display tab in /admin/settings for rows_per_page config - Integrate PlatformSettings.getRowsPerPage() in all paginated pages - Standardize default per_page to 20 across all admin pages - Add documentation at docs/frontend/shared/platform-settings.md Architecture Rules: - Add JS-010: enforce PlatformSettings usage for pagination - Add JS-011: enforce standard pagination structure - Add JS-012: detect double /api/v1 prefix in apiClient calls - Implement all rules in validate_architecture.py Vendor Filter (Tom Select): - Add vendor filter to marketplace-products, vendor-products, customers, inventory, and vendor-themes pages - Add selectedVendor display panel with clear button - Add localStorage persistence for vendor selection - Fix double /api/v1 prefix in vendor-selector.js Bug Fixes: - Remove duplicate PlatformSettings from utils.js - Fix customers.js pagination structure (page_size → per_page) - Fix code-quality-violations.js pagination structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -145,6 +145,143 @@ javascript_rules:
|
||||
exceptions:
|
||||
- "utils.js"
|
||||
|
||||
- id: "JS-010"
|
||||
name: "Use PlatformSettings for pagination rows per page"
|
||||
severity: "error"
|
||||
description: |
|
||||
All pages with tables MUST use window.PlatformSettings.getRowsPerPage()
|
||||
to load the platform-configured rows per page setting. This ensures
|
||||
consistent pagination behavior across the entire admin and vendor interface.
|
||||
|
||||
The setting is configured at /admin/settings under the Display tab.
|
||||
Settings are cached client-side for 5 minutes to minimize API calls.
|
||||
|
||||
Required pattern in init() method:
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._pageNameInitialized) return;
|
||||
window._pageNameInitialized = true;
|
||||
|
||||
// REQUIRED: Load platform settings for pagination
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
WRONG (hardcoded pagination):
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 50, // Hardcoded!
|
||||
total: 0
|
||||
}
|
||||
|
||||
RIGHT (platform settings):
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 20, // Default, overridden by PlatformSettings
|
||||
total: 0
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
}
|
||||
|
||||
Documentation: docs/frontend/shared/platform-settings.md
|
||||
pattern:
|
||||
file_pattern: "static/admin/js/**/*.js"
|
||||
required_in_pages_with_pagination:
|
||||
- "PlatformSettings\\.getRowsPerPage"
|
||||
- "window\\.PlatformSettings"
|
||||
exceptions:
|
||||
- "init-alpine.js"
|
||||
- "init-api-client.js"
|
||||
- "settings.js"
|
||||
|
||||
- id: "JS-011"
|
||||
name: "Use standard pagination object structure"
|
||||
severity: "error"
|
||||
description: |
|
||||
All pages with tables MUST use the standard nested pagination object
|
||||
structure. This ensures compatibility with the pagination macro and
|
||||
consistent behavior across all pages.
|
||||
|
||||
REQUIRED structure:
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
}
|
||||
|
||||
WRONG (flat structure):
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
skip: 0
|
||||
|
||||
WRONG (different property names):
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 20
|
||||
}
|
||||
|
||||
Required computed properties:
|
||||
- totalPages
|
||||
- startIndex
|
||||
- endIndex
|
||||
- pageNumbers
|
||||
|
||||
Required methods:
|
||||
- previousPage()
|
||||
- nextPage()
|
||||
- goToPage(pageNum)
|
||||
|
||||
Documentation: docs/frontend/shared/pagination.md
|
||||
pattern:
|
||||
file_pattern: "static/**/js/**/*.js"
|
||||
required_in_pages_with_pagination:
|
||||
- "pagination:"
|
||||
- "pagination\\.page"
|
||||
- "pagination\\.per_page"
|
||||
anti_patterns_in_pagination_pages:
|
||||
- "^\\s*page:\\s*\\d"
|
||||
- "^\\s*limit:\\s*\\d"
|
||||
- "^\\s*skip:\\s*"
|
||||
exceptions:
|
||||
- "init-alpine.js"
|
||||
|
||||
- id: "JS-012"
|
||||
name: "Do not include /api/v1 prefix in API endpoints"
|
||||
severity: "error"
|
||||
description: |
|
||||
When using apiClient.get(), apiClient.post(), etc., do NOT include
|
||||
the /api/v1 prefix in the endpoint path. The apiClient automatically
|
||||
prepends this prefix.
|
||||
|
||||
CORRECT:
|
||||
apiClient.get('/admin/vendors')
|
||||
apiClient.post('/admin/products')
|
||||
const apiEndpoint = '/admin/vendors'
|
||||
|
||||
WRONG (causes double prefix /api/v1/api/v1/...):
|
||||
apiClient.get('/api/v1/admin/vendors')
|
||||
const apiEndpoint = '/api/v1/admin/vendors'
|
||||
|
||||
Exception: Direct fetch() calls without apiClient should use full path.
|
||||
|
||||
Documentation: docs/frontend/shared/api-client.md
|
||||
pattern:
|
||||
file_pattern: "static/**/js/**/*.js"
|
||||
anti_patterns:
|
||||
- "apiClient\\.(get|post|put|delete|patch)\\s*\\(\\s*['\"`]/api/v1"
|
||||
- "apiEndpoint.*=.*['\"`]/api/v1"
|
||||
exceptions:
|
||||
- "init-api-client.js"
|
||||
|
||||
# ============================================================================
|
||||
# TEMPLATE RULES (Jinja2)
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,16 +1,87 @@
|
||||
{# app/templates/admin/customers.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
{% from 'shared/macros/pagination.html' import pagination_full %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
|
||||
{% block title %}Customers{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminCustomers(){% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Tom Select CSS with local fallback -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
|
||||
/>
|
||||
<style>
|
||||
/* Tom Select dark mode overrides */
|
||||
.dark .ts-wrapper .ts-control {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input::placeholder {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(147 51 234);
|
||||
color: white;
|
||||
}
|
||||
.dark .ts-dropdown .option:hover {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
.dark .ts-wrapper.focus .ts-control {
|
||||
border-color: rgb(147 51 234);
|
||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Customer Management') }}
|
||||
<!-- Page Header with Vendor Selector -->
|
||||
{% call page_header_flex(title='Customer Management', subtitle='Manage customers across all vendors') %}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Vendor Autocomplete (Tom Select) -->
|
||||
<div class="w-80">
|
||||
<select id="vendor-select" x-ref="vendorSelect" placeholder="Filter by vendor...">
|
||||
</select>
|
||||
</div>
|
||||
{{ refresh_button(loading_var='loading', onclick='resetAndLoad()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Selected Vendor Info -->
|
||||
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
|
||||
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearVendorFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading customers...') }}
|
||||
|
||||
@@ -109,17 +180,6 @@
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
x-model="filters.vendor_id"
|
||||
@change="resetAndLoad()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">All Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="vendor.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,11 +295,25 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Numbered Pagination -->
|
||||
{{ pagination_full(load_fn="loadCustomers()", item_label="customers") }}
|
||||
<!-- Pagination -->
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<!-- Tom Select JS with local fallback -->
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
|
||||
script.onerror = function() {
|
||||
console.warn('Tom Select CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/vendor/tom-select.complete.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='admin/js/customers.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# app/templates/admin/inventory.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
@@ -12,7 +12,38 @@
|
||||
{% block alpine_data %}adminInventory(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Inventory', subtitle='Manage stock levels across all vendors') }}
|
||||
<!-- Page Header with Vendor Selector -->
|
||||
{% call page_header_flex(title='Inventory', subtitle='Manage stock levels across all vendors') %}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Vendor Autocomplete (Tom Select) -->
|
||||
{{ vendor_selector(
|
||||
ref_name='vendorSelect',
|
||||
id='inventory-vendor-select',
|
||||
placeholder='Filter by vendor...',
|
||||
width='w-80'
|
||||
) }}
|
||||
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Selected Vendor Info -->
|
||||
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
|
||||
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearVendorFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading inventory...') }}
|
||||
|
||||
@@ -102,14 +133,6 @@
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Vendor Filter (Tom Select) -->
|
||||
{{ vendor_selector(
|
||||
ref_name='vendorSelect',
|
||||
id='inventory-vendor-select',
|
||||
placeholder='Filter by vendor...',
|
||||
width='w-64'
|
||||
) }}
|
||||
|
||||
<!-- Location Filter -->
|
||||
<select
|
||||
x-model="filters.location"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# app/templates/admin/marketplace-products.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
@@ -10,8 +10,79 @@
|
||||
|
||||
{% block alpine_data %}adminMarketplaceProducts(){% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Tom Select CSS with local fallback -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
|
||||
/>
|
||||
<style>
|
||||
/* Tom Select dark mode overrides */
|
||||
.dark .ts-wrapper .ts-control {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input::placeholder {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(147 51 234);
|
||||
color: white;
|
||||
}
|
||||
.dark .ts-dropdown .option:hover {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
.dark .ts-wrapper.focus .ts-control {
|
||||
border-color: rgb(147 51 234);
|
||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Marketplace Products', subtitle='Master product repository - Browse all imported products from external sources') }}
|
||||
<!-- Page Header with Vendor Selector -->
|
||||
{% call page_header_flex(title='Marketplace Products', subtitle='Master product repository - Browse all imported products from external sources') %}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Vendor Autocomplete (Tom Select) -->
|
||||
<div class="w-80">
|
||||
<select id="vendor-select" x-ref="vendorSelect" placeholder="Filter by vendor...">
|
||||
</select>
|
||||
</div>
|
||||
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Selected Vendor Info -->
|
||||
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
|
||||
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearVendorFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading products...') }}
|
||||
|
||||
@@ -128,18 +199,6 @@
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<!-- Source Vendor Filter -->
|
||||
<select
|
||||
x-model="filters.vendor_name"
|
||||
@change="pagination.page = 1; loadProducts()"
|
||||
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 Source Vendors</option>
|
||||
<template x-for="v in sourceVendors" :key="v">
|
||||
<option :value="v" x-text="v"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<!-- Product Type Filter -->
|
||||
<select
|
||||
x-model="filters.is_digital"
|
||||
@@ -161,16 +220,6 @@
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="refresh()"
|
||||
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 products"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +282,7 @@
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('database', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No marketplace products found</p>
|
||||
<p class="text-xs mt-1" x-text="filters.search || filters.marketplace || filters.is_active ? 'Try adjusting your search or filters' : 'Import products from the Import page'"></p>
|
||||
<p class="text-xs mt-1" x-text="filters.search || filters.marketplace || filters.is_active || selectedVendor ? 'Try adjusting your search or filters' : 'Import products from the Import page'"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -409,5 +458,19 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<!-- Tom Select JS with local fallback -->
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
|
||||
script.onerror = function() {
|
||||
console.warn('Tom Select CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/vendor/tom-select.complete.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='admin/js/marketplace-products.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
<!-- Settings Categories Tabs -->
|
||||
{% call tabs_nav() %}
|
||||
{{ tab_button('display', 'Display', icon='view-grid') }}
|
||||
{{ tab_button('logging', 'Logging', icon='document-text') }}
|
||||
{{ tab_button('shipping', 'Shipping', icon='truck') }}
|
||||
{{ tab_button('system', 'System', icon='cog') }}
|
||||
@@ -25,6 +26,66 @@
|
||||
{{ tab_button('notifications', 'Notifications', icon='bell') }}
|
||||
{% endcall %}
|
||||
|
||||
<!-- Display Settings Tab -->
|
||||
<div x-show="activeTab === 'display'" x-transition>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
|
||||
Display Configuration
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure how data is displayed across the admin interface.
|
||||
</p>
|
||||
|
||||
<!-- Rows Per Page Setting -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Default Rows Per Page
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
Set the default number of rows shown in tables across the admin interface.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<template x-for="option in [10, 20, 50, 100]" :key="option">
|
||||
<button
|
||||
@click="displaySettings.rows_per_page = option"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
|
||||
:class="displaySettings.rows_per_page === option
|
||||
? 'bg-purple-600 text-white border-purple-600 dark:bg-purple-500 dark:border-purple-500'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
|
||||
x-text="option"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
This setting applies to: Orders, Products, Customers, Inventory, and other tables.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0')"></span>
|
||||
<div class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p class="font-medium mb-1">When does this take effect?</p>
|
||||
<p>Changes to the rows per page setting will apply immediately to all admin tables when refreshed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="saveDisplaySettings()"
|
||||
:disabled="saving"
|
||||
class="px-6 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">Save Display Settings</span>
|
||||
<span x-show="saving">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logging Settings Tab -->
|
||||
<div x-show="activeTab === 'logging'" x-transition>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# app/templates/admin/vendor-products.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
@@ -10,8 +10,79 @@
|
||||
|
||||
{% block alpine_data %}adminVendorProducts(){% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Tom Select CSS with local fallback -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
|
||||
/>
|
||||
<style>
|
||||
/* Tom Select dark mode overrides */
|
||||
.dark .ts-wrapper .ts-control {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input::placeholder {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(147 51 234);
|
||||
color: white;
|
||||
}
|
||||
.dark .ts-dropdown .option:hover {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
.dark .ts-wrapper.focus .ts-control {
|
||||
border-color: rgb(147 51 234);
|
||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Vendor Products', subtitle='Browse vendor-specific product catalogs with override capability') }}
|
||||
<!-- Page Header with Vendor Selector -->
|
||||
{% call page_header_flex(title='Vendor Products', subtitle='Browse vendor-specific product catalogs with override capability') %}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Vendor Autocomplete (Tom Select) -->
|
||||
<div class="w-80">
|
||||
<select id="vendor-select" x-ref="vendorSelect" placeholder="Filter by vendor...">
|
||||
</select>
|
||||
</div>
|
||||
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Selected Vendor Info -->
|
||||
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
|
||||
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearVendorFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading products...') }}
|
||||
|
||||
@@ -116,18 +187,6 @@
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Vendor Filter -->
|
||||
<select
|
||||
x-model="filters.vendor_id"
|
||||
@change="pagination.page = 1; loadProducts()"
|
||||
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 Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="vendor.name + ' (' + vendor.vendor_code + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.is_active"
|
||||
@@ -149,16 +208,6 @@
|
||||
<option value="true">Featured Only</option>
|
||||
<option value="false">Not Featured</option>
|
||||
</select>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="refresh()"
|
||||
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 products"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,5 +375,19 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<!-- Tom Select JS with local fallback -->
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
|
||||
script.onerror = function() {
|
||||
console.warn('Tom Select CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/vendor/tom-select.complete.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='admin/js/vendor-products.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,34 +5,79 @@
|
||||
|
||||
{% block title %}Vendor Themes{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" rel="stylesheet">
|
||||
<style>
|
||||
.ts-wrapper { width: 100%; }
|
||||
.ts-control {
|
||||
background-color: rgb(249 250 251) !important;
|
||||
border-color: rgb(209 213 219) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
.dark .ts-control {
|
||||
background-color: rgb(55 65 81) !important;
|
||||
border-color: rgb(75 85 99) !important;
|
||||
color: rgb(229 231 235) !important;
|
||||
}
|
||||
.ts-dropdown {
|
||||
border-radius: 0.5rem !important;
|
||||
border-color: rgb(209 213 219) !important;
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81) !important;
|
||||
border-color: rgb(75 85 99) !important;
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(229 231 235) !important;
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(75 85 99) !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminVendorThemes(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Vendor Themes', subtitle='Customize vendor theme colors and branding') }}
|
||||
|
||||
<!-- Vendor Selection -->
|
||||
<!-- Selected Vendor Display (when filtered) -->
|
||||
<div x-show="selectedVendor" x-cloak class="mb-6">
|
||||
<div class="px-4 py-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span x-html="$icon('color-swatch', 'w-6 h-6 text-purple-600')"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Filtered by Vendor</p>
|
||||
<p class="text-lg font-semibold text-purple-900 dark:text-purple-100" x-text="selectedVendor?.name"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="clearVendorFilter()"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-700 dark:text-purple-300 bg-purple-100 dark:bg-purple-800 rounded-md hover:bg-purple-200 dark:hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('x-mark', 'w-4 h-4')"></span>
|
||||
Clear Filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendor Search/Filter -->
|
||||
<div class="px-4 py-6 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">
|
||||
Select Vendor
|
||||
Search Vendor
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Choose a vendor to customize their theme
|
||||
Search for a vendor to customize their theme
|
||||
</p>
|
||||
|
||||
<div class="max-w-md">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Vendor
|
||||
</label>
|
||||
<select
|
||||
x-model="selectedVendorCode"
|
||||
@change="navigateToTheme()"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="">Select a vendor...</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.vendor_code">
|
||||
<option :value="vendor.vendor_code" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
<select x-ref="vendorSelect" placeholder="Search vendor by name or code..."></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,13 +86,16 @@
|
||||
{{ error_state('Error loading vendors') }}
|
||||
|
||||
<!-- Vendors List -->
|
||||
<div x-show="!loading && vendors.length > 0">
|
||||
<div x-show="!loading && filteredVendors.length > 0">
|
||||
<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">
|
||||
All Vendors
|
||||
</h3>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-text="selectedVendor ? 'Selected Vendor' : 'All Vendors'"></span>
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400" x-text="`(${filteredVendors.length})`"></span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="vendor in vendors" :key="vendor.vendor_code">
|
||||
<template x-for="vendor in filteredVendors" :key="vendor.vendor_code">
|
||||
<a
|
||||
:href="`/admin/vendors/${vendor.vendor_code}/theme`"
|
||||
class="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 dark:hover:border-purple-500 hover:shadow-md transition-all"
|
||||
@@ -68,7 +116,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && vendors.length === 0" class="text-center py-12">
|
||||
<div x-show="!loading && filteredVendors.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('shopping-bag', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">No vendors found</p>
|
||||
</div>
|
||||
@@ -76,5 +124,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
|
||||
<script src="{{ url_for('static', path='shared/js/vendor-selector.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='admin/js/vendor-themes.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
363
docs/frontend/shared/platform-settings.md
Normal file
363
docs/frontend/shared/platform-settings.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# Platform Settings Integration
|
||||
|
||||
> **Version:** 1.0
|
||||
> **Last Updated:** December 2024
|
||||
> **Audience:** Frontend Developers
|
||||
|
||||
## Overview
|
||||
|
||||
Platform Settings provides a centralized configuration system for admin and vendor frontend applications. Settings are stored in the database and cached client-side for performance. This ensures consistent behavior across all pages while allowing administrators to customize the platform.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Centralized Configuration**: All display settings in one place (`/admin/settings`)
|
||||
- **Client-Side Caching**: 5-minute cache to minimize API calls
|
||||
- **Automatic Integration**: Easy integration with existing page patterns
|
||||
- **Admin Configurable**: Settings can be changed without code deployment
|
||||
|
||||
## Available Settings
|
||||
|
||||
| Setting | Description | Default | Options |
|
||||
|---------|-------------|---------|---------|
|
||||
| `rows_per_page` | Number of items per page in tables | 20 | 10, 20, 50, 100 |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Platform Settings in Your Page
|
||||
|
||||
```javascript
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._myPageInitialized) return;
|
||||
window._myPageInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// Continue with page initialization...
|
||||
await this.loadData();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### PlatformSettings Object
|
||||
|
||||
The `PlatformSettings` utility is available globally via `window.PlatformSettings`.
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `get()` | `Promise<Object>` | Get all cached settings or fetch from API |
|
||||
| `getRowsPerPage()` | `Promise<number>` | Get the rows per page setting |
|
||||
| `clearCache()` | `void` | Clear the cached settings |
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```javascript
|
||||
// Get rows per page
|
||||
const rowsPerPage = await window.PlatformSettings.getRowsPerPage();
|
||||
|
||||
// Get all settings
|
||||
const settings = await window.PlatformSettings.get();
|
||||
console.log(settings.rows_per_page);
|
||||
|
||||
// Clear cache (call after saving settings)
|
||||
window.PlatformSettings.clearCache();
|
||||
```
|
||||
|
||||
## Implementation Pattern
|
||||
|
||||
### Standard Pagination State Structure
|
||||
|
||||
All pages with tables should use this standard pagination structure:
|
||||
|
||||
```javascript
|
||||
function adminMyPage() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'my-page',
|
||||
|
||||
// Standard pagination structure
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 20, // Will be overridden by platform settings
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
|
||||
async init() {
|
||||
if (window._adminMyPageInitialized) return;
|
||||
window._adminMyPageInitialized = true;
|
||||
|
||||
// REQUIRED: Load platform settings for pagination
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
const params = new URLSearchParams({
|
||||
skip: ((this.pagination.page - 1) * this.pagination.per_page).toString(),
|
||||
limit: this.pagination.per_page.toString()
|
||||
});
|
||||
|
||||
const response = await apiClient.get(`/admin/my-endpoint?${params}`);
|
||||
this.items = response.items;
|
||||
this.pagination.total = response.total;
|
||||
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Computed Properties for Pagination
|
||||
|
||||
Include these computed properties for the pagination macro:
|
||||
|
||||
```javascript
|
||||
// Computed: Total 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: Page numbers for pagination
|
||||
get pageNumbers() {
|
||||
const pages = [];
|
||||
const totalPages = this.totalPages;
|
||||
const current = this.pagination.page;
|
||||
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (current > 3) pages.push('...');
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(totalPages - 1, current + 1);
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (current < totalPages - 2) pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation Methods
|
||||
|
||||
```javascript
|
||||
previousPage() {
|
||||
if (this.pagination.page > 1) {
|
||||
this.pagination.page--;
|
||||
this.loadData();
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.pagination.page < this.totalPages) {
|
||||
this.pagination.page++;
|
||||
this.loadData();
|
||||
}
|
||||
},
|
||||
|
||||
goToPage(pageNum) {
|
||||
if (typeof pageNum === 'number' && pageNum !== this.pagination.page) {
|
||||
this.pagination.page = pageNum;
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Correct vs Incorrect Patterns
|
||||
|
||||
### Loading Platform Settings
|
||||
|
||||
```javascript
|
||||
// CORRECT: Load platform settings in init()
|
||||
async init() {
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
// INCORRECT: Hardcoding pagination values
|
||||
async init() {
|
||||
this.pagination.per_page = 50; // Don't hardcode!
|
||||
await this.loadData();
|
||||
}
|
||||
```
|
||||
|
||||
### Pagination Structure
|
||||
|
||||
```javascript
|
||||
// CORRECT: Standard nested pagination object
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
}
|
||||
|
||||
// INCORRECT: Flat pagination variables
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
skip: 0
|
||||
```
|
||||
|
||||
### API Calls with Pagination
|
||||
|
||||
```javascript
|
||||
// CORRECT: Use pagination object properties
|
||||
const params = new URLSearchParams({
|
||||
skip: ((this.pagination.page - 1) * this.pagination.per_page).toString(),
|
||||
limit: this.pagination.per_page.toString()
|
||||
});
|
||||
|
||||
// INCORRECT: Hardcoded values
|
||||
const params = new URLSearchParams({
|
||||
skip: '0',
|
||||
limit: '50' // Don't hardcode!
|
||||
});
|
||||
```
|
||||
|
||||
## Admin Settings Page
|
||||
|
||||
The rows per page setting can be configured at `/admin/settings` under the **Display** tab.
|
||||
|
||||
### Available Options
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| 10 | Compact view, good for slow connections |
|
||||
| 20 | Default, balanced view |
|
||||
| 50 | Extended view, fewer page loads |
|
||||
| 100 | Maximum view, best for power users |
|
||||
|
||||
## Caching Behavior
|
||||
|
||||
### How Caching Works
|
||||
|
||||
1. **First Access**: API call to `/admin/settings/display/public`
|
||||
2. **Subsequent Access**: Returns cached value (within 5-minute TTL)
|
||||
3. **Cache Expiry**: After 5 minutes, next access fetches fresh data
|
||||
4. **Manual Clear**: Call `PlatformSettings.clearCache()` after saving settings
|
||||
|
||||
### Cache Storage
|
||||
|
||||
Settings are cached in `localStorage` under the key `platform_settings_cache`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"rows_per_page": 20
|
||||
},
|
||||
"timestamp": 1703123456789
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Get Display Settings (Public)
|
||||
|
||||
```
|
||||
GET /api/v1/admin/settings/display/public
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"rows_per_page": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Get Rows Per Page (Authenticated)
|
||||
|
||||
```
|
||||
GET /api/v1/admin/settings/display/rows-per-page
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"rows_per_page": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Update Rows Per Page
|
||||
|
||||
```
|
||||
PUT /api/v1/admin/settings/display/rows-per-page?rows=50
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"rows_per_page": 50,
|
||||
"message": "Rows per page setting updated"
|
||||
}
|
||||
```
|
||||
|
||||
## Pages Using Platform Settings
|
||||
|
||||
The following pages currently integrate with platform settings:
|
||||
|
||||
### Admin Pages
|
||||
- `/admin/orders` - Orders management
|
||||
- `/admin/marketplace-products` - Marketplace product catalog
|
||||
- `/admin/vendor-products` - Vendor product catalog
|
||||
- `/admin/customers` - Customer management
|
||||
- `/admin/inventory` - Inventory management
|
||||
|
||||
### Vendor Pages
|
||||
- (Future) All vendor table pages should follow the same pattern
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Settings Not Loading
|
||||
|
||||
1. Check browser console for errors
|
||||
2. Verify `window.PlatformSettings` is available
|
||||
3. Check network tab for API call to `/settings/display/public`
|
||||
|
||||
### Cache Not Clearing
|
||||
|
||||
```javascript
|
||||
// Force clear cache manually
|
||||
localStorage.removeItem('platform_settings_cache');
|
||||
```
|
||||
|
||||
### Wrong Default Value
|
||||
|
||||
If `PlatformSettings` fails to load, pages fall back to their hardcoded default (typically 20). Check:
|
||||
|
||||
1. API endpoint is accessible
|
||||
2. User has authentication (if required)
|
||||
3. No CORS issues
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Pagination Components](pagination.md) - Pagination macro usage
|
||||
- [Admin Architecture](../admin/architecture.md) - Admin frontend patterns
|
||||
- [Logging System](logging.md) - Frontend logging configuration
|
||||
@@ -96,6 +96,7 @@ nav:
|
||||
- UI Components Quick Reference: frontend/shared/ui-components-quick-reference.md
|
||||
- Pagination: frontend/shared/pagination.md
|
||||
- Pagination Quick Start: frontend/shared/pagination-quick-start.md
|
||||
- Platform Settings: frontend/shared/platform-settings.md
|
||||
- Sidebar Implementation: frontend/shared/sidebar.md
|
||||
- Logging System: frontend/shared/logging.md
|
||||
- Admin Frontend:
|
||||
|
||||
@@ -2698,6 +2698,138 @@ class ArchitectureValidator:
|
||||
# JS-004: Check Alpine components set currentPage
|
||||
self._check_alpine_current_page(file_path, content, lines)
|
||||
|
||||
# JS-010: Check PlatformSettings usage for pagination
|
||||
self._check_platform_settings_usage(file_path, content, lines)
|
||||
|
||||
# JS-011: Check standard pagination structure
|
||||
self._check_pagination_structure(file_path, content, lines)
|
||||
|
||||
# JS-012: Check for double /api/v1 prefix in API calls
|
||||
self._check_api_prefix_usage(file_path, content, lines)
|
||||
|
||||
def _check_platform_settings_usage(
|
||||
self, file_path: Path, content: str, lines: list[str]
|
||||
):
|
||||
"""
|
||||
JS-010: Check that pages with pagination use PlatformSettings.getRowsPerPage()
|
||||
"""
|
||||
# Skip excluded files
|
||||
excluded_files = ["init-alpine.js", "init-api-client.js", "settings.js"]
|
||||
if file_path.name in excluded_files:
|
||||
return
|
||||
|
||||
# Check if this file has pagination (look for pagination object)
|
||||
has_pagination = re.search(r"pagination:\s*\{", content)
|
||||
if not has_pagination:
|
||||
return
|
||||
|
||||
# Check if file uses PlatformSettings
|
||||
uses_platform_settings = "PlatformSettings" in content
|
||||
|
||||
if not uses_platform_settings:
|
||||
# Find the line where pagination is defined
|
||||
for i, line in enumerate(lines, 1):
|
||||
if "pagination:" in line and "{" in line:
|
||||
self._add_violation(
|
||||
rule_id="JS-010",
|
||||
rule_name="Use PlatformSettings for pagination",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Page with pagination must use PlatformSettings.getRowsPerPage()",
|
||||
context=line.strip()[:80],
|
||||
suggestion="Add: if (window.PlatformSettings) { this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); }",
|
||||
)
|
||||
break
|
||||
|
||||
def _check_pagination_structure(
|
||||
self, file_path: Path, content: str, lines: list[str]
|
||||
):
|
||||
"""
|
||||
JS-011: Check that pagination uses standard structure (pagination.per_page not page_size)
|
||||
"""
|
||||
# Skip excluded files
|
||||
excluded_files = ["init-alpine.js"]
|
||||
if file_path.name in excluded_files:
|
||||
return
|
||||
|
||||
# Check if this file has pagination
|
||||
has_pagination = re.search(r"pagination:\s*\{", content)
|
||||
if not has_pagination:
|
||||
return
|
||||
|
||||
# Check for wrong property names in pagination object definition
|
||||
# Look for pagination object with wrong property names
|
||||
wrong_patterns = [
|
||||
(r"page_size\s*:", "page_size", "per_page"),
|
||||
(r"pageSize\s*:", "pageSize", "per_page"),
|
||||
(r"total_pages\s*:", "total_pages", "pages"),
|
||||
(r"totalPages\s*:", "totalPages (in pagination object)", "pages"),
|
||||
]
|
||||
|
||||
# Find the pagination object definition and check its properties
|
||||
in_pagination_block = False
|
||||
brace_count = 0
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Detect start of pagination object
|
||||
if "pagination:" in line and "{" in line:
|
||||
in_pagination_block = True
|
||||
brace_count = line.count("{") - line.count("}")
|
||||
continue
|
||||
|
||||
if in_pagination_block:
|
||||
brace_count += line.count("{") - line.count("}")
|
||||
|
||||
# Check for wrong property names only inside the pagination block
|
||||
for pattern, wrong_name, correct_name in wrong_patterns:
|
||||
if re.search(pattern, line):
|
||||
self._add_violation(
|
||||
rule_id="JS-011",
|
||||
rule_name="Use standard pagination structure",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message=f"Use '{correct_name}' instead of '{wrong_name}' in pagination",
|
||||
context=line.strip()[:80],
|
||||
suggestion=f"Rename '{wrong_name}' to '{correct_name}'",
|
||||
)
|
||||
|
||||
# Exit pagination block when braces close
|
||||
if brace_count <= 0:
|
||||
in_pagination_block = False
|
||||
|
||||
def _check_api_prefix_usage(
|
||||
self, file_path: Path, content: str, lines: list[str]
|
||||
):
|
||||
"""
|
||||
JS-012: Check that apiClient calls don't include /api/v1 prefix
|
||||
(apiClient adds this prefix automatically)
|
||||
"""
|
||||
# Skip excluded files
|
||||
excluded_files = ["init-api-client.js", "api-client.js"]
|
||||
if file_path.name in excluded_files:
|
||||
return
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Skip comments
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//") or stripped.startswith("*"):
|
||||
continue
|
||||
|
||||
# Check for apiClient calls with /api/v1 prefix
|
||||
if re.search(r"apiClient\.(get|post|put|delete|patch)\s*\(\s*['\"`]/api/v1", line):
|
||||
self._add_violation(
|
||||
rule_id="JS-012",
|
||||
rule_name="Do not include /api/v1 prefix",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="apiClient already adds /api/v1 prefix - remove it from the endpoint",
|
||||
context=line.strip()[:80],
|
||||
suggestion="Change '/api/v1/admin/...' to '/admin/...'",
|
||||
)
|
||||
|
||||
def _validate_templates(self, target_path: Path):
|
||||
"""Validate template patterns"""
|
||||
print("📄 Validating templates...")
|
||||
|
||||
@@ -20,9 +20,9 @@ function codeQualityViolations() {
|
||||
violations: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
total_pages: 0
|
||||
pages: 0
|
||||
},
|
||||
filters: {
|
||||
validator_type: '',
|
||||
@@ -33,6 +33,18 @@ function codeQualityViolations() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._codeQualityViolationsInitialized) {
|
||||
codeQualityViolationsLog.warn('Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
window._codeQualityViolationsInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// Load filters from URL params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.filters.validator_type = params.get('validator_type') || '';
|
||||
@@ -52,7 +64,7 @@ function codeQualityViolations() {
|
||||
// Build query params
|
||||
const params = {
|
||||
page: this.pagination.page.toString(),
|
||||
page_size: this.pagination.page_size.toString()
|
||||
page_size: this.pagination.per_page.toString()
|
||||
};
|
||||
|
||||
if (this.filters.validator_type) params.validator_type = this.filters.validator_type;
|
||||
@@ -64,12 +76,8 @@ function codeQualityViolations() {
|
||||
const data = await apiClient.get('/admin/code-quality/violations', params);
|
||||
|
||||
this.violations = data.violations;
|
||||
this.pagination = {
|
||||
page: data.page,
|
||||
page_size: data.page_size,
|
||||
total: data.total,
|
||||
total_pages: data.total_pages
|
||||
};
|
||||
this.pagination.total = data.total;
|
||||
this.pagination.pages = data.total_pages;
|
||||
|
||||
// Update URL with current filters (without reloading)
|
||||
this.updateURL();
|
||||
@@ -93,7 +101,7 @@ function codeQualityViolations() {
|
||||
},
|
||||
|
||||
async nextPage() {
|
||||
if (this.pagination.page < this.pagination.total_pages) {
|
||||
if (this.pagination.page < this.pagination.pages) {
|
||||
this.pagination.page++;
|
||||
await this.loadViolations();
|
||||
}
|
||||
@@ -101,18 +109,18 @@ function codeQualityViolations() {
|
||||
|
||||
// Computed: Total number of pages
|
||||
get totalPages() {
|
||||
return this.pagination.total_pages;
|
||||
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.page_size + 1;
|
||||
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.page_size;
|
||||
const end = this.pagination.page * this.pagination.per_page;
|
||||
return end > this.pagination.total ? this.pagination.total : end;
|
||||
},
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function adminCompanies() {
|
||||
// Pagination state
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
@@ -51,6 +51,11 @@ function adminCompanies() {
|
||||
}
|
||||
window._companiesInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
companiesLog.group('Loading companies data');
|
||||
await this.loadCompanies();
|
||||
companiesLog.groupEnd();
|
||||
|
||||
@@ -23,7 +23,6 @@ function adminCustomers() {
|
||||
|
||||
// Data
|
||||
customers: [],
|
||||
vendors: [],
|
||||
stats: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
@@ -34,11 +33,13 @@ function adminCustomers() {
|
||||
avg_order_value: 0
|
||||
},
|
||||
|
||||
// Pagination
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
skip: 0,
|
||||
// Pagination (standard structure matching pagination macro)
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
@@ -47,51 +48,216 @@ function adminCustomers() {
|
||||
vendor_id: ''
|
||||
},
|
||||
|
||||
// Selected vendor (for prominent display and filtering)
|
||||
selectedVendor: null,
|
||||
|
||||
// Tom Select instance
|
||||
vendorSelectInstance: null,
|
||||
|
||||
// Computed: total pages
|
||||
get totalPages() {
|
||||
return Math.ceil(this.total / this.limit);
|
||||
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: Page numbers for pagination
|
||||
get pageNumbers() {
|
||||
const pages = [];
|
||||
const totalPages = this.totalPages;
|
||||
const current = this.pagination.page;
|
||||
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (current > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(totalPages - 1, current + 1);
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (current < totalPages - 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
|
||||
async init() {
|
||||
customersLog.debug('Customers page initialized');
|
||||
|
||||
// Load vendors for filter dropdown
|
||||
await this.loadVendors();
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
await Promise.all([
|
||||
this.loadCustomers(),
|
||||
this.loadStats()
|
||||
]);
|
||||
// Initialize Tom Select for vendor filter
|
||||
this.initVendorSelect();
|
||||
|
||||
// Check localStorage for saved vendor
|
||||
const savedVendorId = localStorage.getItem('customers_selected_vendor_id');
|
||||
if (savedVendorId) {
|
||||
customersLog.debug('Restoring saved vendor:', savedVendorId);
|
||||
// Restore vendor after a short delay to ensure TomSelect is ready
|
||||
setTimeout(async () => {
|
||||
await this.restoreSavedVendor(parseInt(savedVendorId));
|
||||
}, 200);
|
||||
// Load stats but not customers (restoreSavedVendor will do that)
|
||||
await this.loadStats();
|
||||
} else {
|
||||
// No saved vendor - load all data
|
||||
await Promise.all([
|
||||
this.loadCustomers(),
|
||||
this.loadStats()
|
||||
]);
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load vendors for filter dropdown
|
||||
* Restore saved vendor from localStorage
|
||||
*/
|
||||
async loadVendors() {
|
||||
async restoreSavedVendor(vendorId) {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendors?limit=100');
|
||||
this.vendors = response.vendors || [];
|
||||
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
|
||||
if (this.vendorSelectInstance && vendor) {
|
||||
// Add the vendor as an option and select it
|
||||
this.vendorSelectInstance.addOption({
|
||||
id: vendor.id,
|
||||
name: vendor.name,
|
||||
vendor_code: vendor.vendor_code
|
||||
});
|
||||
this.vendorSelectInstance.setValue(vendor.id, true);
|
||||
|
||||
// Set the filter state
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_id = vendor.id;
|
||||
|
||||
customersLog.debug('Restored vendor:', vendor.name);
|
||||
|
||||
// Load customers with the vendor filter applied
|
||||
await this.loadCustomers();
|
||||
}
|
||||
} catch (error) {
|
||||
customersLog.error('Failed to load vendors:', error);
|
||||
this.vendors = [];
|
||||
customersLog.error('Failed to restore saved vendor, clearing localStorage:', error);
|
||||
localStorage.removeItem('customers_selected_vendor_id');
|
||||
// Load unfiltered customers as fallback
|
||||
await this.loadCustomers();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize Tom Select for vendor autocomplete
|
||||
*/
|
||||
initVendorSelect() {
|
||||
const selectEl = this.$refs.vendorSelect;
|
||||
if (!selectEl) {
|
||||
customersLog.warn('Vendor select element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for Tom Select to be available
|
||||
if (typeof TomSelect === 'undefined') {
|
||||
customersLog.warn('TomSelect not loaded, retrying in 100ms');
|
||||
setTimeout(() => this.initVendorSelect(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.vendorSelectInstance = new TomSelect(selectEl, {
|
||||
valueField: 'id',
|
||||
labelField: 'name',
|
||||
searchField: ['name', 'vendor_code'],
|
||||
placeholder: 'Filter by vendor...',
|
||||
allowEmptyOption: true,
|
||||
load: async (query, callback) => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendors', {
|
||||
search: query,
|
||||
limit: 50
|
||||
});
|
||||
callback(response.vendors || []);
|
||||
} catch (error) {
|
||||
customersLog.error('Failed to search vendors:', error);
|
||||
callback([]);
|
||||
}
|
||||
},
|
||||
render: {
|
||||
option: (data, escape) => {
|
||||
return `<div class="flex items-center justify-between py-1">
|
||||
<span>${escape(data.name)}</span>
|
||||
<span class="text-xs text-gray-400 font-mono">${escape(data.vendor_code || '')}</span>
|
||||
</div>`;
|
||||
},
|
||||
item: (data, escape) => {
|
||||
return `<div>${escape(data.name)}</div>`;
|
||||
}
|
||||
},
|
||||
onChange: (value) => {
|
||||
if (value) {
|
||||
const vendor = this.vendorSelectInstance.options[value];
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_id = value;
|
||||
// Save to localStorage
|
||||
localStorage.setItem('customers_selected_vendor_id', value.toString());
|
||||
} else {
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_id = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('customers_selected_vendor_id');
|
||||
}
|
||||
this.pagination.page = 1;
|
||||
this.loadCustomers();
|
||||
this.loadStats();
|
||||
}
|
||||
});
|
||||
|
||||
customersLog.debug('Vendor select initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear vendor filter
|
||||
*/
|
||||
clearVendorFilter() {
|
||||
if (this.vendorSelectInstance) {
|
||||
this.vendorSelectInstance.clear();
|
||||
}
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_id = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('customers_selected_vendor_id');
|
||||
this.pagination.page = 1;
|
||||
this.loadCustomers();
|
||||
this.loadStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load customers with current filters
|
||||
*/
|
||||
async loadCustomers() {
|
||||
this.loadingCustomers = true;
|
||||
this.error = '';
|
||||
this.skip = (this.page - 1) * this.limit;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: this.skip.toString(),
|
||||
limit: this.limit.toString()
|
||||
skip: ((this.pagination.page - 1) * this.pagination.per_page).toString(),
|
||||
limit: this.pagination.per_page.toString()
|
||||
});
|
||||
|
||||
if (this.filters.search) {
|
||||
@@ -108,7 +274,8 @@ function adminCustomers() {
|
||||
|
||||
const response = await apiClient.get(`/admin/customers?${params}`);
|
||||
this.customers = response.customers || [];
|
||||
this.total = response.total || 0;
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
|
||||
} catch (error) {
|
||||
customersLog.error('Failed to load customers:', error);
|
||||
this.error = error.message || 'Failed to load customers';
|
||||
@@ -139,7 +306,7 @@ function adminCustomers() {
|
||||
* Reset pagination and reload
|
||||
*/
|
||||
async resetAndLoad() {
|
||||
this.page = 1;
|
||||
this.pagination.page = 1;
|
||||
await Promise.all([
|
||||
this.loadCustomers(),
|
||||
this.loadStats()
|
||||
@@ -147,34 +314,33 @@ function adminCustomers() {
|
||||
},
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
* Go to previous page
|
||||
*/
|
||||
goToPage(p) {
|
||||
this.page = p;
|
||||
this.loadCustomers();
|
||||
previousPage() {
|
||||
if (this.pagination.page > 1) {
|
||||
this.pagination.page--;
|
||||
this.loadCustomers();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get array of page numbers to display
|
||||
* Go to next page
|
||||
*/
|
||||
getPageNumbers() {
|
||||
const total = this.totalPages;
|
||||
const current = this.page;
|
||||
const maxVisible = 5;
|
||||
|
||||
if (total <= maxVisible) {
|
||||
return Array.from({length: total}, (_, i) => i + 1);
|
||||
nextPage() {
|
||||
if (this.pagination.page < this.totalPages) {
|
||||
this.pagination.page++;
|
||||
this.loadCustomers();
|
||||
}
|
||||
},
|
||||
|
||||
const half = Math.floor(maxVisible / 2);
|
||||
let start = Math.max(1, current - half);
|
||||
let end = Math.min(total, start + maxVisible - 1);
|
||||
|
||||
if (end - start < maxVisible - 1) {
|
||||
start = Math.max(1, end - maxVisible + 1);
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
goToPage(pageNum) {
|
||||
if (typeof pageNum === 'number' && pageNum !== this.pagination.page) {
|
||||
this.pagination.page = pageNum;
|
||||
this.loadCustomers();
|
||||
}
|
||||
|
||||
return Array.from({length: end - start + 1}, (_, i) => start + i);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,6 +115,11 @@ function adminImports() {
|
||||
}
|
||||
window._adminImportsInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// IMPORTANT: Call parent init first
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
|
||||
@@ -257,4 +257,64 @@ function headerMessages() {
|
||||
}
|
||||
|
||||
// Export to window
|
||||
window.headerMessages = headerMessages;
|
||||
window.headerMessages = headerMessages;
|
||||
|
||||
/**
|
||||
* Platform Settings Utility
|
||||
* Provides cached access to platform-wide settings
|
||||
*/
|
||||
const PlatformSettings = {
|
||||
// Cache key and TTL
|
||||
CACHE_KEY: 'platform_settings_cache',
|
||||
CACHE_TTL: 5 * 60 * 1000, // 5 minutes
|
||||
|
||||
/**
|
||||
* Get cached settings or fetch from API
|
||||
*/
|
||||
async get() {
|
||||
try {
|
||||
const cached = localStorage.getItem(this.CACHE_KEY);
|
||||
if (cached) {
|
||||
const { data, timestamp } = JSON.parse(cached);
|
||||
if (Date.now() - timestamp < this.CACHE_TTL) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
const response = await apiClient.get('/admin/settings/display/public');
|
||||
const settings = {
|
||||
rows_per_page: response.rows_per_page || 20
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
localStorage.setItem(this.CACHE_KEY, JSON.stringify({
|
||||
data: settings,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load platform settings, using defaults:', error);
|
||||
return { rows_per_page: 20 };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get rows per page setting
|
||||
*/
|
||||
async getRowsPerPage() {
|
||||
const settings = await this.get();
|
||||
return settings.rows_per_page;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the cache (call after saving settings)
|
||||
*/
|
||||
clearCache() {
|
||||
localStorage.removeItem(this.CACHE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
// Export to window
|
||||
window.PlatformSettings = PlatformSettings;
|
||||
@@ -47,13 +47,16 @@ function adminInventory() {
|
||||
// Available locations for filter dropdown
|
||||
locations: [],
|
||||
|
||||
// Selected vendor (for prominent display and filtering)
|
||||
selectedVendor: null,
|
||||
|
||||
// Vendor selector controller (Tom Select)
|
||||
vendorSelector: null,
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
@@ -131,21 +134,68 @@ function adminInventory() {
|
||||
}
|
||||
window._adminInventoryInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// Initialize vendor selector (Tom Select)
|
||||
this.$nextTick(() => {
|
||||
this.initVendorSelector();
|
||||
});
|
||||
|
||||
// Load data in parallel
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadLocations(),
|
||||
this.loadInventory()
|
||||
]);
|
||||
// Check localStorage for saved vendor
|
||||
const savedVendorId = localStorage.getItem('inventory_selected_vendor_id');
|
||||
if (savedVendorId) {
|
||||
adminInventoryLog.info('Restoring saved vendor:', savedVendorId);
|
||||
// Restore vendor after a short delay to ensure TomSelect is ready
|
||||
setTimeout(async () => {
|
||||
await this.restoreSavedVendor(parseInt(savedVendorId));
|
||||
}, 200);
|
||||
// Load stats and locations but not inventory (restoreSavedVendor will do that)
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadLocations()
|
||||
]);
|
||||
} else {
|
||||
// No saved vendor - load all data
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadLocations(),
|
||||
this.loadInventory()
|
||||
]);
|
||||
}
|
||||
|
||||
adminInventoryLog.info('Inventory initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore saved vendor from localStorage
|
||||
*/
|
||||
async restoreSavedVendor(vendorId) {
|
||||
try {
|
||||
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
|
||||
if (this.vendorSelector && vendor) {
|
||||
// Use the vendor selector's setValue method
|
||||
this.vendorSelector.setValue(vendor.id, vendor);
|
||||
|
||||
// Set the filter state
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_id = vendor.id;
|
||||
|
||||
adminInventoryLog.info('Restored vendor:', vendor.name);
|
||||
|
||||
// Load inventory with the vendor filter applied
|
||||
await this.loadInventory();
|
||||
}
|
||||
} catch (error) {
|
||||
adminInventoryLog.warn('Failed to restore saved vendor, clearing localStorage:', error);
|
||||
localStorage.removeItem('inventory_selected_vendor_id');
|
||||
// Load unfiltered inventory as fallback
|
||||
await this.loadInventory();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize vendor selector with Tom Select
|
||||
*/
|
||||
@@ -159,27 +209,57 @@ function adminInventory() {
|
||||
placeholder: 'Filter by vendor...',
|
||||
onSelect: (vendor) => {
|
||||
adminInventoryLog.info('Vendor selected:', vendor);
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_id = vendor.id;
|
||||
// Save to localStorage
|
||||
localStorage.setItem('inventory_selected_vendor_id', vendor.id.toString());
|
||||
this.pagination.page = 1;
|
||||
this.loadLocations();
|
||||
this.loadInventory();
|
||||
this.loadStats();
|
||||
},
|
||||
onClear: () => {
|
||||
adminInventoryLog.info('Vendor filter cleared');
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_id = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('inventory_selected_vendor_id');
|
||||
this.pagination.page = 1;
|
||||
this.loadLocations();
|
||||
this.loadInventory();
|
||||
this.loadStats();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear vendor filter
|
||||
*/
|
||||
clearVendorFilter() {
|
||||
if (this.vendorSelector) {
|
||||
this.vendorSelector.clear();
|
||||
}
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_id = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('inventory_selected_vendor_id');
|
||||
this.pagination.page = 1;
|
||||
this.loadLocations();
|
||||
this.loadInventory();
|
||||
this.loadStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load inventory statistics
|
||||
*/
|
||||
async loadStats() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/inventory/stats');
|
||||
const params = new URLSearchParams();
|
||||
if (this.filters.vendor_id) {
|
||||
params.append('vendor_id', this.filters.vendor_id);
|
||||
}
|
||||
const url = params.toString() ? `/admin/inventory/stats?${params}` : '/admin/inventory/stats';
|
||||
const response = await apiClient.get(url);
|
||||
this.stats = response;
|
||||
adminInventoryLog.info('Loaded stats:', this.stats);
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,7 +32,7 @@ function adminLogs() {
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
@@ -86,7 +86,20 @@ function adminLogs() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._adminLogsInitialized) {
|
||||
logsLog.warn('Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
window._adminLogsInitialized = true;
|
||||
|
||||
logsLog.info('=== LOGS PAGE INITIALIZING ===');
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
await this.loadStats();
|
||||
await this.loadLogs();
|
||||
},
|
||||
|
||||
@@ -43,16 +43,19 @@ function adminMarketplaceProducts() {
|
||||
is_digital: ''
|
||||
},
|
||||
|
||||
// Selected vendor (for prominent display and filtering)
|
||||
selectedVendor: null,
|
||||
|
||||
// Tom Select instance
|
||||
vendorSelectInstance: null,
|
||||
|
||||
// Available marketplaces for filter dropdown
|
||||
marketplaces: [],
|
||||
|
||||
// Available source vendors for filter dropdown
|
||||
sourceVendors: [],
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
@@ -127,24 +130,171 @@ function adminMarketplaceProducts() {
|
||||
}
|
||||
window._adminMarketplaceProductsInitialized = true;
|
||||
|
||||
// Load data in parallel
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadMarketplaces(),
|
||||
this.loadSourceVendors(),
|
||||
this.loadTargetVendors(),
|
||||
this.loadProducts()
|
||||
]);
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// Initialize Tom Select for vendor filter
|
||||
this.initVendorSelect();
|
||||
|
||||
// Check localStorage for saved vendor
|
||||
const savedVendorId = localStorage.getItem('marketplace_products_selected_vendor_id');
|
||||
if (savedVendorId) {
|
||||
adminMarketplaceProductsLog.info('Restoring saved vendor:', savedVendorId);
|
||||
// Restore vendor after a short delay to ensure TomSelect is ready
|
||||
setTimeout(async () => {
|
||||
await this.restoreSavedVendor(parseInt(savedVendorId));
|
||||
}, 200);
|
||||
// Load other data but not products (restoreSavedVendor will do that)
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadMarketplaces(),
|
||||
this.loadTargetVendors()
|
||||
]);
|
||||
} else {
|
||||
// No saved vendor - load all data including unfiltered products
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadMarketplaces(),
|
||||
this.loadTargetVendors(),
|
||||
this.loadProducts()
|
||||
]);
|
||||
}
|
||||
|
||||
adminMarketplaceProductsLog.info('Marketplace Products initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore saved vendor from localStorage
|
||||
*/
|
||||
async restoreSavedVendor(vendorId) {
|
||||
try {
|
||||
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
|
||||
if (this.vendorSelectInstance && vendor) {
|
||||
// Add the vendor as an option and select it
|
||||
this.vendorSelectInstance.addOption({
|
||||
id: vendor.id,
|
||||
name: vendor.name,
|
||||
vendor_code: vendor.vendor_code
|
||||
});
|
||||
this.vendorSelectInstance.setValue(vendor.id, true);
|
||||
|
||||
// Set the filter state
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_name = vendor.name;
|
||||
|
||||
adminMarketplaceProductsLog.info('Restored vendor:', vendor.name);
|
||||
|
||||
// Load products with the vendor filter applied
|
||||
await this.loadProducts();
|
||||
}
|
||||
} catch (error) {
|
||||
adminMarketplaceProductsLog.warn('Failed to restore saved vendor, clearing localStorage:', error);
|
||||
localStorage.removeItem('marketplace_products_selected_vendor_id');
|
||||
// Load unfiltered products as fallback
|
||||
await this.loadProducts();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize Tom Select for vendor autocomplete
|
||||
*/
|
||||
initVendorSelect() {
|
||||
const selectEl = this.$refs.vendorSelect;
|
||||
if (!selectEl) {
|
||||
adminMarketplaceProductsLog.warn('Vendor select element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for Tom Select to be available
|
||||
if (typeof TomSelect === 'undefined') {
|
||||
adminMarketplaceProductsLog.warn('TomSelect not loaded, retrying in 100ms');
|
||||
setTimeout(() => this.initVendorSelect(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.vendorSelectInstance = new TomSelect(selectEl, {
|
||||
valueField: 'id',
|
||||
labelField: 'name',
|
||||
searchField: ['name', 'vendor_code'],
|
||||
placeholder: 'Filter by vendor...',
|
||||
allowEmptyOption: true,
|
||||
load: async (query, callback) => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendors', {
|
||||
search: query,
|
||||
limit: 50
|
||||
});
|
||||
callback(response.vendors || []);
|
||||
} catch (error) {
|
||||
adminMarketplaceProductsLog.error('Failed to search vendors:', error);
|
||||
callback([]);
|
||||
}
|
||||
},
|
||||
render: {
|
||||
option: (data, escape) => {
|
||||
return `<div class="flex items-center justify-between py-1">
|
||||
<span>${escape(data.name)}</span>
|
||||
<span class="text-xs text-gray-400 font-mono">${escape(data.vendor_code || '')}</span>
|
||||
</div>`;
|
||||
},
|
||||
item: (data, escape) => {
|
||||
return `<div>${escape(data.name)}</div>`;
|
||||
}
|
||||
},
|
||||
onChange: (value) => {
|
||||
if (value) {
|
||||
const vendor = this.vendorSelectInstance.options[value];
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_name = vendor.name;
|
||||
// Save to localStorage
|
||||
localStorage.setItem('marketplace_products_selected_vendor_id', value.toString());
|
||||
} else {
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_name = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('marketplace_products_selected_vendor_id');
|
||||
}
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
this.loadStats();
|
||||
}
|
||||
});
|
||||
|
||||
adminMarketplaceProductsLog.info('Vendor select initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear vendor filter
|
||||
*/
|
||||
clearVendorFilter() {
|
||||
if (this.vendorSelectInstance) {
|
||||
this.vendorSelectInstance.clear();
|
||||
}
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_name = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('marketplace_products_selected_vendor_id');
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
this.loadStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load product statistics
|
||||
*/
|
||||
async loadStats() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/products/stats');
|
||||
const params = new URLSearchParams();
|
||||
if (this.filters.marketplace) {
|
||||
params.append('marketplace', this.filters.marketplace);
|
||||
}
|
||||
if (this.filters.vendor_name) {
|
||||
params.append('vendor_name', this.filters.vendor_name);
|
||||
}
|
||||
const url = params.toString() ? `/admin/products/stats?${params}` : '/admin/products/stats';
|
||||
const response = await apiClient.get(url);
|
||||
this.stats = response;
|
||||
adminMarketplaceProductsLog.info('Loaded stats:', this.stats);
|
||||
} catch (error) {
|
||||
@@ -165,19 +315,6 @@ function adminMarketplaceProducts() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load available source vendors for filter (from marketplace products)
|
||||
*/
|
||||
async loadSourceVendors() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/products/vendors');
|
||||
this.sourceVendors = response.vendors || [];
|
||||
adminMarketplaceProductsLog.info('Loaded source vendors:', this.sourceVendors);
|
||||
} catch (error) {
|
||||
adminMarketplaceProductsLog.error('Failed to load source vendors:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load target vendors for copy functionality (actual vendor accounts)
|
||||
*/
|
||||
|
||||
@@ -51,7 +51,7 @@ function adminMarketplace() {
|
||||
jobs: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
@@ -118,6 +118,11 @@ function adminMarketplace() {
|
||||
}
|
||||
window._adminMarketplaceInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// Ensure form defaults are set (in case spread didn't work)
|
||||
if (!this.importForm.marketplace) {
|
||||
this.importForm.marketplace = 'Letzshop';
|
||||
|
||||
@@ -58,7 +58,7 @@ function adminOrders() {
|
||||
// Pagination
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
@@ -143,6 +143,11 @@ function adminOrders() {
|
||||
}
|
||||
window._adminOrdersInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// Initialize Tom Select for vendor filter
|
||||
this.initVendorSelect();
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ function adminSettings() {
|
||||
saving: false,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
activeTab: 'logging',
|
||||
activeTab: 'display',
|
||||
displaySettings: {
|
||||
rows_per_page: 20
|
||||
},
|
||||
logSettings: {
|
||||
log_level: 'INFO',
|
||||
log_file_max_size_mb: 10,
|
||||
@@ -41,6 +44,7 @@ function adminSettings() {
|
||||
try {
|
||||
settingsLog.info('=== SETTINGS PAGE INITIALIZING ===');
|
||||
await Promise.all([
|
||||
this.loadDisplaySettings(),
|
||||
this.loadLogSettings(),
|
||||
this.loadShippingSettings()
|
||||
]);
|
||||
@@ -54,11 +58,52 @@ function adminSettings() {
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
await Promise.all([
|
||||
this.loadDisplaySettings(),
|
||||
this.loadLogSettings(),
|
||||
this.loadShippingSettings()
|
||||
]);
|
||||
},
|
||||
|
||||
async loadDisplaySettings() {
|
||||
try {
|
||||
const data = await apiClient.get('/admin/settings/display/rows-per-page');
|
||||
this.displaySettings.rows_per_page = data.rows_per_page || 20;
|
||||
settingsLog.info('Display settings loaded:', this.displaySettings);
|
||||
} catch (error) {
|
||||
settingsLog.error('Failed to load display settings:', error);
|
||||
// Use default value on error
|
||||
this.displaySettings.rows_per_page = 20;
|
||||
}
|
||||
},
|
||||
|
||||
async saveDisplaySettings() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
try {
|
||||
const data = await apiClient.put(`/admin/settings/display/rows-per-page?rows=${this.displaySettings.rows_per_page}`);
|
||||
this.successMessage = data.message || 'Display settings saved successfully';
|
||||
|
||||
// Clear the cached platform settings so pages pick up the new value
|
||||
if (window.PlatformSettings) {
|
||||
window.PlatformSettings.clearCache();
|
||||
}
|
||||
|
||||
// Auto-hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.successMessage = null;
|
||||
}, 5000);
|
||||
|
||||
settingsLog.info('Display settings saved successfully');
|
||||
} catch (error) {
|
||||
settingsLog.error('Failed to save display settings:', error);
|
||||
this.error = error.response?.data?.detail || 'Failed to save display settings';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadLogSettings() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -28,7 +28,7 @@ function adminUsers() {
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
@@ -36,17 +36,22 @@ function adminUsers() {
|
||||
// Initialization
|
||||
async init() {
|
||||
usersLog.info('=== USERS PAGE INITIALIZING ===');
|
||||
|
||||
|
||||
// Prevent multiple initializations
|
||||
if (window._usersInitialized) {
|
||||
usersLog.warn('Users page already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._usersInitialized = true;
|
||||
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
await this.loadUsers();
|
||||
await this.loadStats();
|
||||
|
||||
|
||||
usersLog.info('=== USERS PAGE INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
|
||||
@@ -43,13 +43,16 @@ function adminVendorProducts() {
|
||||
is_featured: ''
|
||||
},
|
||||
|
||||
// Available vendors for filter dropdown
|
||||
vendors: [],
|
||||
// Selected vendor (for prominent display and filtering)
|
||||
selectedVendor: null,
|
||||
|
||||
// Tom Select instance
|
||||
vendorSelectInstance: null,
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
@@ -121,22 +124,162 @@ function adminVendorProducts() {
|
||||
}
|
||||
window._adminVendorProductsInitialized = true;
|
||||
|
||||
// Load data in parallel
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadVendors(),
|
||||
this.loadProducts()
|
||||
]);
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// Initialize Tom Select for vendor filter
|
||||
this.initVendorSelect();
|
||||
|
||||
// Check localStorage for saved vendor
|
||||
const savedVendorId = localStorage.getItem('vendor_products_selected_vendor_id');
|
||||
if (savedVendorId) {
|
||||
adminVendorProductsLog.info('Restoring saved vendor:', savedVendorId);
|
||||
// Restore vendor after a short delay to ensure TomSelect is ready
|
||||
setTimeout(async () => {
|
||||
await this.restoreSavedVendor(parseInt(savedVendorId));
|
||||
}, 200);
|
||||
// Load stats but not products (restoreSavedVendor will do that)
|
||||
await this.loadStats();
|
||||
} else {
|
||||
// No saved vendor - load all data including unfiltered products
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadProducts()
|
||||
]);
|
||||
}
|
||||
|
||||
adminVendorProductsLog.info('Vendor Products initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore saved vendor from localStorage
|
||||
*/
|
||||
async restoreSavedVendor(vendorId) {
|
||||
try {
|
||||
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
|
||||
if (this.vendorSelectInstance && vendor) {
|
||||
// Add the vendor as an option and select it
|
||||
this.vendorSelectInstance.addOption({
|
||||
id: vendor.id,
|
||||
name: vendor.name,
|
||||
vendor_code: vendor.vendor_code
|
||||
});
|
||||
this.vendorSelectInstance.setValue(vendor.id, true);
|
||||
|
||||
// Set the filter state
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_id = vendor.id;
|
||||
|
||||
adminVendorProductsLog.info('Restored vendor:', vendor.name);
|
||||
|
||||
// Load products with the vendor filter applied
|
||||
await this.loadProducts();
|
||||
}
|
||||
} catch (error) {
|
||||
adminVendorProductsLog.warn('Failed to restore saved vendor, clearing localStorage:', error);
|
||||
localStorage.removeItem('vendor_products_selected_vendor_id');
|
||||
// Load unfiltered products as fallback
|
||||
await this.loadProducts();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize Tom Select for vendor autocomplete
|
||||
*/
|
||||
initVendorSelect() {
|
||||
const selectEl = this.$refs.vendorSelect;
|
||||
if (!selectEl) {
|
||||
adminVendorProductsLog.warn('Vendor select element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for Tom Select to be available
|
||||
if (typeof TomSelect === 'undefined') {
|
||||
adminVendorProductsLog.warn('TomSelect not loaded, retrying in 100ms');
|
||||
setTimeout(() => this.initVendorSelect(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.vendorSelectInstance = new TomSelect(selectEl, {
|
||||
valueField: 'id',
|
||||
labelField: 'name',
|
||||
searchField: ['name', 'vendor_code'],
|
||||
placeholder: 'Filter by vendor...',
|
||||
allowEmptyOption: true,
|
||||
load: async (query, callback) => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendors', {
|
||||
search: query,
|
||||
limit: 50
|
||||
});
|
||||
callback(response.vendors || []);
|
||||
} catch (error) {
|
||||
adminVendorProductsLog.error('Failed to search vendors:', error);
|
||||
callback([]);
|
||||
}
|
||||
},
|
||||
render: {
|
||||
option: (data, escape) => {
|
||||
return `<div class="flex items-center justify-between py-1">
|
||||
<span>${escape(data.name)}</span>
|
||||
<span class="text-xs text-gray-400 font-mono">${escape(data.vendor_code || '')}</span>
|
||||
</div>`;
|
||||
},
|
||||
item: (data, escape) => {
|
||||
return `<div>${escape(data.name)}</div>`;
|
||||
}
|
||||
},
|
||||
onChange: (value) => {
|
||||
if (value) {
|
||||
const vendor = this.vendorSelectInstance.options[value];
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_id = value;
|
||||
// Save to localStorage
|
||||
localStorage.setItem('vendor_products_selected_vendor_id', value.toString());
|
||||
} else {
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_id = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('vendor_products_selected_vendor_id');
|
||||
}
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
this.loadStats();
|
||||
}
|
||||
});
|
||||
|
||||
adminVendorProductsLog.info('Vendor select initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear vendor filter
|
||||
*/
|
||||
clearVendorFilter() {
|
||||
if (this.vendorSelectInstance) {
|
||||
this.vendorSelectInstance.clear();
|
||||
}
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_id = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('vendor_products_selected_vendor_id');
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
this.loadStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load product statistics
|
||||
*/
|
||||
async loadStats() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendor-products/stats');
|
||||
const params = new URLSearchParams();
|
||||
if (this.filters.vendor_id) {
|
||||
params.append('vendor_id', this.filters.vendor_id);
|
||||
}
|
||||
const url = params.toString() ? `/admin/vendor-products/stats?${params}` : '/admin/vendor-products/stats';
|
||||
const response = await apiClient.get(url);
|
||||
this.stats = response;
|
||||
adminVendorProductsLog.info('Loaded stats:', this.stats);
|
||||
} catch (error) {
|
||||
@@ -144,19 +287,6 @@ function adminVendorProducts() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load available vendors for filter
|
||||
*/
|
||||
async loadVendors() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendor-products/vendors');
|
||||
this.vendors = response.vendors || [];
|
||||
adminVendorProductsLog.info('Loaded vendors:', this.vendors.length);
|
||||
} catch (error) {
|
||||
adminVendorProductsLog.error('Failed to load vendors:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load products with filtering and pagination
|
||||
*/
|
||||
|
||||
@@ -24,6 +24,13 @@ function adminVendorThemes() {
|
||||
vendors: [],
|
||||
selectedVendorCode: '',
|
||||
|
||||
// Selected vendor for filter (Tom Select)
|
||||
selectedVendor: null,
|
||||
vendorSelector: null,
|
||||
|
||||
// Search/filter
|
||||
searchQuery: '',
|
||||
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._adminVendorThemesInitialized) {
|
||||
@@ -31,13 +38,86 @@ function adminVendorThemes() {
|
||||
}
|
||||
window._adminVendorThemesInitialized = true;
|
||||
|
||||
// Call parent init first
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
vendorThemesLog.info('Vendor Themes init() called');
|
||||
|
||||
// Initialize vendor selector (Tom Select)
|
||||
this.$nextTick(() => {
|
||||
this.initVendorSelector();
|
||||
});
|
||||
|
||||
// Check localStorage for saved vendor
|
||||
const savedVendorId = localStorage.getItem('vendor_themes_selected_vendor_id');
|
||||
if (savedVendorId) {
|
||||
vendorThemesLog.info('Restoring saved vendor:', savedVendorId);
|
||||
await this.loadVendors();
|
||||
// Restore vendor after vendors are loaded
|
||||
setTimeout(async () => {
|
||||
await this.restoreSavedVendor(parseInt(savedVendorId));
|
||||
}, 200);
|
||||
} else {
|
||||
await this.loadVendors();
|
||||
}
|
||||
|
||||
await this.loadVendors();
|
||||
vendorThemesLog.info('Vendor Themes initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore saved vendor from localStorage
|
||||
*/
|
||||
async restoreSavedVendor(vendorId) {
|
||||
try {
|
||||
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
|
||||
if (this.vendorSelector && vendor) {
|
||||
// Use the vendor selector's setValue method
|
||||
this.vendorSelector.setValue(vendor.id, vendor);
|
||||
|
||||
// Set the filter state
|
||||
this.selectedVendor = vendor;
|
||||
|
||||
vendorThemesLog.info('Restored vendor:', vendor.name);
|
||||
}
|
||||
} catch (error) {
|
||||
vendorThemesLog.warn('Failed to restore saved vendor, clearing localStorage:', error);
|
||||
localStorage.removeItem('vendor_themes_selected_vendor_id');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize vendor selector with Tom Select
|
||||
*/
|
||||
initVendorSelector() {
|
||||
if (!this.$refs.vendorSelect) {
|
||||
vendorThemesLog.warn('Vendor select element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.vendorSelector = initVendorSelector(this.$refs.vendorSelect, {
|
||||
placeholder: 'Search vendor...',
|
||||
onSelect: (vendor) => {
|
||||
vendorThemesLog.info('Vendor selected:', vendor);
|
||||
this.selectedVendor = vendor;
|
||||
// Save to localStorage
|
||||
localStorage.setItem('vendor_themes_selected_vendor_id', vendor.id.toString());
|
||||
},
|
||||
onClear: () => {
|
||||
vendorThemesLog.info('Vendor filter cleared');
|
||||
this.selectedVendor = null;
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('vendor_themes_selected_vendor_id');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear vendor filter
|
||||
*/
|
||||
clearVendorFilter() {
|
||||
if (this.vendorSelector) {
|
||||
this.vendorSelector.clear();
|
||||
}
|
||||
this.selectedVendor = null;
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('vendor_themes_selected_vendor_id');
|
||||
},
|
||||
|
||||
async loadVendors() {
|
||||
@@ -56,6 +136,28 @@ function adminVendorThemes() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Computed: Filtered vendors based on search and selected vendor
|
||||
*/
|
||||
get filteredVendors() {
|
||||
let filtered = this.vendors;
|
||||
|
||||
// If a vendor is selected via Tom Select, show only that vendor
|
||||
if (this.selectedVendor) {
|
||||
filtered = this.vendors.filter(v => v.id === this.selectedVendor.id);
|
||||
}
|
||||
// Otherwise filter by search query
|
||||
else if (this.searchQuery) {
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
filtered = this.vendors.filter(v =>
|
||||
v.name.toLowerCase().includes(query) ||
|
||||
(v.vendor_code && v.vendor_code.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
|
||||
navigateToTheme() {
|
||||
if (!this.selectedVendorCode) {
|
||||
return;
|
||||
|
||||
@@ -35,7 +35,7 @@ function adminVendors() {
|
||||
// Pagination state (server-side)
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
@@ -51,6 +51,11 @@ function adminVendors() {
|
||||
}
|
||||
window._vendorsInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
vendorsLog.group('Loading vendors data');
|
||||
await this.loadVendors();
|
||||
await this.loadStats();
|
||||
|
||||
@@ -187,83 +187,8 @@ const Utils = {
|
||||
// ============================================================================
|
||||
// Platform Settings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Platform settings cache and loader
|
||||
*/
|
||||
const PlatformSettings = {
|
||||
_settings: null,
|
||||
_loading: false,
|
||||
_loadPromise: null,
|
||||
|
||||
/**
|
||||
* Load platform display settings from API
|
||||
* @returns {Promise<Object>} Platform settings
|
||||
*/
|
||||
async load() {
|
||||
// Return cached settings if available
|
||||
if (this._settings) {
|
||||
return this._settings;
|
||||
}
|
||||
|
||||
// Return existing promise if already loading
|
||||
if (this._loadPromise) {
|
||||
return this._loadPromise;
|
||||
}
|
||||
|
||||
this._loading = true;
|
||||
this._loadPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/settings/display/public');
|
||||
if (response.ok) {
|
||||
this._settings = await response.json();
|
||||
} else {
|
||||
// Default settings
|
||||
this._settings = { rows_per_page: 20 };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load platform settings, using defaults:', error);
|
||||
this._settings = { rows_per_page: 20 };
|
||||
}
|
||||
this._loading = false;
|
||||
return this._settings;
|
||||
})();
|
||||
|
||||
return this._loadPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get rows per page setting
|
||||
* @returns {number} Rows per page (default: 20)
|
||||
*/
|
||||
getRowsPerPage() {
|
||||
return this._settings?.rows_per_page || 20;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get rows per page synchronously (returns cached or default)
|
||||
* @returns {number} Rows per page
|
||||
*/
|
||||
get rowsPerPage() {
|
||||
return this._settings?.rows_per_page || 20;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset cached settings (call after updating settings)
|
||||
*/
|
||||
reset() {
|
||||
this._settings = null;
|
||||
this._loadPromise = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Load settings on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
PlatformSettings.load();
|
||||
});
|
||||
|
||||
// Make available globally
|
||||
window.PlatformSettings = PlatformSettings;
|
||||
// Note: PlatformSettings is defined in init-alpine.js for admin pages
|
||||
window.Utils = Utils;
|
||||
|
||||
// Export for modules
|
||||
|
||||
@@ -66,7 +66,7 @@ function waitForTomSelect(callback, maxRetries = 20, retryDelay = 100) {
|
||||
* @param {number} options.minChars - Minimum characters before search (default: 2)
|
||||
* @param {number} options.maxOptions - Maximum options to show (default: 50)
|
||||
* @param {string} options.placeholder - Placeholder text
|
||||
* @param {string} options.apiEndpoint - API endpoint for search (default: '/api/v1/admin/vendors')
|
||||
* @param {string} options.apiEndpoint - API endpoint for search (default: '/admin/vendors')
|
||||
* @returns {Object} Controller object with setValue() and clear() methods
|
||||
*/
|
||||
function initVendorSelector(selectElement, options = {}) {
|
||||
@@ -79,7 +79,7 @@ function initVendorSelector(selectElement, options = {}) {
|
||||
minChars: options.minChars || 2,
|
||||
maxOptions: options.maxOptions || 50,
|
||||
placeholder: options.placeholder || selectElement.getAttribute('placeholder') || 'Search vendor by name or code...',
|
||||
apiEndpoint: options.apiEndpoint || '/api/v1/admin/vendors',
|
||||
apiEndpoint: options.apiEndpoint || '/admin/vendors', // Note: apiClient adds /api/v1 prefix
|
||||
onSelect: options.onSelect || (() => {}),
|
||||
onClear: options.onClear || (() => {})
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user