770 lines
38 KiB
HTML
770 lines
38 KiB
HTML
{% extends 'base.html' %}
|
||
{% block title %}Nouveau calcul{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="page-header">
|
||
<h1><i class="bi bi-plus-circle me-2" style="color:#ff6b35"></i>Nouveau calcul de prix</h1>
|
||
</div>
|
||
|
||
<div class="row g-4">
|
||
<div class="col-lg-7">
|
||
|
||
<!-- Import 3mf -->
|
||
<div class="card mb-3 border border-warning border-opacity-50">
|
||
<div class="card-header py-3">
|
||
<i class="bi bi-file-earmark-zip text-warning me-2"></i>Import Bambu Studio (.3mf)
|
||
<span class="badge bg-warning text-dark ms-1">Optionnel</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-center gap-3">
|
||
<input type="file" id="file3mf" accept=".3mf" class="form-control" style="max-width:320px">
|
||
<button type="button" class="btn btn-outline-warning" onclick="parse3mf()">
|
||
<i class="bi bi-magic me-1"></i>Extraire
|
||
</button>
|
||
<span id="parseStatus" class="text-muted small"></span>
|
||
</div>
|
||
<div class="form-text mt-1">Votre fichier .3mf depuis Bambu Studio pour remplir poids et duree automatiquement.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<form method="POST" id="jobForm">
|
||
|
||
<!-- Donnees impression -->
|
||
<div class="card mb-3">
|
||
<div class="card-header py-3">Donnees impression</div>
|
||
<div class="card-body">
|
||
<div class="row g-3 mb-3">
|
||
{% if prefill %}
|
||
<div class="col-12">
|
||
<div class="alert alert-info py-2 mb-0 small">
|
||
<i class="bi bi-copy me-1"></i>
|
||
Dupliqué depuis <strong>{{ prefill.name }}</strong> — modifiez les champs souhaités.
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
<div class="col-md-8">
|
||
<label class="form-label fw-semibold">Nom de la commande</label>
|
||
<input type="text" name="name" id="jobName" class="form-control" required
|
||
placeholder="ex: Marqueurs competition salle X"
|
||
value="{% if prefill %}Copie — {{ prefill.name }}{% endif %}">
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label fw-semibold">Fichier source</label>
|
||
<div class="input-group">
|
||
<input type="text" name="source_file" id="sourceFile" class="form-control"
|
||
placeholder="nom_fichier.3mf"
|
||
value="{{ prefill.source_file if prefill else '' }}">
|
||
<button type="button" class="btn btn-outline-secondary" id="ncBrowseBtn"
|
||
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
|
||
title="Parcourir Nextcloud">
|
||
<i class="bi bi-cloud-arrow-down"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="row g-3 mb-3">
|
||
<div class="col-md-4">
|
||
<label class="form-label fw-semibold">Client</label>
|
||
<select name="client_id" id="clientSelect" class="form-select">
|
||
<option value="">Sans client</option>
|
||
{% for c in clients %}
|
||
<option value="{{ c.id }}" data-dm="{{ c.design_multiplier }}"
|
||
{% if prefill and prefill.client_id == c.id %}selected{% endif %}>
|
||
{{ c.name }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label fw-semibold">Projet</label>
|
||
<select name="project_id" id="projectSelect" class="form-select">
|
||
<option value="">— Sans projet —</option>
|
||
{% for p in projects %}
|
||
<option value="{{ p.id }}"
|
||
{% if (prefill and prefill.project_id == p.id) or request.args.get('project_id')|int == p.id %}selected{% endif %}>
|
||
{{ p.name }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label fw-semibold">Matiere (filament)</label>
|
||
<select name="material_id" id="materialSelect" class="form-select">
|
||
<option value="">Prix global (parametres)</option>
|
||
{% for m in materials %}
|
||
<option value="{{ m.id }}" data-price="{{ m.price_per_kg }}"
|
||
data-stock="{{ m.stock_g }}" data-threshold="{{ m.low_stock_threshold_g }}"
|
||
data-name="{{ m.name }} ({{ m.type }})"
|
||
{% if prefill and prefill.material_id == m.id %}selected{% endif %}>
|
||
{{ m.name }} ({{ m.type }}) — {{ m.price_per_kg }} €/kg
|
||
{% if m.stock_g <= 0 %}[RUPTURE]{% elif m.stock_g <= m.low_stock_threshold_g %}[stock bas]{% endif %}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
<div id="stockInfo" class="form-text"></div>
|
||
</div>
|
||
</div>
|
||
<div class="row g-3 mb-3">
|
||
<div class="col-md-4">
|
||
<label class="form-label fw-semibold">Poids plateau (g)</label>
|
||
<input type="number" name="weight_g" id="weight_g" class="form-control"
|
||
step="0.01" min="0.1" required placeholder="423.97"
|
||
value="{{ prefill.weight_g if prefill else '' }}">
|
||
<div class="form-text">Poids total du plateau Bambu (toutes pièces)</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label fw-semibold">Heures</label>
|
||
<input type="number" name="hours" id="hours" class="form-control" min="0"
|
||
value="{{ prefill.hours if prefill else 0 }}">
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label fw-semibold">Minutes</label>
|
||
<input type="number" name="minutes" id="minutes" class="form-control" min="0" max="59"
|
||
value="{{ prefill.minutes if prefill else 0 }}">
|
||
<div class="form-text">Temps total Bambu</div>
|
||
</div>
|
||
</div>
|
||
<div class="row g-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label fw-semibold">Pièces par plateau</label>
|
||
<input type="number" name="pieces_per_plate" id="pieces_per_plate" class="form-control"
|
||
min="1" step="1" value="{{ prefill.pieces_per_plate if prefill else 1 }}">
|
||
<div class="form-text">Combien de pièces tiennent sur un plateau.</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label fw-semibold">Quantité commandée</label>
|
||
<input type="number" name="order_qty" id="order_qty" class="form-control"
|
||
min="1" step="1" value="{{ prefill.order_qty if prefill else 1 }}">
|
||
<div class="form-text">Nombre total de pièces à produire.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Profils -->
|
||
<div class="card mb-3">
|
||
<div class="card-header py-3">
|
||
<i class="bi bi-bookmark-star me-2"></i>Profils
|
||
<a href="{{ url_for('profiles') }}" class="btn btn-sm btn-outline-secondary ms-2 py-0" target="_blank">
|
||
<i class="bi bi-pencil-square"></i> Gerer
|
||
</a>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label fw-semibold small">
|
||
<i class="bi bi-cpu me-1 text-muted"></i>Profil machine
|
||
</label>
|
||
<select name="machine_profile_id" id="machineProfileSelect" class="form-select form-select-sm">
|
||
<option value="">— Parametres globaux —</option>
|
||
{% for p in machine_profiles %}
|
||
<option value="{{ p.id }}"
|
||
{% if prefill and prefill.machine_profile_id == p.id %}selected{% endif %}>
|
||
{{ p.name }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label fw-semibold small">
|
||
<i class="bi bi-hand-index me-1 text-muted"></i>Profil manutention
|
||
</label>
|
||
<select name="handling_profile_id" id="handlingProfileSelect" class="form-select form-select-sm">
|
||
<option value="">— Parametres globaux —</option>
|
||
{% for p in handling_profiles %}
|
||
<option value="{{ p.id }}"
|
||
{% if prefill and prefill.handling_profile_id == p.id %}selected{% endif %}>
|
||
{{ p.name }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label fw-semibold small">
|
||
<i class="bi bi-box me-1 text-muted"></i>Profil matiere
|
||
</label>
|
||
<select name="material_profile_id" id="materialProfileSelect" class="form-select form-select-sm">
|
||
<option value="">— Parametres globaux —</option>
|
||
{% for p in material_profiles %}
|
||
<option value="{{ p.id }}" data-margin="{{ p.material_margin_pct }}"
|
||
{% if prefill and prefill.material_profile_id == p.id %}selected{% endif %}>
|
||
{{ p.name }} ({{ p.material_margin_pct }}%)
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label fw-semibold small">
|
||
<i class="bi bi-tags me-1 text-muted"></i>Profil tarif client
|
||
</label>
|
||
<select name="pricing_profile_id" id="pricingProfileSelect" class="form-select form-select-sm">
|
||
<option value="">— Manuel —</option>
|
||
{% for p in pricing_profiles %}
|
||
<option value="{{ p.id }}"
|
||
data-dm="{{ p.design_multiplier }}"
|
||
data-margin="{{ p.gross_margin_pct }}"
|
||
{% if prefill and prefill.pricing_profile_id == p.id %}selected{% endif %}>
|
||
{{ p.name }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Parametres tarifaires -->
|
||
<div class="card mb-3">
|
||
<div class="card-header py-3">Parametres tarifaires</div>
|
||
<div class="card-body">
|
||
<div class="mb-4">
|
||
<label class="form-label fw-semibold">
|
||
Coefficient design <span class="badge bg-secondary ms-1" id="dmLabel">x1.80</span>
|
||
</label>
|
||
<div class="d-flex gap-2 mb-2" id="dmButtons">
|
||
<button type="button" id="btnDM080" class="btn btn-sm btn-outline-success" onclick="setDM(0.80)">
|
||
<i class="bi bi-check2 me-1 d-none" id="checkDM080"></i>STL fourni (x0.80)
|
||
</button>
|
||
<button type="button" id="btnDM180" class="btn btn-sm btn-outline-primary" onclick="setDM(1.80)">
|
||
<i class="bi bi-check2 me-1 d-none" id="checkDM180"></i>Creation complete (x1.80)
|
||
</button>
|
||
</div>
|
||
<input type="number" name="design_multiplier" id="design_multiplier"
|
||
class="form-control form-control-sm" step="0.05" min="0"
|
||
value="{{ prefill.design_multiplier if prefill else 1.80 }}">
|
||
</div>
|
||
<div class="mb-4">
|
||
<label class="form-label fw-semibold d-flex justify-content-between">
|
||
Marge brute <span id="marginDisplay" class="fw-bold" style="color:#ff6b35">27.5 %</span>
|
||
</label>
|
||
<div class="d-flex gap-2 mb-2" id="marginButtons">
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setMargin(20)">20%</button>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setMargin(25)">25%</button>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setMargin(30)">30%</button>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setMargin(35)">35%</button>
|
||
</div>
|
||
<input type="range" name="gross_margin_pct" id="gross_margin_pct" class="form-range"
|
||
min="0" max="100" step="0.5"
|
||
value="{{ prefill.gross_margin_pct if prefill else 27.5 }}">
|
||
<div class="d-flex justify-content-between" style="font-size:.75rem;color:#6b7280">
|
||
<span>0%</span><span>100%</span>
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Remise client (%)</label>
|
||
<input type="number" name="discount_pct" id="discount_pct" class="form-control"
|
||
min="0" max="100" step="0.5"
|
||
value="{{ prefill.discount_pct if prefill else 0 }}">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Notes internes</label>
|
||
<textarea name="notes" class="form-control" rows="2">{{ prefill.notes if prefill else '' }}</textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="d-flex gap-2">
|
||
<button type="submit" name="action" value="save" class="btn flex-fill py-2 fw-bold" style="background:#ff6b35;color:#fff">
|
||
<i class="bi bi-save me-2"></i>Enregistrer la commande
|
||
</button>
|
||
<button type="submit" name="action" value="save_and_new" class="btn py-2 fw-semibold btn-outline-secondary" style="white-space:nowrap">
|
||
<i class="bi bi-plus-square me-1"></i>Enregistrer et créer un autre
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Recap temps reel -->
|
||
<div class="col-lg-5">
|
||
<div class="card sticky-top" style="top:1.5rem">
|
||
<div class="card-header py-3">
|
||
<i class="bi bi-receipt me-2"></i>Recapitulatif temps reel
|
||
</div>
|
||
<div class="card-body" id="breakdown">
|
||
<div class="text-center text-muted py-4">
|
||
<i class="bi bi-calculator fs-2"></i>
|
||
<p class="mt-2 small">Remplissez le poids et la duree pour voir le calcul.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modale Nextcloud File Picker -->
|
||
<div class="modal fade" id="ncPickerModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<div class="flex-fill">
|
||
<h5 class="modal-title mb-1"><i class="bi bi-cloud me-2"></i>Nextcloud — Choisir un fichier</h5>
|
||
<nav aria-label="breadcrumb">
|
||
<ol class="breadcrumb mb-0 small" id="ncBreadcrumb"></ol>
|
||
</nav>
|
||
</div>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body p-0" id="ncPickerBody" style="max-height:60vh;overflow-y:auto">
|
||
<div class="text-center text-muted py-5">
|
||
<i class="bi bi-cloud fs-1 d-block mb-2"></i>
|
||
Cliquez sur le bouton <i class="bi bi-cloud-arrow-down"></i> pour ouvrir.
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer py-2">
|
||
<small class="text-muted me-auto">Cliquez sur un fichier pour le sélectionner.</small>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
// ── 3MF parser avec sélecteur de plaque ──────────────────────────────────────
|
||
let _3mfPlates = [];
|
||
|
||
function parse3mf() {
|
||
const file = document.getElementById('file3mf').files[0];
|
||
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
|
||
const status = document.getElementById('parseStatus');
|
||
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyse…';
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
fetch('/api/parse-3mf', {method:'POST', body:fd})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.filename) {
|
||
document.getElementById('sourceFile').value = d.filename;
|
||
if (!document.getElementById('jobName').value)
|
||
document.getElementById('jobName').value = d.filename;
|
||
}
|
||
// ── Cas Bambu avec plaques détectées ──
|
||
if (d.plates && d.plates.length > 1) {
|
||
_3mfPlates = d.plates;
|
||
renderPlateSelector(d.plates);
|
||
status.innerHTML = `<span class="text-success">${d.plates.length} plateau(x) détecté(s) — choisissez ci-dessous</span>`;
|
||
return;
|
||
}
|
||
// ── Plaque unique ou fallback ──
|
||
applyPlateData(d.weight_g, d.hours, d.minutes);
|
||
const ok = d.weight_g || d.hours != null;
|
||
status.innerHTML = ok
|
||
? '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>'
|
||
: '<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Données non trouvées</span>';
|
||
})
|
||
.catch(() => { status.innerHTML = '<span class="text-danger">Erreur de lecture</span>'; });
|
||
}
|
||
|
||
function applyPlateData(weight_g, hours, minutes) {
|
||
if (weight_g != null) document.getElementById('weight_g').value = weight_g;
|
||
if (hours != null) document.getElementById('hours').value = hours;
|
||
if (minutes != null) document.getElementById('minutes').value = minutes;
|
||
recalc();
|
||
}
|
||
|
||
function renderPlateSelector(plates) {
|
||
// Supprimer un éventuel sélecteur précédent
|
||
document.getElementById('plateSelectorWrap')?.remove();
|
||
const wrap = document.createElement('div');
|
||
wrap.id = 'plateSelectorWrap';
|
||
wrap.className = 'mt-3 border rounded p-3 bg-light';
|
||
wrap.innerHTML = '<div class="fw-semibold small mb-2"><i class="bi bi-layers me-1"></i>Choisir le plateau à imprimer :</div>';
|
||
const grid = document.createElement('div');
|
||
grid.className = 'd-flex flex-wrap gap-2';
|
||
plates.forEach(p => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'btn btn-sm btn-outline-warning';
|
||
// Pastilles filaments
|
||
const dots = p.filaments.map(f =>
|
||
`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${f.color || '#ccc'};border:1px solid #aaa;margin-right:2px"></span>`
|
||
).join('');
|
||
btn.innerHTML = `${dots}<strong>Plateau ${p.index}</strong> — ${p.weight_g} g — ${Math.floor(p.print_time_s/3600)}h${String(Math.floor((p.print_time_s%3600)/60)).padStart(2,'0')}m`;
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('#plateSelectorWrap button').forEach(b => b.classList.replace('btn-warning','btn-outline-warning'));
|
||
btn.classList.replace('btn-outline-warning','btn-warning');
|
||
applyPlateData(p.weight_g, Math.floor(p.print_time_s/3600), Math.floor((p.print_time_s%3600)/60));
|
||
document.getElementById('parseStatus').innerHTML =
|
||
`<span class="text-success"><i class="bi bi-check-circle me-1"></i>Plateau ${p.index} appliqué</span>`;
|
||
});
|
||
grid.appendChild(btn);
|
||
});
|
||
wrap.appendChild(grid);
|
||
// Insérer après le card d'import
|
||
document.querySelector('.card.border-warning').insertAdjacentElement('afterend', wrap);
|
||
}
|
||
|
||
// ── Coefficient design ────────────────────────────────────────────────────────
|
||
function setDM(val) {
|
||
document.getElementById('design_multiplier').value = val;
|
||
updateDMLabel(val);
|
||
recalc();
|
||
}
|
||
|
||
function updateDMLabel(val) {
|
||
val = parseFloat(val);
|
||
document.getElementById('dmLabel').textContent = 'x' + val.toFixed(2);
|
||
// Etat actif des boutons
|
||
const is080 = Math.abs(val - 0.80) < 0.001;
|
||
const is180 = Math.abs(val - 1.80) < 0.001;
|
||
document.getElementById('btnDM080').className = 'btn btn-sm ' + (is080 ? 'btn-success' : 'btn-outline-success');
|
||
document.getElementById('btnDM180').className = 'btn btn-sm ' + (is180 ? 'btn-primary' : 'btn-outline-primary');
|
||
}
|
||
|
||
document.getElementById('design_multiplier').addEventListener('input', function() {
|
||
updateDMLabel(this.value);
|
||
recalc();
|
||
});
|
||
|
||
// ── Marge ─────────────────────────────────────────────────────────────────────
|
||
function setMargin(val) {
|
||
document.getElementById('gross_margin_pct').value = val;
|
||
updateMarginDisplay(val);
|
||
recalc();
|
||
}
|
||
|
||
function updateMarginDisplay(val) {
|
||
val = parseFloat(val);
|
||
document.getElementById('marginDisplay').textContent = val.toFixed(1) + ' %';
|
||
[20, 25, 30, 35].forEach(function(v) {
|
||
const btn = document.querySelector('#marginButtons button[onclick="setMargin(' + v + ')"]');
|
||
if (btn) btn.className = 'btn btn-sm ' + (Math.abs(val - v) < 0.01 ? 'btn-secondary' : 'btn-outline-secondary');
|
||
});
|
||
}
|
||
|
||
const marginSlider = document.getElementById('gross_margin_pct');
|
||
marginSlider.addEventListener('input', function() { updateMarginDisplay(this.value); recalc(); });
|
||
marginSlider.addEventListener('change', function() { updateMarginDisplay(this.value); recalc(); });
|
||
|
||
// ── Profil tarif client ───────────────────────────────────────────────────────
|
||
document.getElementById('pricingProfileSelect').addEventListener('change', function() {
|
||
const opt = this.options[this.selectedIndex];
|
||
if (opt.value) {
|
||
const dm = parseFloat(opt.dataset.dm);
|
||
const margin = parseFloat(opt.dataset.margin);
|
||
if (!isNaN(dm)) {
|
||
document.getElementById('design_multiplier').value = dm;
|
||
updateDMLabel(dm);
|
||
}
|
||
if (!isNaN(margin)) {
|
||
marginSlider.value = margin;
|
||
updateMarginDisplay(margin);
|
||
}
|
||
recalc();
|
||
}
|
||
});
|
||
|
||
// ── Client : auto-remplir profils par défaut ─────────────────────────────────
|
||
document.getElementById('clientSelect').addEventListener('change', function() {
|
||
const clientId = this.value;
|
||
if (!clientId) return;
|
||
fetch('/api/client/' + clientId + '/defaults')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.machine_profile_id != null) document.getElementById('machineProfileSelect').value = d.machine_profile_id;
|
||
if (d.handling_profile_id != null) document.getElementById('handlingProfileSelect').value = d.handling_profile_id;
|
||
if (d.material_profile_id != null) document.getElementById('materialProfileSelect').value = d.material_profile_id;
|
||
if (d.pricing_profile_id != null) {
|
||
const sel = document.getElementById('pricingProfileSelect');
|
||
sel.value = d.pricing_profile_id;
|
||
// Appliquer DM + marge du profil tarif sélectionné
|
||
const opt = sel.options[sel.selectedIndex];
|
||
if (opt && opt.value) {
|
||
const dm = parseFloat(opt.dataset.dm);
|
||
const margin = parseFloat(opt.dataset.margin);
|
||
if (!isNaN(dm)) { document.getElementById('design_multiplier').value = dm; updateDMLabel(dm); }
|
||
if (!isNaN(margin)) { marginSlider.value = margin; updateMarginDisplay(margin); }
|
||
}
|
||
}
|
||
recalc();
|
||
});
|
||
});
|
||
|
||
// ── Matiere : afficher stock ──────────────────────────────────────────────────
|
||
document.getElementById('materialSelect').addEventListener('change', function() {
|
||
const opt = this.options[this.selectedIndex];
|
||
const stock = parseFloat(opt.dataset.stock) || 0;
|
||
const threshold = parseFloat(opt.dataset.threshold) || 200;
|
||
const info = document.getElementById('stockInfo');
|
||
if (!opt.value) { info.textContent = ''; }
|
||
else if (stock <= 0) info.innerHTML = '<span class="text-danger">RUPTURE DE STOCK</span>';
|
||
else if (stock <= threshold) info.innerHTML = '<span class="text-warning">Stock bas : ' + stock.toFixed(0) + ' g</span>';
|
||
else info.innerHTML = '<span class="text-success">Stock : ' + stock.toFixed(0) + ' g</span>';
|
||
recalc();
|
||
});
|
||
|
||
// ── Autres champs → recalc ────────────────────────────────────────────────────
|
||
['weight_g','hours','minutes','discount_pct','pieces_per_plate','order_qty'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('input', recalc);
|
||
});
|
||
['machineProfileSelect','handlingProfileSelect','materialProfileSelect'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('change', recalc);
|
||
});
|
||
|
||
// ── Calcul API ────────────────────────────────────────────────────────────────
|
||
function recalc() {
|
||
const w = parseFloat(document.getElementById('weight_g').value) || 0;
|
||
const h = parseInt(document.getElementById('hours').value) || 0;
|
||
const m = parseInt(document.getElementById('minutes').value) || 0;
|
||
if (w <= 0 && h === 0 && m === 0) return;
|
||
if (w <= 0) return;
|
||
|
||
const body = {
|
||
weight_g: w,
|
||
hours: h,
|
||
minutes: m,
|
||
design_multiplier: parseFloat(document.getElementById('design_multiplier').value) || 1.80,
|
||
gross_margin_pct: parseFloat(document.getElementById('gross_margin_pct').value ?? 27.5),
|
||
discount_pct: parseFloat(document.getElementById('discount_pct').value) || 0,
|
||
material_id: document.getElementById('materialSelect').value || null,
|
||
machine_profile_id: document.getElementById('machineProfileSelect').value || null,
|
||
handling_profile_id: document.getElementById('handlingProfileSelect').value || null,
|
||
material_profile_id: document.getElementById('materialProfileSelect').value || null,
|
||
pieces_per_plate: parseInt(document.getElementById('pieces_per_plate').value) || 1,
|
||
order_qty: parseInt(document.getElementById('order_qty').value) || 1,
|
||
};
|
||
|
||
fetch('/api/calculate', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body)
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => renderBreakdown(d, body))
|
||
.catch(() => {});
|
||
}
|
||
|
||
// ── Rendu du recap ────────────────────────────────────────────────────────────
|
||
function fmt(n) { return parseFloat(n).toFixed(4) + ' €'; }
|
||
function fmt2(n) { return parseFloat(n).toFixed(2) + ' €'; }
|
||
|
||
function renderBreakdown(d, body) {
|
||
const dm = parseFloat(body.design_multiplier).toFixed(2);
|
||
const mgn = parseFloat(body.gross_margin_pct).toFixed(1);
|
||
const qty = body.order_qty || 1;
|
||
const ppp = body.pieces_per_plate || 1;
|
||
const multi = qty > 1;
|
||
|
||
// Nom du materiau
|
||
const matSel = document.getElementById('materialSelect');
|
||
const matOpt = matSel.options[matSel.selectedIndex];
|
||
const matName = matOpt.value ? (matOpt.dataset.name || matOpt.text.split('—')[0].trim()) : 'global';
|
||
|
||
// TVA
|
||
const tvaLabel = d._tva_pct > 0 ? `TVA (${d._tva_pct}%)` : `TVA — franchise en base`;
|
||
const tvaClass = d._tva_pct > 0 ? '' : 'text-muted fst-italic';
|
||
|
||
// Helper : génère une ligne avec 1 ou 2 colonnes de valeurs
|
||
// cls : classes CSS supplémentaires sur le div
|
||
function row(label, pieceVal, extraCls = '', isFmt4 = false) {
|
||
const fmtFn = isFmt4 ? fmt : fmt2;
|
||
const p = fmtFn(pieceVal);
|
||
const o = multi ? `<span class="text-end" style="min-width:80px">${fmt2(pieceVal * qty)}</span>` : '';
|
||
return `<div class="breakdown-row${extraCls ? ' '+extraCls : ''}">
|
||
<span class="flex-fill">${label}</span>
|
||
<span class="text-end" style="min-width:80px">${p}</span>
|
||
${o}
|
||
</div>`;
|
||
}
|
||
function rowFiscal(label, pieceVal, extraCls = '') {
|
||
const p = fmt2(pieceVal);
|
||
const o = multi ? `<span class="text-end" style="min-width:80px">${fmt2(pieceVal * qty)}</span>` : '';
|
||
return `<div class="breakdown-row${extraCls ? ' '+extraCls : ''}">
|
||
<span class="flex-fill">${label}</span>
|
||
<span class="text-end" style="min-width:80px">${p}</span>
|
||
${o}
|
||
</div>`;
|
||
}
|
||
|
||
// Alerte plateau incomplet
|
||
const nbPlateaux = Math.ceil(qty / ppp);
|
||
const lastPlate = qty % ppp;
|
||
let plateauAlert = '';
|
||
if (lastPlate !== 0) {
|
||
plateauAlert = `<div class="alert alert-warning py-2 mb-2 small">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||
Dernier plateau incomplet : <strong>${lastPlate}/${ppp} pièces</strong> —
|
||
vous pourriez imprimer <strong>${nbPlateaux * ppp}</strong> pièces pour le même coût fixe.
|
||
</div>`;
|
||
}
|
||
|
||
// En-tête colonnes (multi uniquement)
|
||
const colHeader = multi
|
||
? `<div class="d-flex justify-content-end gap-0 small text-muted mb-1 border-bottom pb-1">
|
||
<span style="min-width:80px;text-align:right">/ pièce</span>
|
||
<span style="min-width:80px;text-align:right">/ commande ×${qty}</span>
|
||
</div>` : '';
|
||
|
||
// VFL / autres taxes
|
||
const vflRow = d._vfl_pct > 0 ? rowFiscal(`<span class="breakdown-muted ps-2">VFL impot (${d._vfl_pct}%)</span>`, d.vfl_amount) : '';
|
||
const otherRow = d._other_taxes_pct > 0 ? rowFiscal(`<span class="breakdown-muted ps-2">Autres taxes / CFE (${d._other_taxes_pct}%)</span>`, d.other_taxes_amount) : '';
|
||
|
||
// Remise (sur prix TTC/pièce)
|
||
let disRow = '';
|
||
if (body.discount_pct > 0) {
|
||
const remisePiece = d.prix_ttc * body.discount_pct / 100;
|
||
const o = multi ? `<span class="text-end text-danger" style="min-width:80px">-${fmt2(remisePiece * qty)}</span>` : '';
|
||
disRow = `<div class="breakdown-row text-danger">
|
||
<span class="flex-fill">Remise (${body.discount_pct}%)</span>
|
||
<span class="text-end" style="min-width:80px">-${fmt2(remisePiece)}</span>${o}
|
||
</div>`;
|
||
}
|
||
|
||
// Capacité
|
||
const cap = d.capacity || {};
|
||
const totalMin = d.total_print_min || 0;
|
||
const capBlock = `
|
||
<div class="breakdown-row section-header mt-1"><span>Capacite de production</span></div>
|
||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Nb plateaux necessaires</span><span><strong>${nbPlateaux}</strong> plateau${nbPlateaux>1?'x':''}</span></div>
|
||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Duree totale (impression + cooldown)</span><span>${Math.floor(totalMin/60)}h${String(Math.round(totalMin%60)).padStart(2,'0')}min</span></div>
|
||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / jour (${cap.effective_printers||5} machines)</span><span>${cap.pieces_per_day||'—'}</span></div>
|
||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / semaine</span><span>${cap.pieces_per_week||'—'}</span></div>
|
||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / mois (30j)</span><span>${cap.pieces_per_month||'—'}</span></div>`;
|
||
|
||
// Prix finaux
|
||
const pricePiece = d.final_price;
|
||
const priceOrder = pricePiece * qty;
|
||
const finalLabel = multi
|
||
? `<div class="d-flex justify-content-between align-items-center mt-2 pt-2" style="border-top:2px solid #dee2e6">
|
||
<span class="fw-bold">PRIX FINAL TTC</span>
|
||
<div class="d-flex gap-2">
|
||
<span class="final-price" style="font-size:1.4rem">${fmt2(pricePiece)}</span>
|
||
<span class="final-price" style="font-size:1.4rem;opacity:.7">× ${qty} = ${fmt2(priceOrder)}</span>
|
||
</div>
|
||
</div>`
|
||
: `<div class="breakdown-row total"><span>PRIX FINAL TTC</span><span class="final-price">${fmt2(pricePiece)}</span></div>`;
|
||
|
||
document.getElementById('breakdown').innerHTML = plateauAlert + colHeader + `
|
||
<div class="breakdown-row section-header"><span>Cout fixe</span></div>
|
||
${row(`<span class="breakdown-muted ps-2">Matiere <small class="text-secondary">${matName}</small></span>`, d.material_cost, '', true)}
|
||
${row(`<span class="breakdown-muted ps-2">Marge matiere (${d._material_margin_pct}%)</span>`, d.material_margin, '', true)}
|
||
${row(`<span class="breakdown-muted ps-2">Usure — imprimante</span>`, d.printer_wear, '', true)}
|
||
${row(`<span class="breakdown-muted ps-2">Usure — buse</span>`, d.nozzle_wear, '', true)}
|
||
${row(`<span class="breakdown-muted ps-2">Usure — plateau</span>`, d.plate_wear, '', true)}
|
||
${row(`<span class="breakdown-muted ps-2">Electricite <small class="text-secondary">${parseFloat(d.kwh_used).toFixed(3)} kWh × ${d._elec_price} €</small></span>`, d.electricity_cost, '', true)}
|
||
${row(`<span class="fw-semibold">Total cout fixe</span>`, d.cout_fixe, 'subtotal')}
|
||
|
||
<div class="breakdown-row section-header"><span>Partie variable</span></div>
|
||
${row(`<span class="breakdown-muted ps-2">Manutention <small class="text-secondary">${d._handling_minutes} min × ${d._handling_rate} €/h</small></span>`, d.handling_cost, '', true)}
|
||
${row(`<span class="breakdown-muted ps-2">Design (x${dm})</span>`, d.design_cost, '', true)}
|
||
${row(`<span class="breakdown-muted ps-2">Marge brute (${mgn}%)</span>`, d.margin_amount)}
|
||
${row(`<span class="fw-semibold">Total marge <small class="text-muted fw-normal">(${d.marge_pct_on_ht}% du HT)</small></span>`, d.total_marge, 'subtotal')}
|
||
|
||
<div class="breakdown-row section-header"><span>Fiscal</span></div>
|
||
${rowFiscal(`<span class="breakdown-muted ps-2">Cotisations BIC vente (${d._cot_pct}%)</span>`, d.cotisations_amount)}
|
||
${vflRow}${otherRow}
|
||
<div class="breakdown-row" style="border-top:none">
|
||
<span class="fw-bold">Prix HT</span>
|
||
<span class="final-price" style="font-size:1.3rem;text-align:right;min-width:80px">${fmt2(d.prix_ht_net)}</span>
|
||
${multi ? `<span class="final-price" style="font-size:1.3rem;text-align:right;min-width:80px;opacity:.75">${fmt2(d.prix_ht_net * qty)}</span>` : ''}
|
||
</div>
|
||
<div class="breakdown-row ${tvaClass}">
|
||
<span class="ps-2">${tvaLabel}</span>
|
||
<span class="text-end" style="min-width:80px">${d._tva_pct > 0 ? fmt2(d.tva_amount) : ''}</span>
|
||
${multi && d._tva_pct > 0 ? `<span class="text-end" style="min-width:80px">${fmt2(d.tva_amount * qty)}</span>` : (multi ? '<span style="min-width:80px"></span>' : '')}
|
||
</div>
|
||
${disRow}
|
||
${finalLabel}
|
||
` + capBlock;
|
||
}
|
||
|
||
// Init — utiliser les valeurs du champ (prefill ou défaut)
|
||
const _initDM = parseFloat(document.getElementById('design_multiplier').value) || 1.80;
|
||
const _initMargin = parseFloat(document.getElementById('gross_margin_pct').value) ?? 27.5;
|
||
updateDMLabel(_initDM);
|
||
updateMarginDisplay(_initMargin);
|
||
if (document.getElementById('weight_g').value) recalc();
|
||
|
||
// ── Nextcloud File Picker ─────────────────────────────────────────────────────
|
||
(function () {
|
||
const modal = document.getElementById('ncPickerModal');
|
||
const body = document.getElementById('ncPickerBody');
|
||
const bread = document.getElementById('ncBreadcrumb');
|
||
const sfInput = document.getElementById('sourceFile');
|
||
|
||
if (!modal) return;
|
||
|
||
let currentPath = '';
|
||
|
||
async function browse(path) {
|
||
body.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> Chargement…</div>';
|
||
try {
|
||
const r = await fetch('/api/nextcloud/browse?path=' + encodeURIComponent(path));
|
||
const d = await r.json();
|
||
if (d.error) {
|
||
body.innerHTML = `<div class="alert alert-danger m-3">${d.error}</div>`;
|
||
return;
|
||
}
|
||
currentPath = d.path;
|
||
renderBreadcrumb(d.breadcrumb);
|
||
renderItems(d.items);
|
||
} catch (e) {
|
||
body.innerHTML = `<div class="alert alert-danger m-3">${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderBreadcrumb(crumbs) {
|
||
bread.innerHTML = crumbs.map((c, i) =>
|
||
i < crumbs.length - 1
|
||
? `<li class="breadcrumb-item"><a href="#" class="nc-crumb" data-path="${c.path}">${c.name}</a></li>`
|
||
: `<li class="breadcrumb-item active">${c.name}</li>`
|
||
).join('');
|
||
bread.querySelectorAll('.nc-crumb').forEach(a =>
|
||
a.addEventListener('click', e => { e.preventDefault(); browse(a.dataset.path); }));
|
||
}
|
||
|
||
const EXT_ICONS = {
|
||
'3mf': 'bi-box-seam text-warning',
|
||
'stl': 'bi-triangle text-info',
|
||
'step': 'bi-bezier2 text-success',
|
||
'stp': 'bi-bezier2 text-success',
|
||
'obj': 'bi-grid-3x3 text-secondary',
|
||
'gcode':'bi-code-square text-danger',
|
||
'dir': 'bi-folder-fill text-warning',
|
||
};
|
||
|
||
function renderItems(items) {
|
||
if (!items.length) {
|
||
body.innerHTML = '<div class="text-center text-muted py-4">Dossier vide.</div>';
|
||
return;
|
||
}
|
||
body.innerHTML = '';
|
||
const list = document.createElement('div');
|
||
list.className = 'list-group list-group-flush';
|
||
items.forEach(item => {
|
||
const icon = EXT_ICONS[item.ext] || 'bi-file text-muted';
|
||
const size = item.is_dir ? '' : ` <small class="text-muted">(${(item.size/1024/1024).toFixed(1)} Mo)</small>`;
|
||
const el = document.createElement('button');
|
||
el.type = 'button';
|
||
el.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2';
|
||
el.innerHTML = `<i class="bi ${icon} fs-5" style="flex-shrink:0"></i>
|
||
<span class="flex-fill text-start">${item.name}${size}</span>
|
||
${item.is_dir ? '<i class="bi bi-chevron-right text-muted"></i>' : '<i class="bi bi-check2-circle text-success"></i>'}`;
|
||
if (item.is_dir) {
|
||
el.addEventListener('click', () => browse(item.path));
|
||
} else {
|
||
el.addEventListener('click', () => selectFile(item));
|
||
}
|
||
list.appendChild(el);
|
||
});
|
||
body.appendChild(list);
|
||
}
|
||
|
||
function selectFile(item) {
|
||
sfInput.value = item.path;
|
||
// Auto-remplir le nom de commande si vide
|
||
const nameField = document.getElementById('jobName');
|
||
if (!nameField.value) {
|
||
nameField.value = item.name.replace(/\.[^.]+$/, '');
|
||
}
|
||
bootstrap.Modal.getInstance(modal).hide();
|
||
}
|
||
|
||
// Ouvrir = charger le dossier racine
|
||
modal.addEventListener('show.bs.modal', () => { if (!currentPath) browse(''); });
|
||
})();
|
||
</script>
|
||
{% endblock %}
|