Files
3d-pricing/templates/new_job.html
T
2026-07-07 14:59:04 +02:00

947 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 (collapsible) -->
<div class="card mb-3 border border-warning border-opacity-50">
<div class="card-header py-2 d-flex align-items-center" role="button"
id="bambuToggle" style="cursor:pointer;user-select:none"
onclick="toggleBambu()">
<i class="bi bi-file-earmark-zip text-warning me-2"></i>
<span class="fw-semibold">Import Bambu Studio (.3mf)</span>
<span class="badge bg-warning text-dark ms-2">Optionnel</span>
<i class="bi bi-chevron-down ms-auto" id="bambuChevron"
style="transition:transform .2s"></i>
</div>
<div class="collapse" id="bambuBody">
<div class="card-body">
<div class="d-flex align-items-center gap-2 flex-wrap">
<input type="file" id="file3mf" accept=".3mf,.gcode.3mf" class="form-control" style="max-width:260px">
<button type="button" class="btn btn-outline-warning" onclick="parse3mf()">
<i class="bi bi-magic me-1"></i>Extraire
</button>
<span class="text-muted small">ou</span>
<button type="button" class="btn btn-outline-secondary" id="ncBambuBtn"
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
onclick="ncPickerMode='bambu'">
<i class="bi bi-cloud-arrow-down me-1"></i>Nextcloud
</button>
<span id="parseStatus" class="text-muted small"></span>
</div>
<div class="form-text mt-1">Choisissez un fichier <strong>.3mf</strong> local ou depuis Nextcloud — poids et durée seront remplis automatiquement. <strong class="text-warning"><i class="bi bi-exclamation-triangle-fill me-1"></i>Le fichier doit être un <code>gcode.3mf</code> (exporté depuis Bambu Studio après slicing), pas un simple <code>.3mf</code> de conception.</strong></div>
</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 '' }}">
<input type="hidden" name="source_nc_path" id="sourceNcPath"
value="{{ prefill.source_nc_path if prefill else '' }}">
<button type="button" class="btn btn-outline-secondary" id="ncBrowseBtn"
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
title="Parcourir Nextcloud"
onclick="ncPickerMode='source'">
<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 class="row g-3 mt-0">
<div class="col-12">
<label class="form-label fw-semibold">Nom / référence du plateau <span class="text-muted fw-normal small">(optionnel)</span></label>
<input type="text" name="plate_name" id="plate_name" class="form-control"
placeholder="ex : «Plateau 2» ou nom custom Bambu Studio"
value="{{ prefill.plate_name if prefill else '' }}">
<div id="plateObjectsHint" class="form-text"></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 pb-2">
<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-2 small" id="ncBreadcrumb"></ol>
</nav>
<input type="text" id="ncSearchInput" class="form-control form-control-sm"
placeholder="Filtrer par nom…" autocomplete="off">
</div>
<button type="button" class="btn-close ms-2 align-self-start mt-1" data-bs-dismiss="modal"></button>
</div>
<div id="ncBambuHint" class="alert alert-info d-none m-2 mb-0 py-2 px-3 small" role="alert">
<i class="bi bi-info-circle me-1"></i>
Sélectionnez le fichier <code>.gcode.3mf</code> pour importer poids, durée et données de découpe.
</div>
<div class="modal-body p-0" id="ncPickerBody" style="max-height:55vh;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>
// ── Section Bambu collapsible ────────────────────────────────────────────────
(function () {
const KEY = 'bambu_open';
const body = document.getElementById('bambuBody');
const chevron = document.getElementById('bambuChevron');
const isOpen = localStorage.getItem(KEY) === 'true'; // collapsed by default
if (isOpen) {
body.classList.add('show');
chevron.style.transform = 'rotate(180deg)';
}
})();
function toggleBambu() {
const KEY = 'bambu_open';
const body = document.getElementById('bambuBody');
const chevron = document.getElementById('bambuChevron');
const opening = !body.classList.contains('show');
body.classList.toggle('show', opening);
chevron.style.transform = opening ? 'rotate(180deg)' : '';
localStorage.setItem(KEY, opening);
}
// ── 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;
}
// ── Fichier non slicé ──
if (d.warning) {
status.innerHTML = `<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>${d.warning}</span>`;
return;
}
// ── 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 ──
const p0 = (d.plates && d.plates.length === 1) ? d.plates[0] : null;
applyPlateData(d.weight_g, d.hours, d.minutes,
p0 ? p0.pieces : null,
p0 ? (p0.name || `Plateau ${p0.index}`) : null,
p0 ? p0.object_names : null);
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 — vérifiez que le fichier a bien été slicé</span>';
})
.catch(() => { status.innerHTML = '<span class="text-danger">Erreur de lecture</span>'; });
}
function applyPlateData(weight_g, hours, minutes, pieces, plate_name, object_names) {
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;
if (pieces != null) document.getElementById('pieces_per_plate').value = pieces;
if (plate_name != null) document.getElementById('plate_name').value = plate_name;
// Afficher les noms des modèles comme aide
const hint = document.getElementById('plateObjectsHint');
if (hint && object_names && object_names.length) {
hint.innerHTML = `<i class="bi bi-layers me-1 text-muted"></i>Modèles : <span class="text-muted">${object_names.join(', ')}</span>`;
} else if (hint) {
hint.textContent = '';
}
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('');
const piecesLabel = p.pieces != null ? ` — ${p.pieces} pce` : '';
const nameLabel = p.name ? ` «${p.name}»` : '';
btn.innerHTML = `${dots}<strong>Plateau ${p.index}${nameLabel}</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${piecesLabel}`;
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),
p.pieces,
p.name || `Plateau ${p.index}`,
p.object_names
);
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 ─────────────────────────────────────────────────────
let ncPickerMode = 'source'; // 'source' = remplit seulement sourceFile, 'bambu' = remplit + parse 3MF
(function () {
const modal = document.getElementById('ncPickerModal');
const body = document.getElementById('ncPickerBody');
const bread = document.getElementById('ncBreadcrumb');
const sfInput = document.getElementById('sourceFile');
const searchInput = document.getElementById('ncSearchInput');
const bambuHint = document.getElementById('ncBambuHint');
if (!modal) return;
let currentPath = '';
let allItems = [];
async function browse(path) {
searchInput.value = '';
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;
allItems = d.items;
renderBreadcrumb(d.breadcrumb);
renderItems(allItems);
} catch (e) {
body.innerHTML = `<div class="alert alert-danger m-3">${e.message}</div>`;
}
}
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim().toLowerCase();
if (!q) { renderItems(allItems); return; }
const filtered = allItems.filter(it => it.name.toLowerCase().includes(q));
renderItems(filtered);
});
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 fmtMtime(mtime) {
if (!mtime) return '';
try {
const d = new Date(mtime);
const now = new Date();
const diffMs = now - d;
if (diffMs < 86400000) return 'Aujourd\'hui';
if (diffMs < 172800000) return 'Hier';
if (diffMs < 604800000) return Math.floor(diffMs/86400000) + 'j';
return d.toLocaleDateString('fr-FR', {day:'2-digit', month:'2-digit', year:'2-digit'});
} catch (e) { return ''; }
}
function renderItems(items) {
if (!items.length) {
body.innerHTML = '<div class="text-center text-muted py-4">Aucun fichier trouvé.</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 meta = item.is_dir ? '' :
`<small class="text-muted ms-auto text-nowrap" style="font-size:.75rem">
${fmtMtime(item.mtime)}${item.size ? ' · ' + (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}</span>
${meta}
${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);
}
async function selectFile(item) {
const nameLower = item.name.toLowerCase();
const isGcode3mf = nameLower.endsWith('.gcode.3mf');
const is3mf = nameLower.endsWith('.3mf');
// Fichier source : si c'est un gcode.3mf, pointer vers le .3mf projet correspondant
if (isGcode3mf) {
// Chercher le .3mf projet dans le même dossier
const dir = item.path.substring(0, item.path.lastIndexOf('/') + 1);
const base = item.name.replace(/\.gcode\.3mf$/i, '');
sfInput.value = dir + base + '.3mf';
} else {
sfInput.value = item.path;
}
// Auto-remplir le nom de commande si vide
const nameField = document.getElementById('jobName');
if (!nameField.value) {
const cleanName = item.name.replace(/\.gcode\.3mf$/i, '').replace(/\.[^.]+$/, '');
nameField.value = cleanName;
}
bootstrap.Modal.getInstance(modal).hide();
// Toujours sauvegarder le chemin Nextcloud pour permettre la duplication
document.getElementById('sourceNcPath').value = item.path;
// Mode Bambu : télécharger et parser le 3MF (gcode.3mf ou projet)
if (ncPickerMode === 'bambu' && (is3mf || isGcode3mf)) {
const status = document.getElementById('parseStatus');
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Téléchargement…';
try {
const resp = await fetch('/api/nextcloud/file?path=' + encodeURIComponent(item.path));
if (!resp.ok) throw new Error('Erreur téléchargement Nextcloud');
const blob = await resp.blob();
const file = new File([blob], item.name, { type: 'application/octet-stream' });
const dt = new DataTransfer();
dt.items.add(file);
document.getElementById('file3mf').files = dt.files;
await parse3mf();
} catch (e) {
document.getElementById('parseStatus').innerHTML =
`<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${e.message}</span>`;
}
}
ncPickerMode = 'source'; // reset
}
// ── Auto-parse au chargement (duplication avec source_nc_path) ────────────
(async function autoParseOnClone() {
const ncPath = document.getElementById('sourceNcPath').value;
if (!ncPath) return;
const status = document.getElementById('parseStatus');
if (!status) return;
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Rechargement des plateaux…';
try {
const r = await fetch('/api/nextcloud/parse?path=' + encodeURIComponent(ncPath));
const d = await r.json();
if (d.error) { status.innerHTML = `<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>${d.error}</span>`; return; }
if (d.plates && d.plates.length > 1) {
_3mfPlates = d.plates;
renderPlateSelector(d.plates);
status.innerHTML = `<span class="text-success">${d.plates.length} plateau(x) disponibles — choisissez ou modifiez</span>`;
} else if (d.plates && d.plates.length === 1) {
status.innerHTML = '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>';
} else {
status.innerHTML = '<span class="text-muted small">Fichier source lié — plateaux non disponibles</span>';
}
} catch(e) {
status.innerHTML = '<span class="text-muted small">Fichier source lié (réimportez si besoin)</span>';
}
})();
// Ouvrir = charger le dossier racine + afficher/masquer hint bambu
modal.addEventListener('show.bs.modal', () => {
bambuHint.classList.toggle('d-none', ncPickerMode !== 'bambu');
searchInput.value = '';
if (!currentPath) browse('');
});
})();
</script>
{% endblock %}