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:
2025-12-22 22:39:34 +01:00
parent 1274135091
commit 6f8434f200
27 changed files with 1966 additions and 303 deletions

View File

@@ -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)
# ============================================================================

View File

@@ -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 %}

View File

@@ -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"

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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 %}

View File

@@ -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 %}

View 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

View File

@@ -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:

View File

@@ -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...")

View File

@@ -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;
},

View File

@@ -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();

View File

@@ -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);
},
/**

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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();
},

View File

@@ -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)
*/

View File

@@ -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';

View File

@@ -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();

View File

@@ -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;

View File

@@ -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 ===');
},

View File

@@ -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
*/

View File

@@ -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;

View File

@@ -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();

View File

@@ -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

View File

@@ -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 || (() => {})
};