This commit is contained in:
@@ -690,8 +690,9 @@ def materials():
|
|||||||
ROUND(COALESCE(SUM(j.weight_g),0),1) as total_used_g
|
ROUND(COALESCE(SUM(j.weight_g),0),1) as total_used_g
|
||||||
FROM materials m LEFT JOIN jobs j ON j.material_id=m.id
|
FROM materials m LEFT JOIN jobs j ON j.material_id=m.id
|
||||||
GROUP BY m.id ORDER BY m.type, m.name''').fetchall()
|
GROUP BY m.id ORDER BY m.type, m.name''').fetchall()
|
||||||
|
types = sorted(set(m['type'] for m in mats if m['type']))
|
||||||
conn.close()
|
conn.close()
|
||||||
return render_template('materials.html', materials=mats)
|
return render_template('materials.html', materials=mats, types=types)
|
||||||
|
|
||||||
@app.route('/materials/new', methods=['GET','POST'])
|
@app.route('/materials/new', methods=['GET','POST'])
|
||||||
def new_material():
|
def new_material():
|
||||||
@@ -746,6 +747,23 @@ def restock_material(id):
|
|||||||
conn.commit(); conn.close()
|
conn.commit(); conn.close()
|
||||||
return redirect(url_for('materials'))
|
return redirect(url_for('materials'))
|
||||||
|
|
||||||
|
@app.route('/materials/<int:id>/duplicate', methods=['POST'])
|
||||||
|
def duplicate_material(id):
|
||||||
|
conn = get_db()
|
||||||
|
m = conn.execute('SELECT * FROM materials WHERE id=?',(id,)).fetchone()
|
||||||
|
if m:
|
||||||
|
conn.execute('''INSERT INTO materials(name,brand,type,color,price_per_kg,stock_g,low_stock_threshold_g,notes)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?)''',
|
||||||
|
('Copie de ' + m['name'], m['brand'], m['type'], m['color'],
|
||||||
|
m['price_per_kg'], 0, m['low_stock_threshold_g'], m['notes']))
|
||||||
|
new_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0]
|
||||||
|
conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)',
|
||||||
|
(new_id, m['price_per_kg']))
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
return redirect(url_for('edit_material', id=new_id))
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for('materials'))
|
||||||
|
|
||||||
@app.route('/materials/<int:id>/delete', methods=['POST'])
|
@app.route('/materials/<int:id>/delete', methods=['POST'])
|
||||||
def delete_material(id):
|
def delete_material(id):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
|||||||
+243
-12
@@ -4,25 +4,97 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header d-flex justify-content-between align-items-center">
|
<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>
|
<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">
|
<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
|
<i class="bi bi-plus-lg me-1"></i>Ajouter une matière
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
{% if materials %}
|
{% if materials %}
|
||||||
<table class="table table-hover mb-0 align-middle">
|
<table class="table table-hover mb-0 align-middle" id="materialsTable">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nom</th><th>Type</th><th>Couleur</th>
|
<th data-col="name" class="sortable-th" style="cursor:pointer">
|
||||||
<th>Prix/kg</th><th>Stock</th><th>Commandes</th><th>Utilisé total</th><th></th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="materialsBody">
|
||||||
{% for m in materials %}
|
{% for m in materials %}
|
||||||
{% set stock_pct = [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min %}
|
{% set stock_pct = [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min %}
|
||||||
<tr>
|
<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 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><span class="badge bg-secondary">{{ m.type }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -49,24 +121,29 @@
|
|||||||
{% if m.stock_g <= 0 %}bg-danger
|
{% if m.stock_g <= 0 %}bg-danger
|
||||||
{% elif m.stock_g <= m.low_stock_threshold_g %}bg-warning
|
{% elif m.stock_g <= m.low_stock_threshold_g %}bg-warning
|
||||||
{% else %}bg-success{% endif %}"
|
{% else %}bg-success{% endif %}"
|
||||||
style="width:{{ [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min }}%">
|
style="width:{{ stock_pct }}%">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ m.job_count }}</td>
|
<td>{{ m.job_count }}</td>
|
||||||
<td>{{ m.total_used_g }} g</td>
|
<td>{{ m.total_used_g }} g</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1 flex-nowrap">
|
||||||
<button class="btn btn-sm btn-outline-success py-0" data-bs-toggle="modal"
|
<button class="btn btn-sm btn-outline-success py-0" data-bs-toggle="modal"
|
||||||
data-bs-target="#restockModal{{ m.id }}">
|
data-bs-target="#restockModal{{ m.id }}" title="Réapprovisionner">
|
||||||
<i class="bi bi-plus-lg"></i> Stock
|
<i class="bi bi-plus-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('edit_material', id=m.id) }}" class="btn btn-sm btn-outline-secondary py-0">
|
<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>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</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) }}"
|
<form method="POST" action="{{ url_for('delete_material', id=m.id) }}"
|
||||||
onsubmit="return confirm('Supprimer cette matière ?')">
|
onsubmit="return confirm('Supprimer {{ m.name }} ?')">
|
||||||
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger py-0" title="Supprimer"><i class="bi bi-trash"></i></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -106,6 +183,12 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 %}
|
{% else %}
|
||||||
<div class="text-center text-muted py-5">
|
<div class="text-center text-muted py-5">
|
||||||
<i class="bi bi-boxes fs-1"></i>
|
<i class="bi bi-boxes fs-1"></i>
|
||||||
@@ -118,3 +201,151 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user