Ajoute la création rapide des lots par catégorie
This commit is contained in:
parent
0b3fff43e5
commit
25934d018d
6 changed files with 194 additions and 9 deletions
|
|
@ -154,11 +154,18 @@ def create():
|
|||
|
||||
if request.method == 'POST':
|
||||
parent_id = request.form.get('parent_select') or request.form.get('parent_id') or None
|
||||
category_id = request.form.get('category_id', type=int)
|
||||
lot_id = request.form.get('lot_id', type=int)
|
||||
if lot_id:
|
||||
lot = db.session.get(Lot, lot_id)
|
||||
if not category_id or not lot or lot.category_id != category_id:
|
||||
flash('Le lot sélectionné doit appartenir à la catégorie choisie.', 'danger')
|
||||
return redirect(url_for('equipments.create', advanced=1)), 400
|
||||
equipment = Equipment(
|
||||
name=request.form.get('name'),
|
||||
category_id=request.form.get('category_id') or None,
|
||||
category_id=category_id,
|
||||
room_id=request.form.get('room_id') or None,
|
||||
lot_id=request.form.get('lot_id') or None,
|
||||
lot_id=lot_id,
|
||||
code=_empty_to_none(request.form.get('code', '').strip()),
|
||||
description=_empty_to_none(request.form.get('description', '').strip()),
|
||||
status=request.form.get('status', 'en_service'),
|
||||
|
|
@ -225,11 +232,18 @@ def edit(id):
|
|||
equipment = Equipment.query.get_or_404(id)
|
||||
|
||||
if request.method == 'POST':
|
||||
category_id = request.form.get('category_id', type=int)
|
||||
lot_id = request.form.get('lot_id', type=int)
|
||||
if lot_id:
|
||||
lot = db.session.get(Lot, lot_id)
|
||||
if not category_id or not lot or lot.category_id != category_id:
|
||||
flash('Le lot sélectionné doit appartenir à la catégorie choisie.', 'danger')
|
||||
return redirect(url_for('equipments.edit', id=id)), 400
|
||||
equipment.name = request.form.get('name')
|
||||
equipment.code = _empty_to_none(request.form.get('code'))
|
||||
equipment.description = _empty_to_none(request.form.get('description'))
|
||||
equipment.category_id = _empty_to_none(request.form.get('category_id'))
|
||||
equipment.lot_id = _empty_to_none(request.form.get('lot_id'))
|
||||
equipment.category_id = category_id
|
||||
equipment.lot_id = lot_id
|
||||
equipment.room_id = _empty_to_none(request.form.get('room_id'))
|
||||
equipment.status = request.form.get('status', 'en_service')
|
||||
equipment.quantity = int(request.form.get('quantity', 1)) or 1
|
||||
|
|
|
|||
|
|
@ -90,11 +90,12 @@
|
|||
<select class="form-select" id="lot_id" name="lot_id">
|
||||
<option value="">-- Sans lot --</option>
|
||||
{% for lot in lots %}
|
||||
<option value="{{ lot.id }}" {{ 'selected' if equipment.lot_id == lot.id else '' }}>
|
||||
<option value="{{ lot.id }}" data-category-id="{{ lot.category_id or '' }}" {{ 'selected' if equipment.lot_id == lot.id else '' }}>
|
||||
{{ lot.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mt-2" id="quick-add-lot"><i class="bi bi-plus-lg"></i> Créer un lot dans cette catégorie</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -641,6 +642,50 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
const equipmentCategory = document.getElementById('category_id');
|
||||
const equipmentLot = document.getElementById('lot_id');
|
||||
const quickAddEquipmentLot = document.getElementById('quick-add-lot');
|
||||
function filterEquipmentLots() {
|
||||
const categoryId = equipmentCategory.value;
|
||||
let selectedIsValid = !equipmentLot.value;
|
||||
[...equipmentLot.options].forEach((option, index) => {
|
||||
if (!index) return;
|
||||
const visible = !!categoryId && option.dataset.categoryId === categoryId;
|
||||
option.hidden = !visible;
|
||||
option.disabled = !visible;
|
||||
if (visible && option.selected) selectedIsValid = true;
|
||||
});
|
||||
if (!selectedIsValid) equipmentLot.value = '';
|
||||
quickAddEquipmentLot.disabled = !categoryId;
|
||||
}
|
||||
equipmentCategory.addEventListener('change', filterEquipmentLots);
|
||||
quickAddEquipmentLot.addEventListener('click', async () => {
|
||||
const categoryName = equipmentCategory.selectedOptions[0]?.textContent.trim();
|
||||
const name = window.prompt(`Nom du nouveau lot pour « ${categoryName} » :`);
|
||||
if (!name || !name.trim()) return;
|
||||
const response = await fetch('{{ url_for("wizard.quick_create_lot") }}', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
|
||||
body: JSON.stringify({name: name.trim(), category_id: equipmentCategory.value})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok && !data.lot) {
|
||||
window.alert(data.error || 'Création du lot impossible.');
|
||||
return;
|
||||
}
|
||||
const created = data.lot;
|
||||
let option = [...equipmentLot.options].find(item => item.value === String(created.id));
|
||||
if (!option) {
|
||||
option = new Option(created.name, created.id);
|
||||
option.dataset.categoryId = String(created.category_id);
|
||||
equipmentLot.add(option);
|
||||
}
|
||||
filterEquipmentLots();
|
||||
equipmentLot.value = String(created.id);
|
||||
if (!response.ok) window.alert(data.error);
|
||||
});
|
||||
filterEquipmentLots();
|
||||
|
||||
// Gestion des formulaires AJAX
|
||||
document.getElementById('meterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -116,11 +116,12 @@
|
|||
<select class="form-select" id="lot_id" name="lot_id">
|
||||
<option value="">-- Aucun lot --</option>
|
||||
{% for lot in lots %}
|
||||
<option value="{{ lot.id }}" {{ 'selected' if equipment and equipment.lot_id == lot.id else '' }}>
|
||||
<option value="{{ lot.id }}" data-category-id="{{ lot.category_id or '' }}" {{ 'selected' if equipment and equipment.lot_id == lot.id else '' }}>
|
||||
{{ lot.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mt-2" id="quick-add-lot" disabled><i class="bi bi-plus-lg"></i> Créer un lot dans cette catégorie</button>
|
||||
<small class="text-muted">Associe les tâches préventives du lot à l'équipement</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
|
|
@ -256,6 +257,51 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const codeField = document.getElementById('code_field');
|
||||
const roomRequired = document.getElementById('room_required');
|
||||
const roomHint = document.getElementById('room_hint');
|
||||
const categorySelect = document.getElementById('category_id');
|
||||
const lotSelect = document.getElementById('lot_id');
|
||||
const quickAddLot = document.getElementById('quick-add-lot');
|
||||
|
||||
function filterLots() {
|
||||
const categoryId = categorySelect.value;
|
||||
let selectedIsValid = !lotSelect.value;
|
||||
[...lotSelect.options].forEach((option, index) => {
|
||||
if (!index) return;
|
||||
const visible = !!categoryId && option.dataset.categoryId === categoryId;
|
||||
option.hidden = !visible;
|
||||
option.disabled = !visible;
|
||||
if (visible && option.selected) selectedIsValid = true;
|
||||
});
|
||||
if (!selectedIsValid) lotSelect.value = '';
|
||||
quickAddLot.disabled = !categoryId;
|
||||
}
|
||||
|
||||
categorySelect.addEventListener('change', filterLots);
|
||||
quickAddLot.addEventListener('click', async () => {
|
||||
const categoryName = categorySelect.selectedOptions[0]?.textContent.trim();
|
||||
const name = window.prompt(`Nom du nouveau lot pour « ${categoryName} » :`);
|
||||
if (!name || !name.trim()) return;
|
||||
const response = await fetch('{{ url_for("wizard.quick_create_lot") }}', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
|
||||
body: JSON.stringify({name: name.trim(), category_id: categorySelect.value})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok && !data.lot) {
|
||||
window.alert(data.error || 'Création du lot impossible.');
|
||||
return;
|
||||
}
|
||||
const created = data.lot;
|
||||
let option = [...lotSelect.options].find(item => item.value === String(created.id));
|
||||
if (!option) {
|
||||
option = new Option(created.name, created.id);
|
||||
option.dataset.categoryId = String(created.category_id);
|
||||
lotSelect.add(option);
|
||||
}
|
||||
filterLots();
|
||||
lotSelect.value = String(created.id);
|
||||
if (!response.ok) window.alert(data.error);
|
||||
});
|
||||
filterLots();
|
||||
|
||||
// Vérifier si on a un parent_id dans l'URL (via template)
|
||||
const preselectedParentId = "{{ preselected_parent_id or '' }}";
|
||||
|
|
@ -276,6 +322,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (this.selectedOptions[0]) {
|
||||
const categoryId = this.selectedOptions[0].dataset.category;
|
||||
document.getElementById('category_id').value = categoryId;
|
||||
filterLots();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,38 @@ from app_new.core.models.college import Room, Building, Zone, RoomType
|
|||
wizard_bp = Blueprint('wizard', __name__, url_prefix='/wizard', template_folder='templates')
|
||||
|
||||
|
||||
@wizard_bp.route('/api/lots', methods=['POST'])
|
||||
@login_required
|
||||
def quick_create_lot():
|
||||
"""Crée un lot depuis un formulaire d'équipement et le lie à sa catégorie."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
category_id = data.get('category_id')
|
||||
try:
|
||||
category_id = int(category_id)
|
||||
except (TypeError, ValueError):
|
||||
category_id = 0
|
||||
category = db.session.get(EquipmentCategory, category_id)
|
||||
if not name or len(name) > 500 or not category:
|
||||
return jsonify({'error': 'Sélectionnez une catégorie et saisissez le nom du lot.'}), 400
|
||||
existing = Lot.query.filter(
|
||||
Lot.category_id == category.id,
|
||||
db.func.lower(Lot.name) == name.lower(),
|
||||
).first()
|
||||
if existing:
|
||||
return jsonify({
|
||||
'error': 'Ce lot existe déjà dans cette catégorie.',
|
||||
'lot': {'id': existing.id, 'name': existing.name, 'category_id': category.id},
|
||||
}), 409
|
||||
lot = Lot(name=name, category_id=category.id, is_present=True)
|
||||
db.session.add(lot)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'lot': {'id': lot.id, 'name': lot.name, 'category_id': category.id},
|
||||
}), 201
|
||||
|
||||
|
||||
@wizard_bp.route('/equipments')
|
||||
@login_required
|
||||
def equipment_wizard():
|
||||
|
|
@ -66,7 +98,7 @@ def bulk_create_equipments():
|
|||
mobility = raw.get('mobility', 'non_precise')
|
||||
if not name or not category or not lot or quantity < 1 or quantity > 1000:
|
||||
raise ValueError('Une ligne contient des données invalides.')
|
||||
if lot.category_id and lot.category_id != category.id:
|
||||
if lot.category_id != category.id:
|
||||
raise ValueError(f'Le lot « {lot.name} » ne correspond pas à la catégorie « {category.name} ».')
|
||||
|
||||
root = Equipment(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
<div class="card mb-3"><div class="card-header fw-semibold">3. Vérification</div><div class="card-body" id="summary">Sélectionnez une pièce et ajoutez un équipement.</div></div>
|
||||
<div class="d-flex justify-content-between"><a href="{{ url_for('equipments.index') }}" class="btn btn-outline-secondary">Annuler</a><button id="submit" class="btn btn-success btn-lg" disabled><i class="bi bi-check-lg"></i> Créer l’inventaire</button></div>
|
||||
|
||||
<template id="row-template"><tr class="item"><td><input class="form-control name" placeholder="Ex. Luminaire" required></td><td><select class="form-select category"><option value="">Catégorie…</option>{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}</select><select class="form-select lot mt-1" disabled><option value="">Lot…</option></select></td><td><input type="number" min="1" max="1000" value="1" class="form-control quantity" style="min-width:6rem"></td><td><select class="form-select mode"><option value="quantitatif">Quantitatif</option><option value="individuel">Individualisé</option></select></td><td><select class="form-select mobility"><option value="non_precise">À préciser</option><option value="mobile">Mobile</option><option value="fixe">Fixe</option></select></td><td><button type="button" class="btn btn-outline-danger remove" title="Supprimer"><i class="bi bi-trash"></i></button></td></tr></template>
|
||||
<template id="row-template"><tr class="item"><td><input class="form-control name" placeholder="Ex. Luminaire" required></td><td><select class="form-select category"><option value="">Catégorie…</option>{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}</select><select class="form-select lot mt-1" disabled><option value="">Lot…</option></select><button type="button" class="btn btn-link btn-sm p-0 mt-1 quick-lot" disabled><i class="bi bi-plus-lg"></i> Nouveau lot dans cette catégorie</button></td><td><input type="number" min="1" max="1000" value="1" class="form-control quantity" style="min-width:6rem"></td><td><select class="form-select mode"><option value="quantitatif">Quantitatif</option><option value="individuel">Individualisé</option></select></td><td><select class="form-select mobility"><option value="non_precise">À préciser</option><option value="mobile">Mobile</option><option value="fixe">Fixe</option></select></td><td><button type="button" class="btn btn-outline-danger remove" title="Supprimer"><i class="bi bi-trash"></i></button></td></tr></template>
|
||||
{% endblock %}
|
||||
{% block extra_scripts %}<script>
|
||||
const lots={{ lots_by_category|tojson }}, selected=new Set({{ selected_room_ids|tojson }}), items=document.getElementById('items');
|
||||
|
|
@ -30,7 +30,7 @@ async function zones(){const s=document.getElementById('zone'),b=document.getEle
|
|||
document.getElementById('rooms').onclick=e=>{const x=e.target.closest('.room');if(!x)return;const id=+x.dataset.id;selected.has(id)?selected.delete(id):selected.add(id);refreshRooms()};
|
||||
document.getElementById('building').onchange=zones;document.getElementById('zone').onchange=refreshRooms;document.getElementById('room-search').oninput=refreshRooms;
|
||||
document.getElementById('select-visible').onclick=()=>{document.querySelectorAll('.room:not([hidden])').forEach(x=>selected.add(+x.dataset.id));refreshRooms()};document.getElementById('clear-rooms').onclick=()=>{selected.clear();refreshRooms()};
|
||||
function addRow(data={}){const row=document.getElementById('row-template').content.firstElementChild.cloneNode(true);items.append(row);row.querySelector('.name').value=data.name||'';const cat=row.querySelector('.category'),lot=row.querySelector('.lot');if(data.category){const option=[...cat.options].find(o=>norm(o.text).includes(norm(data.category)));if(option)cat.value=option.value}function fill(){lot.innerHTML='<option value="">Lot…</option>';for(const x of (lots[cat.value]||[]))lot.add(new Option(x.name,x.id));lot.disabled=!cat.value;if(data.lot&&lot.options.length>1)lot.selectedIndex=1;refreshSummary()}cat.onchange=fill;fill();row.querySelector('.quantity').value=data.quantity||1;row.querySelector('.mode').value=data.mode||'quantitatif';row.querySelector('.mobility').value=data.mobility||'non_precise';row.querySelectorAll('input,select').forEach(x=>x.addEventListener('input',refreshSummary));row.querySelector('.remove').onclick=()=>{row.remove();refreshSummary()};refreshSummary()}
|
||||
function addRow(data={}){const row=document.getElementById('row-template').content.firstElementChild.cloneNode(true);items.append(row);row.querySelector('.name').value=data.name||'';const cat=row.querySelector('.category'),lot=row.querySelector('.lot'),quick=row.querySelector('.quick-lot');if(data.category){const option=[...cat.options].find(o=>norm(o.text).includes(norm(data.category)));if(option)cat.value=option.value}function fill(selected=''){lot.innerHTML='<option value="">Lot…</option>';for(const x of (lots[cat.value]||[]))lot.add(new Option(x.name,x.id));lot.disabled=!cat.value;quick.disabled=!cat.value;if(selected)lot.value=String(selected);else if(data.lot&&lot.options.length>1)lot.selectedIndex=1;refreshSummary()}cat.onchange=()=>fill();quick.onclick=async()=>{const categoryName=cat.selectedOptions[0]?.textContent.trim(),name=window.prompt(`Nom du nouveau lot pour « ${categoryName} » :`);if(!name||!name.trim())return;quick.disabled=true;const response=await fetch('{{ url_for("wizard.quick_create_lot") }}',{method:'POST',headers:{'Content-Type':'application/json','X-CSRFToken':'{{ csrf_token() }}'},body:JSON.stringify({name:name.trim(),category_id:cat.value})}),result=await response.json();quick.disabled=false;if(!response.ok&&!result.lot){alert(result.error||'Création du lot impossible');return}const created=result.lot;lots[cat.value]=lots[cat.value]||[];if(!lots[cat.value].some(x=>String(x.id)===String(created.id)))lots[cat.value].push(created);fill(created.id);if(!response.ok)alert(result.error)};fill();row.querySelector('.quantity').value=data.quantity||1;row.querySelector('.mode').value=data.mode||'quantitatif';row.querySelector('.mobility').value=data.mobility||'non_precise';row.querySelectorAll('input,select').forEach(x=>x.addEventListener('input',refreshSummary));row.querySelector('.remove').onclick=()=>{row.remove();refreshSummary()};refreshSummary()}
|
||||
document.getElementById('add-row').onclick=()=>addRow();
|
||||
const presets={classe:[['Chaises','chaise',30,'quantitatif','mobile'],['Prises','prise',10,'individuel','fixe'],['Luminaires','luminaire',10,'individuel','fixe'],['Portes','porte',2,'individuel','fixe'],['Murs','mur',4,'quantitatif','fixe']],logement:[['Prises','prise',8,'individuel','fixe'],['Luminaires','luminaire',2,'individuel','fixe'],['Portes','porte',1,'individuel','fixe'],['Fenêtres','fenetre',1,'individuel','fixe']]};
|
||||
document.querySelectorAll('.preset').forEach(b=>b.onclick=()=>presets[b.dataset.preset].forEach(x=>addRow({name:x[0],category:x[1],quantity:x[2],mode:x[3],mobility:x[4],lot:true})));
|
||||
|
|
|
|||
47
tests/integration/test_quick_lot_creation.py
Normal file
47
tests/integration/test_quick_lot_creation.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from uuid import uuid4
|
||||
|
||||
from app_new.extensions import db
|
||||
from app_new.core.models.equipment import EquipmentCategory
|
||||
from app_new.core.models.maintenance import Lot
|
||||
|
||||
|
||||
def test_quick_lot_is_created_in_selected_category(authenticated_client, app):
|
||||
with app.app_context():
|
||||
category = EquipmentCategory(name=f'Catégorie rapide {uuid4().hex[:6]}')
|
||||
db.session.add(category)
|
||||
db.session.commit()
|
||||
category_id = category.id
|
||||
response = authenticated_client.post('/wizard/api/lots', json={
|
||||
'name': 'Lot créé pendant l’inventaire',
|
||||
'category_id': category_id,
|
||||
})
|
||||
assert response.status_code == 201
|
||||
payload = response.get_json()['lot']
|
||||
assert payload['category_id'] == category_id
|
||||
with app.app_context():
|
||||
lot = db.session.get(Lot, payload['id'])
|
||||
assert lot.category_id == category_id
|
||||
assert lot.is_present is True
|
||||
|
||||
|
||||
def test_quick_lot_duplicate_returns_existing_lot(authenticated_client, app):
|
||||
with app.app_context():
|
||||
category = EquipmentCategory(name=f'Catégorie doublon {uuid4().hex[:6]}')
|
||||
lot = Lot(name='Lot identique', category=category)
|
||||
db.session.add_all([category, lot])
|
||||
db.session.commit()
|
||||
response = authenticated_client.post('/wizard/api/lots', json={
|
||||
'name': 'lot IDENTIQUE', 'category_id': category.id,
|
||||
})
|
||||
lot_id = lot.id
|
||||
assert response.status_code == 409
|
||||
assert response.get_json()['lot']['id'] == lot_id
|
||||
|
||||
|
||||
def test_equipment_forms_offer_category_bound_lot_creation(authenticated_client):
|
||||
wizard = authenticated_client.get('/wizard/equipments')
|
||||
advanced = authenticated_client.get('/equipments/create?advanced=1')
|
||||
assert wizard.status_code == 200
|
||||
assert advanced.status_code == 200
|
||||
assert 'Nouveau lot dans cette catégorie'.encode() in wizard.data
|
||||
assert 'Créer un lot dans cette catégorie'.encode() in advanced.data
|
||||
Loading…
Reference in a new issue