✨ feat(crm): Améliore l'interface et la recherche de produits/options
Ce commit modernise l'interface utilisateur pour la recherche et la sélection de produits et d'options. Il améliore l'apparence
visuelle, l'ergonomie et la réactivité, en utilisant des composants plus modernes et des animations plus fluides. Les
fonctionnalités de recherche ont été optimisées pour une meilleure expérience utilisateur. Ajout de nouvelles classes
'SearchProductDevis' et 'SearchOptionsDevis' pour la gestion des options dans Devis.
```
285 lines
15 KiB
JavaScript
285 lines
15 KiB
JavaScript
export class SearchOptions extends HTMLButtonElement {
|
|
constructor() {
|
|
super();
|
|
this.allOptions = [];
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.addEventListener('click', () => this.openModal());
|
|
}
|
|
|
|
async openModal() {
|
|
let modal = document.getElementById('modal-search-options');
|
|
if (!modal) {
|
|
modal = this.createModalStructure();
|
|
document.body.appendChild(modal);
|
|
this.setupSearchEvent(modal);
|
|
}
|
|
|
|
modal.classList.remove('hidden');
|
|
const container = modal.querySelector('#results-container-options');
|
|
const searchInput = modal.querySelector('#modal-search-input-options');
|
|
|
|
searchInput.value = '';
|
|
container.innerHTML = '<div class="text-slate-400 p-8 text-center animate-pulse tracking-[0.2em] text-[10px] uppercase font-black">Chargement du catalogue options...</div>';
|
|
|
|
try {
|
|
const response = await fetch('/crm/options/json');
|
|
this.allOptions = await response.json();
|
|
this.renderOptions(this.allOptions, container, modal);
|
|
searchInput.focus();
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="text-rose-500 p-8 text-center font-bold uppercase text-xs">Erreur de liaison catalogue.</div>';
|
|
}
|
|
}
|
|
|
|
createModalStructure() {
|
|
const div = document.createElement('div');
|
|
div.id = 'modal-search-options';
|
|
div.className = 'fixed inset-0 z-[100] flex items-center justify-center p-4 bg-[#0f172a]/95 backdrop-blur-xl hidden animate-in fade-in duration-300';
|
|
div.innerHTML = `
|
|
<div class="bg-[#1e293b] border border-slate-800 w-full max-w-2xl max-h-[85vh] rounded-[2.5rem] shadow-[0_0_50px_rgba(0,0,0,0.5)] overflow-hidden flex flex-col scale-in-center">
|
|
<div class="p-8 border-b border-slate-800 bg-slate-900/30 space-y-6">
|
|
<div class="flex justify-between items-center">
|
|
<h3 class="text-white font-black uppercase tracking-[0.3em] text-[11px] flex items-center">
|
|
<span class="w-2.5 h-2.5 bg-blue-600 rounded-full mr-3 shadow-[0_0_10px_rgba(37,99,235,0.5)] animate-pulse"></span>
|
|
Catalogue Options
|
|
</h3>
|
|
<button type="button" onclick="this.closest('#modal-search-options').classList.add('hidden')" class="p-2 text-slate-500 hover:text-white transition-colors">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="relative">
|
|
<input type="text" id="modal-search-input-options" placeholder="Rechercher une option..."
|
|
class="w-full bg-[#0f172a] border border-slate-700 rounded-2xl py-4 pl-12 pr-5 text-white text-xs font-bold tracking-wide focus:ring-2 focus:ring-blue-600/20 focus:border-blue-600 transition-all placeholder:text-slate-600 outline-none">
|
|
<svg class="w-5 h-5 absolute left-4 top-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="results-container-options" class="overflow-y-auto p-6 space-y-3 custom-scrollbar flex-grow min-h-[350px] bg-[#1e293b]"></div>
|
|
|
|
<div class="p-4 bg-slate-900/50 border-t border-slate-800 text-center">
|
|
<p class="text-[9px] text-slate-500 font-black uppercase tracking-widest">ESC pour quitter • Sélectionner pour ajouter</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
setupSearchEvent(modal) {
|
|
const input = modal.querySelector('#modal-search-input-options');
|
|
const container = modal.querySelector('#results-container-options');
|
|
|
|
input.oninput = () => {
|
|
const query = input.value.toLowerCase().trim();
|
|
const filtered = this.allOptions.filter(o => o.name.toLowerCase().includes(query));
|
|
this.renderOptions(filtered, container, modal);
|
|
};
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') modal.classList.add('hidden');
|
|
});
|
|
}
|
|
|
|
renderOptions(options, container, modal) {
|
|
container.innerHTML = '';
|
|
|
|
if (options.length === 0) {
|
|
container.innerHTML = '<div class="text-slate-600 p-12 text-center text-[10px] font-black uppercase tracking-[0.2em]">Aucun résultat correspondant</div>';
|
|
return;
|
|
}
|
|
|
|
options.forEach(option => {
|
|
const card = document.createElement('div');
|
|
card.className = 'flex items-center gap-5 p-4 bg-slate-900/40 border border-slate-800 rounded-2xl hover:bg-blue-600/5 hover:border-blue-600/30 transition-all cursor-pointer group animate-in fade-in slide-in-from-bottom-1';
|
|
|
|
const imgHtml = option.image
|
|
? `<img src="${option.image}" class="w-14 h-14 rounded-xl object-cover shadow-2xl border border-slate-700 group-hover:scale-105 transition-transform">`
|
|
: `<div class="w-14 h-14 bg-[#0f172a] rounded-xl flex items-center justify-center text-[8px] text-slate-600 font-black border border-slate-800 uppercase tracking-tighter">Pas d'image</div>`;
|
|
|
|
card.innerHTML = `
|
|
${imgHtml}
|
|
<div class="flex-grow">
|
|
<div class="text-slate-100 font-bold text-sm mb-1">${option.name}</div>
|
|
<div class="flex gap-4">
|
|
<span class="text-blue-500 text-[10px] font-black uppercase tracking-widest">Prix HT: ${option.price}€</span>
|
|
</div>
|
|
</div>
|
|
<div class="w-10 h-10 rounded-xl bg-slate-800 group-hover:bg-blue-600 text-slate-500 group-hover:text-white flex items-center justify-center transition-all shadow-lg">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
|
</div>
|
|
`;
|
|
|
|
card.onclick = () => {
|
|
this.fillOptionLine(option);
|
|
modal.classList.add('hidden');
|
|
};
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
fillOptionLine(option) {
|
|
const row = this.closest('.form-repeater__row');
|
|
if (row) {
|
|
const nameInput = row.querySelector('input[name*="[name]"]');
|
|
const priceInput = row.querySelector('input[name*="[priceHt]"]');
|
|
if(nameInput) nameInput.value = option.name;
|
|
if(priceInput) priceInput.value = option.price;
|
|
|
|
const fieldset = row.querySelector('fieldset');
|
|
if (fieldset) {
|
|
fieldset.classList.add('border-blue-600/40', 'bg-blue-600/5');
|
|
setTimeout(() => fieldset.classList.remove('border-blue-600/40', 'bg-blue-600/5'), 800);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* RECHERCHE PRODUITS (SearchProduct)
|
|
*/
|
|
export class SearchProduct extends HTMLButtonElement {
|
|
constructor() {
|
|
super();
|
|
this.allProducts = [];
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.addEventListener('click', () => this.openModal());
|
|
}
|
|
|
|
async openModal() {
|
|
let modal = document.getElementById('modal-search-product');
|
|
if (!modal) {
|
|
modal = this.createModalStructure();
|
|
document.body.appendChild(modal);
|
|
this.setupSearchEvent(modal);
|
|
}
|
|
|
|
modal.classList.remove('hidden');
|
|
const container = modal.querySelector('#results-container');
|
|
const searchInput = modal.querySelector('#modal-search-input');
|
|
|
|
searchInput.value = '';
|
|
container.innerHTML = '<div class="text-slate-400 p-8 text-center animate-pulse tracking-[0.2em] text-[10px] uppercase font-black">Mise à jour du catalogue produits...</div>';
|
|
|
|
try {
|
|
const response = await fetch('/crm/products/json');
|
|
this.allProducts = await response.json();
|
|
this.renderProducts(this.allProducts, container, modal);
|
|
searchInput.focus();
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="text-rose-500 p-8 text-center font-bold uppercase text-xs">Échec de synchronisation.</div>';
|
|
}
|
|
}
|
|
|
|
createModalStructure() {
|
|
const div = document.createElement('div');
|
|
div.id = 'modal-search-product';
|
|
div.className = 'fixed inset-0 z-[100] flex items-center justify-center p-4 bg-[#0f172a]/95 backdrop-blur-xl hidden animate-in fade-in duration-300';
|
|
div.innerHTML = `
|
|
<div class="bg-[#1e293b] border border-slate-800 w-full max-w-2xl max-h-[85vh] rounded-[2.5rem] shadow-[0_0_50px_rgba(0,0,0,0.5)] overflow-hidden flex flex-col scale-in-center">
|
|
<div class="p-8 border-b border-slate-800 bg-slate-900/30 space-y-6">
|
|
<div class="flex justify-between items-center">
|
|
<h3 class="text-white font-black uppercase tracking-[0.3em] text-[11px] flex items-center">
|
|
<span class="w-2.5 h-2.5 bg-purple-600 rounded-full mr-3 shadow-[0_0_10px_rgba(147,51,234,0.5)] animate-pulse"></span>
|
|
Catalogue Location
|
|
</h3>
|
|
<button type="button" onclick="this.closest('#modal-search-product').classList.add('hidden')" class="p-2 text-slate-500 hover:text-white transition-colors">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="relative">
|
|
<input type="text" id="modal-search-input" placeholder="Rechercher un produit..."
|
|
class="w-full bg-[#0f172a] border border-slate-700 rounded-2xl py-4 pl-12 pr-5 text-white text-xs font-bold tracking-wide focus:ring-2 focus:ring-purple-600/20 focus:border-purple-600 transition-all placeholder:text-slate-600 outline-none">
|
|
<svg class="w-5 h-5 absolute left-4 top-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="results-container" class="overflow-y-auto p-6 space-y-3 custom-scrollbar flex-grow min-h-[350px] bg-[#1e293b]"></div>
|
|
|
|
<div class="p-4 bg-slate-900/50 border-t border-slate-800 text-center">
|
|
<p class="text-[9px] text-slate-500 font-black uppercase tracking-widest">ESC pour quitter • Sélectionner pour ajouter</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
setupSearchEvent(modal) {
|
|
const input = modal.querySelector('#modal-search-input');
|
|
const container = modal.querySelector('#results-container');
|
|
|
|
input.oninput = () => {
|
|
const query = input.value.toLowerCase().trim();
|
|
const filtered = this.allProducts.filter(p => p.name.toLowerCase().includes(query));
|
|
this.renderProducts(filtered, container, modal);
|
|
};
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') modal.classList.add('hidden');
|
|
});
|
|
}
|
|
|
|
renderProducts(products, container, modal) {
|
|
container.innerHTML = '';
|
|
|
|
if (products.length === 0) {
|
|
container.innerHTML = '<div class="text-slate-600 p-12 text-center text-[10px] font-black uppercase tracking-[0.2em]">Aucun produit trouvé</div>';
|
|
return;
|
|
}
|
|
|
|
products.forEach(product => {
|
|
const card = document.createElement('div');
|
|
card.className = 'flex items-center gap-5 p-4 bg-slate-900/40 border border-slate-800 rounded-2xl hover:bg-purple-600/5 hover:border-purple-600/30 transition-all cursor-pointer group animate-in fade-in slide-in-from-bottom-1';
|
|
|
|
const imgHtml = product.image
|
|
? `<img src="${product.image}" class="w-14 h-14 rounded-xl object-cover shadow-2xl border border-slate-700 group-hover:scale-105 transition-transform">`
|
|
: `<div class="w-14 h-14 bg-[#0f172a] rounded-xl flex items-center justify-center text-[8px] text-slate-600 font-black border border-slate-800 uppercase tracking-tighter">Pas d'image</div>`;
|
|
|
|
card.innerHTML = `
|
|
${imgHtml}
|
|
<div class="flex-grow">
|
|
<div class="text-slate-100 font-bold text-sm mb-1">${product.name}</div>
|
|
<div class="flex gap-4">
|
|
<span class="text-purple-400 text-[10px] font-black tracking-widest uppercase">1J: ${product.price1day}€</span>
|
|
<span class="text-slate-500 text-[10px] font-black tracking-widest uppercase">Sup: ${product.priceSup}€</span>
|
|
<span class="text-amber-600 text-[10px] font-black tracking-widest uppercase">Caution: ${product.caution}€</span>
|
|
</div>
|
|
</div>
|
|
<div class="w-10 h-10 rounded-xl bg-slate-800 group-hover:bg-purple-600 text-slate-500 group-hover:text-white flex items-center justify-center transition-all shadow-lg">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
|
</div>
|
|
`;
|
|
|
|
card.onclick = () => {
|
|
this.fillFormLine(product);
|
|
modal.classList.add('hidden');
|
|
};
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
fillFormLine(product) {
|
|
const row = this.closest('.form-repeater__row');
|
|
if (row) {
|
|
row.querySelector('input[name*="[name]"]').value = product.name;
|
|
row.querySelector('input[name*="[priceHt1Day]"]').value = product.price1day;
|
|
row.querySelector('input[name*="[priceHtSupDay]"]').value = product.priceSup;
|
|
row.querySelector('input[name*="[caution]"]').value = product.caution;
|
|
|
|
const fieldset = row.querySelector('fieldset');
|
|
if (fieldset) {
|
|
fieldset.classList.add('border-emerald-600/40', 'bg-emerald-600/5');
|
|
setTimeout(() => fieldset.classList.remove('border-emerald-600/40', 'bg-emerald-600/5'), 800);
|
|
}
|
|
}
|
|
}
|
|
}
|