Files
Jo e410bfbc16
Deploy via Portainer / deploy (push) Successful in 0s
feat: color picker palette + filtre/tri/duplicate matières
2026-07-06 23:47:39 +02:00

352 lines
15 KiB
HTML

{% extends 'base.html' %}
{% block title %}Matières{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-boxes me-2" style="color:#8b5cf6"></i>Matières & Stock</h1>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse"
data-bs-target="#filterBar" aria-expanded="false" id="filterToggle">
<i class="bi bi-funnel me-1"></i>Filtres
</button>
<a href="{{ url_for('new_material') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
<i class="bi bi-plus-lg me-1"></i>Ajouter une matière
</a>
</div>
</div>
<!-- Barre de filtres collapsable -->
<div class="collapse mb-3" id="filterBar">
<div class="card card-body py-3">
<div class="row g-3 align-items-end">
<!-- Recherche texte -->
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Rechercher</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" id="searchInput" class="form-control" placeholder="Nom, marque…">
<button class="btn btn-outline-secondary" type="button" id="clearSearch" title="Effacer">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<!-- Filtre par type -->
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Type</label>
<div class="d-flex flex-wrap gap-1" id="typeFilters">
<button class="btn btn-sm btn-secondary active" data-type="all">Tous</button>
{% for t in types %}
<button class="btn btn-sm btn-outline-secondary" data-type="{{ t }}">{{ t }}</button>
{% endfor %}
</div>
</div>
<!-- Tri -->
<div class="col-md-3">
<label class="form-label small text-muted mb-1">Trier par</label>
<select id="sortSelect" class="form-select form-select-sm">
<option value="type-name">Type puis nom</option>
<option value="name">Nom (A→Z)</option>
<option value="price-asc">Prix/kg (croissant)</option>
<option value="price-desc">Prix/kg (décroissant)</option>
<option value="stock-asc">Stock (croissant)</option>
<option value="stock-desc">Stock (décroissant)</option>
<option value="usage-desc">Plus utilisé</option>
</select>
</div>
</div>
<!-- Résumé du filtre actif -->
<div class="mt-2 small text-muted" id="filterSummary"></div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
{% if materials %}
<table class="table table-hover mb-0 align-middle" id="materialsTable">
<thead class="table-light">
<tr>
<th data-col="name" class="sortable-th" style="cursor:pointer">
Nom <i class="bi bi-arrow-down-up text-muted small"></i>
</th>
<th data-col="type" class="sortable-th" style="cursor:pointer">
Type <i class="bi bi-arrow-down-up text-muted small"></i>
</th>
<th>Couleur</th>
<th data-col="price" class="sortable-th" style="cursor:pointer">
Prix/kg <i class="bi bi-arrow-down-up text-muted small"></i>
</th>
<th data-col="stock" class="sortable-th" style="cursor:pointer">
Stock <i class="bi bi-arrow-down-up text-muted small"></i>
</th>
<th>Commandes</th>
<th data-col="usage" class="sortable-th" style="cursor:pointer">
Utilisé total <i class="bi bi-arrow-down-up text-muted small"></i>
</th>
<th></th>
</tr>
</thead>
<tbody id="materialsBody">
{% for m in materials %}
{% set stock_pct = [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min %}
<tr class="mat-row"
data-name="{{ m.name|lower }}{% if m.brand %} {{ m.brand|lower }}{% endif %}"
data-type="{{ m.type }}"
data-price="{{ m.price_per_kg }}"
data-stock="{{ m.stock_g }}"
data-usage="{{ m.total_used_g }}"
data-jobs="{{ m.job_count }}">
<td class="fw-semibold">{{ m.name }}{% if m.brand %} <small class="text-muted">/ {{ m.brand }}</small>{% endif %}</td>
<td><span class="badge bg-secondary">{{ m.type }}</span></td>
<td>
{% if m.color %}
<span class="d-inline-flex align-items-center gap-1">
<span style="width:14px;height:14px;border-radius:50%;background:{{ m.color | css_color }};border:1px solid #ccc;display:inline-block"></span>
{{ m.color }}
</span>
{% else %}—{% endif %}
</td>
<td class="fw-semibold">{{ m.price_per_kg }} €</td>
<td style="min-width:140px">
{% if m.stock_g <= 0 %}
<span class="stock-empty fw-bold"><i class="bi bi-x-circle"></i> Rupture</span>
{% elif m.stock_g <= m.low_stock_threshold_g %}
<div class="d-flex align-items-center gap-2">
<span class="stock-low"><i class="bi bi-exclamation-triangle"></i> {{ m.stock_g|round(0)|int }} g</span>
</div>
{% else %}
<span class="stock-ok"><i class="bi bi-check-circle"></i> {{ m.stock_g|round(0)|int }} g</span>
{% endif %}
<div class="progress mt-1" style="height:4px">
<div class="progress-bar
{% if m.stock_g <= 0 %}bg-danger
{% elif m.stock_g <= m.low_stock_threshold_g %}bg-warning
{% else %}bg-success{% endif %}"
style="width:{{ stock_pct }}%">
</div>
</div>
</td>
<td>{{ m.job_count }}</td>
<td>{{ m.total_used_g }} g</td>
<td>
<div class="d-flex gap-1 flex-nowrap">
<button class="btn btn-sm btn-outline-success py-0" data-bs-toggle="modal"
data-bs-target="#restockModal{{ m.id }}" title="Réapprovisionner">
<i class="bi bi-plus-lg"></i>
</button>
<a href="{{ url_for('edit_material', id=m.id) }}" class="btn btn-sm btn-outline-secondary py-0" title="Modifier">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ url_for('duplicate_material', id=m.id) }}" style="display:inline">
<button class="btn btn-sm btn-outline-secondary py-0" title="Dupliquer">
<i class="bi bi-copy"></i>
</button>
</form>
<form method="POST" action="{{ url_for('delete_material', id=m.id) }}"
onsubmit="return confirm('Supprimer {{ m.name }} ?')">
<button class="btn btn-sm btn-outline-danger py-0" title="Supprimer"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
<!-- Modal Restock -->
<div class="modal fade" id="restockModal{{ m.id }}" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<form method="POST" action="{{ url_for('restock_material', id=m.id) }}">
<div class="modal-header">
<h5 class="modal-title">Réapprovisionner — {{ m.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Quantité à ajouter (g)</label>
<input type="number" name="add_g" class="form-control" step="1" min="1"
placeholder="ex: 1000 pour 1 kg" required>
</div>
<div class="mb-3">
<label class="form-label">Nouveau prix/kg (optionnel)</label>
<input type="number" name="new_price_per_kg" class="form-control"
step="0.01" placeholder="{{ m.price_per_kg }} (actuel)">
<div class="form-text">Laissez vide si le prix n'a pas changé.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Annuler</button>
<button type="submit" class="btn btn-success btn-sm">Ajouter au stock</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
<!-- Ligne "aucun résultat" -->
<div id="noResults" class="text-center text-muted py-4" style="display:none">
<i class="bi bi-search fs-3 d-block mb-2"></i>
Aucune matière ne correspond aux filtres.
<button class="btn btn-sm btn-link p-0 ms-1" id="resetFilters">Réinitialiser</button>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-boxes fs-1"></i>
<p class="mt-2">Aucune matière enregistrée.</p>
<a href="{{ url_for('new_material') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
Ajouter votre premier filament
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function() {
const rows = Array.from(document.querySelectorAll('.mat-row'));
if (!rows.length) return;
let activeType = 'all';
let searchText = '';
let sortKey = 'type-name';
const tbody = document.getElementById('materialsBody');
const noResults = document.getElementById('noResults');
const summary = document.getElementById('filterSummary');
const filterBar = document.getElementById('filterBar');
const toggle = document.getElementById('filterToggle');
// Persist filter bar open/close state
const STORE_KEY = 'mat_filter_open';
if (localStorage.getItem(STORE_KEY) === '1') {
filterBar.classList.add('show');
toggle.setAttribute('aria-expanded', 'true');
toggle.classList.add('active');
}
filterBar.addEventListener('shown.bs.collapse', () => {
localStorage.setItem(STORE_KEY, '1');
toggle.classList.add('active');
});
filterBar.addEventListener('hidden.bs.collapse', () => {
localStorage.setItem(STORE_KEY, '0');
toggle.classList.remove('active');
});
// ── Search ──────────────────────────────────
document.getElementById('searchInput').addEventListener('input', function() {
searchText = this.value.toLowerCase().trim();
render();
});
document.getElementById('clearSearch').addEventListener('click', function() {
document.getElementById('searchInput').value = '';
searchText = '';
render();
});
// ── Type filters ────────────────────────────
document.querySelectorAll('#typeFilters button').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('#typeFilters button').forEach(b => {
b.classList.remove('active', 'btn-secondary');
b.classList.add('btn-outline-secondary');
});
this.classList.add('active', 'btn-secondary');
this.classList.remove('btn-outline-secondary');
activeType = this.dataset.type;
render();
});
});
// ── Sort select ─────────────────────────────
document.getElementById('sortSelect').addEventListener('change', function() {
sortKey = this.value;
render();
});
// ── Column header click sorts ───────────────
document.querySelectorAll('.sortable-th').forEach(th => {
th.addEventListener('click', function() {
const col = this.dataset.col;
const map = {
name: 'name',
type: 'type-name',
price: 'price-asc',
stock: 'stock-desc',
usage: 'usage-desc'
};
if (map[col]) {
document.getElementById('sortSelect').value = map[col];
sortKey = map[col];
render();
}
});
});
// ── Reset ───────────────────────────────────
const resetBtn = document.getElementById('resetFilters');
if (resetBtn) resetBtn.addEventListener('click', reset);
function reset() {
searchText = '';
activeType = 'all';
sortKey = 'type-name';
document.getElementById('searchInput').value = '';
document.getElementById('sortSelect').value = 'type-name';
document.querySelectorAll('#typeFilters button').forEach(b => {
b.classList.remove('active', 'btn-secondary');
b.classList.add('btn-outline-secondary');
});
const allBtn = document.querySelector('#typeFilters button[data-type="all"]');
allBtn.classList.add('active', 'btn-secondary');
allBtn.classList.remove('btn-outline-secondary');
render();
}
// ── Main render ─────────────────────────────
function render() {
// 1. Filter
const visible = rows.filter(r => {
const matchType = activeType === 'all' || r.dataset.type === activeType;
const matchSearch = !searchText || r.dataset.name.includes(searchText);
return matchType && matchSearch;
});
// 2. Sort
visible.sort((a, b) => {
switch (sortKey) {
case 'name': return a.dataset.name.localeCompare(b.dataset.name);
case 'type-name': return a.dataset.type.localeCompare(b.dataset.type)
|| a.dataset.name.localeCompare(b.dataset.name);
case 'price-asc': return parseFloat(a.dataset.price) - parseFloat(b.dataset.price);
case 'price-desc': return parseFloat(b.dataset.price) - parseFloat(a.dataset.price);
case 'stock-asc': return parseFloat(a.dataset.stock) - parseFloat(b.dataset.stock);
case 'stock-desc': return parseFloat(b.dataset.stock) - parseFloat(a.dataset.stock);
case 'usage-desc': return parseFloat(b.dataset.usage) - parseFloat(a.dataset.usage);
default: return 0;
}
});
// 3. Reorder DOM & show/hide
const hidden = rows.filter(r => !visible.includes(r));
hidden.forEach(r => { r.style.display = 'none'; });
visible.forEach(r => { r.style.display = ''; tbody.appendChild(r); });
// 4. No results message
noResults.style.display = visible.length === 0 ? '' : 'none';
// 5. Active filter summary
const parts = [];
if (activeType !== 'all') parts.push(`Type : <strong>${activeType}</strong>`);
if (searchText) parts.push(`Recherche : <strong>"${searchText}"</strong>`);
summary.innerHTML = parts.length
? `${visible.length} résultat${visible.length !== 1 ? 's' : ''}${parts.join(' · ')}`
: '';
}
render();
})();
</script>
{% endblock %}