feat(admin): separate platform CRUD from CMS, add entity selector macro
Some checks failed
CI / ruff (push) Successful in 11s
CI / docs (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

- Move platforms menu from CMS to Platform Admin section with create/edit
- Add platform create page, API endpoint, and service method
- Remove CMS-specific content from platform list and detail pages
- Create shared entity_selector + entity_selected_badge Jinja macros
- Create entity-selector.js generalizing store-selector.js for any entity
- Add Tom Select merchant filter to stores page with localStorage persistence
- Migrate store-products page to use shared macros (remove 53 lines of duped CSS)
- Fix broken icons: puzzle→puzzle-piece, building-storefront→store, language→translate, server→cube

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 22:40:15 +01:00
parent fa758b7e31
commit 45260b6b82
22 changed files with 943 additions and 267 deletions

View File

@@ -0,0 +1,262 @@
// static/shared/js/entity-selector.js
/**
* Shared Entity Selector Module
* ==============================
* A configurable Tom Select-based autocomplete for any entity type.
* Generalizes store-selector.js to work with stores, merchants, platforms, etc.
*
* Usage:
* const selector = initEntitySelector(this.$refs.merchantSelect, {
* apiEndpoint: '/admin/merchants',
* responseKey: 'merchants',
* searchFields: ['name'],
* valueField: 'id',
* labelField: 'name',
* codeField: null,
* onSelect: (entity) => this.onMerchantSelected(entity),
* onClear: () => this.onMerchantCleared()
* });
*
* selector.setValue(merchantId, merchantData);
* selector.clear();
*/
const entitySelectorLog = window.LogConfig?.loggers?.entitySelector ||
window.LogConfig?.createLogger?.('entitySelector', false) ||
{ info: console.log, warn: console.warn, error: console.error }; // noqa: js-001 - fallback if logger not ready
/**
* Wait for Tom Select to be available
*/
function waitForTomSelectReady(callback, maxRetries = 20, retryDelay = 100) {
let retries = 0;
function check() {
if (typeof TomSelect !== 'undefined') {
callback();
} else if (retries < maxRetries) {
retries++;
setTimeout(check, retryDelay);
} else {
entitySelectorLog.error('TomSelect not available after maximum retries');
}
}
check();
}
/**
* Initialize an entity selector on the given element
* @param {HTMLElement} selectElement - The select element to enhance
* @param {Object} options - Configuration options
* @param {string} options.apiEndpoint - API endpoint for search (e.g. '/admin/merchants')
* @param {string} options.responseKey - Key in API response containing the array (e.g. 'merchants')
* @param {string} options.valueField - Field used as option value (default: 'id')
* @param {string} options.labelField - Field used as display label (default: 'name')
* @param {string[]} options.searchFields - Fields to search on (default: ['name'])
* @param {string|null} options.codeField - Optional secondary code field for display (e.g. 'store_code')
* @param {Function} options.onSelect - Callback when entity is selected (receives entity object)
* @param {Function} options.onClear - Callback when selection is cleared
* @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.noResultsText - Text when no results (default: 'No results found')
* @returns {Object} Controller object with setValue(), clear(), getInstance(), destroy()
*/
function initEntitySelector(selectElement, options = {}) {
if (!selectElement) {
entitySelectorLog.error('Entity selector element not provided');
return null;
}
const config = {
apiEndpoint: options.apiEndpoint || '/admin/stores',
responseKey: options.responseKey || 'stores',
valueField: options.valueField || 'id',
labelField: options.labelField || 'name',
searchFields: options.searchFields || ['name'],
codeField: options.codeField || null,
minChars: options.minChars || 2,
maxOptions: options.maxOptions || 50,
placeholder: options.placeholder || selectElement.getAttribute('placeholder') || 'Search...',
noResultsText: options.noResultsText || 'No results found',
onSelect: options.onSelect || (() => {}),
onClear: options.onClear || (() => {})
};
let tomSelectInstance = null;
const controller = {
/**
* Set the selected entity by ID
* @param {*} entityId - Entity ID to select
* @param {Object} entityData - Optional entity data to avoid API call
*/
setValue: async function(entityId, entityData = null) {
if (!tomSelectInstance) return;
if (entityData) {
const optionData = {};
optionData[config.valueField] = entityData[config.valueField] || entityId;
optionData[config.labelField] = entityData[config.labelField];
if (config.codeField) {
optionData[config.codeField] = entityData[config.codeField];
}
tomSelectInstance.addOption(optionData);
tomSelectInstance.setValue(optionData[config.valueField], true);
} else if (entityId) {
try {
const response = await apiClient.get(`${config.apiEndpoint}/${entityId}`);
const optionData = {};
optionData[config.valueField] = response[config.valueField] || entityId;
optionData[config.labelField] = response[config.labelField];
if (config.codeField) {
optionData[config.codeField] = response[config.codeField];
}
tomSelectInstance.addOption(optionData);
tomSelectInstance.setValue(optionData[config.valueField], true);
} catch (error) {
entitySelectorLog.error('Failed to load entity:', error);
}
}
},
clear: function() {
if (tomSelectInstance) {
tomSelectInstance.clear();
}
},
getInstance: function() {
return tomSelectInstance;
},
destroy: function() {
if (tomSelectInstance) {
tomSelectInstance.destroy();
tomSelectInstance = null;
}
}
};
// Initialize Tom Select when available
waitForTomSelectReady(() => {
entitySelectorLog.info(`Initializing entity selector (${config.responseKey})`);
// Build render functions
const render = {
option: function(data, escape) {
if (config.codeField && data[config.codeField]) {
return `<div class="flex justify-between items-center py-1">
<span class="font-medium">${escape(data[config.labelField])}</span>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2 font-mono">${escape(data[config.codeField])}</span>
</div>`;
}
return `<div class="py-1"><span class="font-medium">${escape(data[config.labelField])}</span></div>`;
},
item: function(data, escape) {
if (config.codeField && data[config.codeField]) {
return `<div class="flex items-center gap-2">
<span>${escape(data[config.labelField])}</span>
<span class="text-xs text-gray-400 font-mono">(${escape(data[config.codeField])})</span>
</div>`;
}
return `<div>${escape(data[config.labelField])}</div>`;
},
no_results: function() {
return `<div class="no-results py-2 px-3 text-gray-500 dark:text-gray-400">${config.noResultsText}</div>`;
},
loading: function() {
return '<div class="loading py-2 px-3 text-gray-500 dark:text-gray-400">Searching...</div>';
}
};
tomSelectInstance = new TomSelect(selectElement, {
valueField: config.valueField,
labelField: config.labelField,
searchField: config.searchFields,
maxOptions: config.maxOptions,
placeholder: config.placeholder,
load: async function(query, callback) {
if (query.length < config.minChars) {
callback([]);
return;
}
try {
const sep = config.apiEndpoint.includes('?') ? '&' : '?';
const response = await apiClient.get(
`${config.apiEndpoint}${sep}search=${encodeURIComponent(query)}&limit=${config.maxOptions}`
);
const items = (response[config.responseKey] || []).map(v => {
const item = {};
item[config.valueField] = v[config.valueField];
item[config.labelField] = v[config.labelField];
if (config.codeField) {
item[config.codeField] = v[config.codeField];
}
return item;
});
entitySelectorLog.info(`Found ${items.length} ${config.responseKey} for "${query}"`);
callback(items);
} catch (error) {
entitySelectorLog.error(`${config.responseKey} search failed:`, error);
callback([]);
}
},
render: render,
onChange: function(value) {
if (value) {
const selectedOption = this.options[value];
if (selectedOption) {
entitySelectorLog.info(`${config.responseKey} selected:`, selectedOption);
const entity = {};
entity[config.valueField] = config.valueField === 'id' ? parseInt(value) : value;
entity[config.labelField] = selectedOption[config.labelField];
if (config.codeField) {
entity[config.codeField] = selectedOption[config.codeField];
}
config.onSelect(entity);
}
} else {
entitySelectorLog.info(`${config.responseKey} selection cleared`);
config.onClear();
}
},
loadThrottle: 150,
closeAfterSelect: true,
hideSelected: false,
persist: true,
createOnBlur: false,
create: false
});
entitySelectorLog.info(`Entity selector (${config.responseKey}) initialized`);
});
return controller;
}
// Convenience wrapper for store selector (backward compatible)
function initStoreSelectorFromEntity(selectElement, options = {}) {
return initEntitySelector(selectElement, {
apiEndpoint: options.apiEndpoint || '/admin/stores',
responseKey: 'stores',
valueField: 'id',
labelField: 'name',
searchFields: ['name', 'store_code'],
codeField: 'store_code',
placeholder: options.placeholder || 'Search store by name or code...',
noResultsText: 'No stores found',
...options
});
}
// Export to window
window.initEntitySelector = initEntitySelector;