Files
3d-pricing/templates/job_detail.html
T
2026-07-07 08:58:12 +02:00

458 lines
20 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 %}{{ job.name }}{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<div>
<a href="{{ url_for('jobs') }}" class="text-muted text-decoration-none small">
<i class="bi bi-arrow-left"></i> Commandes
</a>
<h1 class="mt-1">{{ job.name }}</h1>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-secondary" id="shareBtn"
title="{{ 'Lien client actif — cliquer pour copier' if job.client_token else 'Générer un lien client' }}">
{% if job.client_token %}
<i class="bi bi-share-fill me-1 text-success"></i>Lien client
{% else %}
<i class="bi bi-share me-1"></i>Partager
{% endif %}
</button>
{% if job.client_token %}
<button class="btn btn-sm btn-outline-danger" id="revokeBtn" title="Révoquer le lien">
<i class="bi bi-x-circle"></i>
</button>
{% endif %}
<a href="{{ url_for('new_job', clone_from=job.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-copy me-1"></i>Dupliquer
</a>
<form method="POST" action="{{ url_for('delete_job', id=job.id) }}"
onsubmit="return confirm('Supprimer cette commande ?')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> Supprimer</button>
</form>
</div>
</div>
{% set qty = job.order_qty or 1 %}
{% set multi = qty > 1 %}
<div class="row g-4">
<div class="col-md-5">
<div class="card mb-3">
<div class="card-header py-3">Informations</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><th class="text-muted fw-normal" style="width:45%">Client</th><td>{{ job.client_name or '—' }}</td></tr>
<tr><th class="text-muted fw-normal">Matiere</th>
<td>
{% if job.material_name %}
<span class="badge bg-secondary">{{ job.material_type }}</span> {{ job.material_name }}
{% else %}—{% endif %}
</td>
</tr>
<tr><th class="text-muted fw-normal">Fichier source</th>
<td class="small">
{% if job.source_file %}
<span class="font-monospace">{{ job.source_file.split('/')[-1] }}</span>
{% set ext = job.source_file.rsplit('.',1)[-1].lower() %}
{% if ext in ['stl','3mf','step','stp','obj'] %}
<button class="btn btn-xs btn-outline-secondary py-0 px-1 ms-1"
style="font-size:.7rem"
data-bs-toggle="modal" data-bs-target="#viewerModal"
data-file="{{ job.source_file }}" data-ext="{{ ext }}">
<i class="bi bi-box me-1"></i>Voir
</button>
{% endif %}
{% else %}—{% endif %}
</td></tr>
<tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr>
<tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr>
<tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr>
<tr><th class="text-muted fw-normal">Quantite commandee</th><td>{{ qty }} pièce{{ 's' if qty > 1 else '' }}</td></tr>
<tr><th class="text-muted fw-normal">Duree plateau</th><td>{{ job.print_time_s | fmt_time }}</td></tr>
<tr><th class="text-muted fw-normal">Coeff design</th><td>×{{ job.design_multiplier }}</td></tr>
<tr><th class="text-muted fw-normal">Marge brute</th><td>{{ job.gross_margin_pct }} %</td></tr>
<tr><th class="text-muted fw-normal">Remise</th><td>{{ job.discount_pct }} %</td></tr>
{% if job.machine_profile_name %}
<tr><th class="text-muted fw-normal">Profil machine</th><td>{{ job.machine_profile_name }}</td></tr>
{% endif %}
{% if job.handling_profile_name %}
<tr><th class="text-muted fw-normal">Profil manutention</th><td>{{ job.handling_profile_name }}</td></tr>
{% endif %}
{% if job.pricing_profile_name %}
<tr><th class="text-muted fw-normal">Profil tarif</th><td>{{ job.pricing_profile_name }}</td></tr>
{% endif %}
</table>
{% if job.notes %}
<hr>
<small class="text-muted">{{ job.notes }}</small>
{% endif %}
</div>
</div>
</div>
<div class="col-md-7">
<div class="card">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-receipt me-2"></i>Decomposition du prix</span>
{% if multi %}
<span class="badge bg-secondary">{{ qty }} pièces commandées</span>
{% endif %}
</div>
<div class="card-body">
{# Macro pour une ligne à 1 ou 2 colonnes #}
{% macro brow(label, val_piece, cls='', precision=2) %}
<div class="breakdown-row {{ cls }}">
<span class="flex-fill">{{ label | safe }}</span>
<span class="text-end" style="min-width:85px">
{% if precision == 4 %}{{ "%.4f"|format(val_piece) }} €
{% else %}{{ "%.2f"|format(val_piece) }} €{% endif %}
</span>
{% if multi %}
<span class="text-end text-muted" style="min-width:85px">
{% if precision == 4 %}{{ "%.2f"|format(val_piece * qty) }} €
{% else %}{{ "%.2f"|format(val_piece * qty) }} €{% endif %}
</span>
{% endif %}
</div>
{% endmacro %}
{# En-tête colonnes #}
{% if multi %}
<div class="d-flex justify-content-end small text-muted mb-2 border-bottom pb-1 gap-0">
<span style="min-width:85px;text-align:right">/ pièce</span>
<span style="min-width:85px;text-align:right">/ commande ×{{ qty }}</span>
</div>
{% endif %}
{% set cout_f = job.cout_fixe or (job.material_cost + job.material_margin + job.wear_cost + job.electricity_cost) %}
{% set tot_m = job.total_marge or (job.handling_cost + job.design_cost + job.margin_amount) %}
{% set prix_ttc = job.final_price / (1 - job.discount_pct / 100) if job.discount_pct < 100 else job.final_price %}
{% set prix_ht_net = prix_ttc - (job.tva_amount or 0) %}
{% set marge_pct_ht = (tot_m / prix_ht_net * 100)|round(1) if prix_ht_net > 0 else 0 %}
{# Macro pour une ligne simple (label = texte pur) #}
{# Pour les labels avec HTML, les lignes sont écrites directement ci-dessous #}
<!-- Cout fixe -->
<div class="breakdown-row section-header"><span>Cout fixe</span></div>
<div class="breakdown-row">
<span class="flex-fill breakdown-muted ps-2">Matiere{% if job.material_name %} <small class="text-secondary">{{ job.material_name }}</small>{% endif %}</span>
<span class="text-end" style="min-width:85px">{{ "%.4f"|format(job.material_cost) }} €</span>
{% if multi %}<span class="text-end text-muted" style="min-width:85px">{{ "%.2f"|format(job.material_cost * qty) }} €</span>{% endif %}
</div>
{{ brow('Marge matiere', job.material_margin, 'breakdown-muted ps-2', 4) }}
{{ brow('Usure machine', job.wear_cost, 'breakdown-muted ps-2', 4) }}
{{ brow('Electricite', job.electricity_cost, 'breakdown-muted ps-2', 4) }}
<div class="breakdown-row subtotal">
<span class="flex-fill fw-semibold">Total cout fixe</span>
<span class="text-end" style="min-width:85px">{{ "%.2f"|format(cout_f) }} €</span>
{% if multi %}<span class="text-end text-muted" style="min-width:85px">{{ "%.2f"|format(cout_f * qty) }} €</span>{% endif %}
</div>
<!-- Partie variable -->
<div class="breakdown-row section-header"><span>Partie variable</span></div>
{{ brow('Manutention', job.handling_cost, 'breakdown-muted ps-2', 4) }}
{{ brow('Design (×' ~ job.design_multiplier ~ ')', job.design_cost, 'breakdown-muted ps-2', 4) }}
{{ brow('Marge brute (' ~ job.gross_margin_pct ~ '%)', job.margin_amount, 'breakdown-muted ps-2') }}
<div class="breakdown-row subtotal">
<span class="flex-fill fw-semibold">Total marge <small class="text-muted fw-normal">({{ marge_pct_ht }}% du HT)</small></span>
<span class="text-end" style="min-width:85px">{{ "%.2f"|format(tot_m) }} €</span>
{% if multi %}<span class="text-end text-muted" style="min-width:85px">{{ "%.2f"|format(tot_m * qty) }} €</span>{% endif %}
</div>
<!-- Fiscal -->
<div class="breakdown-row section-header"><span>Fiscal</span></div>
{{ brow('Cotisations BIC vente', job.cotisations_amount or job.tax_amount or 0) }}
{% if job.vfl_amount and job.vfl_amount > 0 %}
{{ brow('VFL impot', job.vfl_amount) }}
{% endif %}
{% if job.other_taxes_amount and job.other_taxes_amount > 0 %}
{{ brow('Autres taxes / CFE', job.other_taxes_amount) }}
{% endif %}
<!-- Prix HT -->
<div class="breakdown-row" style="border-top:none">
<span class="fw-bold flex-fill">Prix HT</span>
<span class="final-price text-end" style="font-size:1.3rem;min-width:85px">{{ "%.2f"|format(prix_ht_net) }} €</span>
{% if multi %}<span class="final-price text-end" style="font-size:1.3rem;min-width:85px;opacity:.75">{{ "%.2f"|format(prix_ht_net * qty) }} €</span>{% endif %}
</div>
{% if job.tva_amount and job.tva_amount > 0 %}
<div class="breakdown-row">
<span class="ps-2 flex-fill">TVA</span>
<span class="text-end" style="min-width:85px">{{ "%.2f"|format(job.tva_amount) }} €</span>
{% if multi %}<span class="text-end text-muted" style="min-width:85px">{{ "%.2f"|format(job.tva_amount * qty) }} €</span>{% endif %}
</div>
{% endif %}
{% if job.discount_pct > 0 %}
<div class="breakdown-row text-danger">
<span class="ps-2 flex-fill">Remise ({{ job.discount_pct }}%)</span>
<span class="text-end" style="min-width:85px">{{ "%.2f"|format(prix_ttc * job.discount_pct / 100) }} €</span>
{% if multi %}<span class="text-end" style="min-width:85px">{{ "%.2f"|format(prix_ttc * qty * job.discount_pct / 100) }} €</span>{% endif %}
</div>
{% endif %}
<!-- Prix final -->
{% if 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-3">
<span class="final-price" style="font-size:1.4rem">{{ "%.2f"|format(job.final_price) }} €</span>
<span class="final-price" style="font-size:1.4rem;opacity:.7">× {{ qty }} = {{ "%.2f"|format(job.final_price * qty) }} €</span>
</div>
</div>
{% else %}
<div class="breakdown-row total">
<span>PRIX FINAL TTC</span>
<span class="final-price">{{ "%.2f"|format(job.final_price) }} €</span>
</div>
{% endif %}
<!-- Barre de composition -->
{% set fiscal = (job.cotisations_amount or job.tax_amount or 0) + (job.vfl_amount or 0) + (job.other_taxes_amount or 0) + (job.tva_amount or 0) %}
{% if job.final_price > 0 %}
<div class="mt-4">
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Cout fixe ({{ (cout_f/job.final_price*100)|round(1) }}%)</span>
<span>Marge ({{ (tot_m/job.final_price*100)|round(1) }}%)</span>
<span>Fiscal ({{ (fiscal/job.final_price*100)|round(1) }}%)</span>
</div>
<div class="progress" style="height:18px;border-radius:6px">
<div class="progress-bar bg-secondary" style="width:{{ (cout_f/job.final_price*100)|round(1) }}%"></div>
<div class="progress-bar" style="width:{{ (tot_m/job.final_price*100)|round(1) }}%;background:#ff6b35"></div>
<div class="progress-bar bg-warning text-dark" style="width:{{ (fiscal/job.final_price*100)|round(1) }}%"></div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- ── Modale viewer 3D ──────────────────────────────────────────────────── -->
<div class="modal fade" id="viewerModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content" style="background:#1a1d23">
<div class="modal-header border-0 pb-0">
<span class="text-white fw-semibold" id="viewerTitle">Viewer 3D</span>
<div class="ms-auto d-flex gap-2">
<button class="btn btn-sm btn-outline-light" id="viewerWire">Wireframe</button>
<button class="btn btn-sm btn-outline-light" id="viewerReset">Reset vue</button>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body p-0 position-relative" style="height:75vh">
<canvas id="viewerCanvas" style="width:100%;height:100%;display:block"></canvas>
<div id="viewerLoader" class="position-absolute top-50 start-50 translate-middle text-white text-center">
<div class="spinner-border mb-2"></div><br>Chargement du modèle…
</div>
<div id="viewerError" class="position-absolute top-50 start-50 translate-middle text-danger text-center" style="display:none">
<i class="bi bi-exclamation-triangle fs-1 d-block mb-2"></i>
<span id="viewerErrorMsg"></span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
const modal = document.getElementById('viewerModal');
const canvas = document.getElementById('viewerCanvas');
const loader = document.getElementById('viewerLoader');
const errDiv = document.getElementById('viewerError');
const errMsg = document.getElementById('viewerErrorMsg');
const title = document.getElementById('viewerTitle');
let renderer, scene, camera, controls, modelMesh;
let wireMode = false;
function initRenderer() {
if (renderer) return;
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x1a1d23, 1);
renderer.shadowMap.enabled = true;
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// Lumières
const amb = new THREE.AmbientLight(0xffffff, 0.5);
const dir = new THREE.DirectionalLight(0xffffff, 1.2);
dir.position.set(5, 10, 7);
scene.add(amb, dir);
// Grille
const grid = new THREE.GridHelper(300, 30, 0x444444, 0x333333);
scene.add(grid);
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
}
animate();
}
function fitCamera() {
if (!modelMesh) return;
const box = new THREE.Box3().setFromObject(modelMesh);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
camera.position.set(center.x + maxDim * 1.5, center.y + maxDim, center.z + maxDim * 1.5);
controls.target.copy(center);
controls.update();
}
function loadModel(path, ext) {
loader.style.display = '';
errDiv.style.display = 'none';
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
const url = '/api/nextcloud/file?path=' + encodeURIComponent(path);
const mat = new THREE.MeshPhongMaterial({ color: 0xff6b35, specular: 0x333333, shininess: 40, side: THREE.DoubleSide });
const onError = (e) => {
loader.style.display = 'none';
errDiv.style.display = '';
errMsg.textContent = 'Erreur chargement : ' + (e.message || e);
};
if (ext === 'stl') {
new STLLoader().load(url, geo => {
geo.computeVertexNormals();
modelMesh = new THREE.Mesh(geo, mat);
modelMesh.rotation.x = -Math.PI / 2;
scene.add(modelMesh);
loader.style.display = 'none';
fitCamera();
}, undefined, onError);
} else if (ext === '3mf') {
new ThreeMFLoader().load(url, obj => {
obj.traverse(child => {
if (child.isMesh) child.material = mat.clone();
});
modelMesh = obj;
scene.add(modelMesh);
loader.style.display = 'none';
fitCamera();
}, undefined, onError);
} else {
loader.style.display = 'none';
errDiv.style.display = '';
errMsg.textContent = `Format .${ext} non supporté dans le viewer (STL et 3MF uniquement).`;
}
}
// Ouvrir la modale
modal.addEventListener('show.bs.modal', e => {
const btn = e.relatedTarget;
const path = btn.dataset.file;
const ext = btn.dataset.ext;
title.textContent = path.split('/').pop();
initRenderer();
loadModel(path, ext);
});
// Fermer → libérer
modal.addEventListener('hidden.bs.modal', () => {
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
});
// Wireframe toggle
document.getElementById('viewerWire').addEventListener('click', function () {
wireMode = !wireMode;
this.classList.toggle('active', wireMode);
if (modelMesh) modelMesh.traverse(c => {
if (c.isMesh) c.material.wireframe = wireMode;
});
});
// Reset vue
document.getElementById('viewerReset').addEventListener('click', fitCamera);
</script>
<script>
// ── Partage client ───────────────────────────────────────────────────────────
const jobId = {{ job.id }};
const shareBtn = document.getElementById('shareBtn');
const revokeBtn= document.getElementById('revokeBtn');
let currentToken = {{ '"' + job.client_token + '"' if job.client_token else 'null' }};
if (shareBtn) {
shareBtn.addEventListener('click', async () => {
if (currentToken) {
// Copier l'URL existante
const url = location.origin + '/portal/' + currentToken;
await navigator.clipboard.writeText(url);
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Copié !';
setTimeout(() => {
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
}, 2000);
} else {
// Générer un nouveau token
const r = await fetch(`/jobs/${jobId}/generate-token`, {method:'POST'});
const d = await r.json();
currentToken = d.token;
await navigator.clipboard.writeText(d.url);
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Lien copié !';
setTimeout(() => {
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
}, 2500);
// Ajouter bouton révoquer
if (!document.getElementById('revokeBtn')) {
const rb = document.createElement('button');
rb.id = 'revokeBtn';
rb.className = 'btn btn-sm btn-outline-danger';
rb.title = 'Révoquer le lien';
rb.innerHTML = '<i class="bi bi-x-circle"></i>';
shareBtn.insertAdjacentElement('afterend', rb);
rb.addEventListener('click', revokeLink);
}
}
});
}
async function revokeLink() {
if (!confirm('Révoquer le lien client ? Il ne sera plus accessible.')) return;
await fetch(`/jobs/${jobId}/revoke-token`, {method:'POST'});
currentToken = null;
shareBtn.innerHTML = '<i class="bi bi-share me-1"></i>Partager';
shareBtn.title = 'Générer un lien client';
document.getElementById('revokeBtn')?.remove();
}
if (revokeBtn) revokeBtn.addEventListener('click', revokeLink);
</script>
{% endblock %}