feat: viewer 3D + parser 3MF par plaque + échecs filament + portail client
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -104,7 +104,23 @@
|
||||
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}">
|
||||
<i class="bi bi-sliders"></i> Paramètres
|
||||
</a>
|
||||
<a href="{{ url_for('mobile_quick') }}" class="{% if request.endpoint=='mobile_quick' %}active{% endif %}">
|
||||
<i class="bi bi-phone"></i> Vue mobile
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{% if session.user %}
|
||||
<div style="padding:.75rem 1.25rem;border-top:1px solid #2e323d;margin-top:auto">
|
||||
<div style="font-size:.75rem;color:#9ba3af;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
|
||||
title="{{ session.user.email }}">
|
||||
<i class="bi bi-person-circle me-1"></i>{{ session.user.name }}
|
||||
</div>
|
||||
<a href="{{ url_for('logout') }}"
|
||||
style="font-size:.72rem;color:#6b7280;text-decoration:none;display:block;margin-top:.25rem">
|
||||
<i class="bi bi-box-arrow-left me-1"></i>Déconnexion
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Suivi commande — {{ job.name }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f4f6f9; }
|
||||
.topbar { background: #1a1d23; color: #fff; padding: 1rem 1.5rem; }
|
||||
.topbar .brand { font-weight: 800; font-size: 1.2rem; }
|
||||
.topbar .brand span { color: #ff6b35; }
|
||||
.card { border: none; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.stat-val { font-size: 1.5rem; font-weight: 700; }
|
||||
.timeline { position: relative; padding-left: 2rem; }
|
||||
.timeline::before { content: ''; position: absolute; left: .6rem; top: 0; bottom: 0;
|
||||
width: 2px; background: #dee2e6; }
|
||||
.timeline-item { position: relative; margin-bottom: 1.2rem; }
|
||||
.timeline-item::before { content: ''; position: absolute; left: -1.55rem; top: .3rem;
|
||||
width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 2px #dee2e6; background: #6b7280; }
|
||||
.timeline-item.done::before { background: #22c55e; box-shadow: 0 0 0 2px #22c55e; }
|
||||
.timeline-item.failed::before { background: #ef4444; box-shadow: 0 0 0 2px #ef4444; }
|
||||
.timeline-item.planned::before{ background: #3b82f6; box-shadow: 0 0 0 2px #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar d-flex align-items-center justify-content-between">
|
||||
<div class="brand"><i class="bi bi-layers-half me-1"></i>3D<span>Pricing</span></div>
|
||||
<small class="text-secondary">Suivi de commande</small>
|
||||
</div>
|
||||
|
||||
<div class="container py-4" style="max-width:720px">
|
||||
|
||||
<!-- En-tête commande -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h4 class="fw-bold mb-1">{{ job.name }}</h4>
|
||||
{% if job.client_name %}
|
||||
<div class="text-muted small mb-3"><i class="bi bi-building me-1"></i>{{ job.client_name }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
{% set status_map = {'planned':'En cours','done':'Terminé','failed':'Échec','cancelled':'Annulé'} %}
|
||||
{% set slot_statuses = slots | map(attribute='status') | list %}
|
||||
{% if 'done' in slot_statuses %}
|
||||
<span class="badge bg-success fs-6 px-3 py-2">✅ Terminé</span>
|
||||
{% elif 'failed' in slot_statuses %}
|
||||
<span class="badge bg-danger fs-6 px-3 py-2">❌ Incident</span>
|
||||
{% elif slots %}
|
||||
<span class="badge bg-primary fs-6 px-3 py-2">🖨️ En cours</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary fs-6 px-3 py-2">📋 Planifié</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="text-muted small">Pièces commandées</div>
|
||||
<div class="stat-val">{{ job.order_qty or 1 }}</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="text-muted small">Matière</div>
|
||||
<div class="fw-semibold">
|
||||
{% if job.material_name %}
|
||||
{% if job.material_color %}
|
||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;
|
||||
background:{{ job.material_color | css_color }};border:1px solid #ccc;
|
||||
vertical-align:middle;margin-right:4px"></span>
|
||||
{% endif %}
|
||||
{{ job.material_name }}
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="text-muted small">Créé le</div>
|
||||
<div class="fw-semibold">{{ job.created_at[:10] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if job.description %}
|
||||
<hr class="my-3">
|
||||
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{{ job.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline créneaux -->
|
||||
{% if slots %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3"><i class="bi bi-printer me-2"></i>Suivi d'impression</h6>
|
||||
<div class="timeline">
|
||||
{% for s in slots %}
|
||||
<div class="timeline-item {{ s.status }}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ s.printer_name }}</div>
|
||||
<div class="small text-muted">
|
||||
{{ s.planned_start[:16].replace('T', ' ') }} →
|
||||
{{ s.planned_end[11:16] }}
|
||||
</div>
|
||||
{% if s.failure_note %}
|
||||
<div class="small text-danger mt-1">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>{{ s.failure_note }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge
|
||||
{% if s.status == 'done' %}bg-success
|
||||
{% elif s.status == 'failed' %}bg-danger
|
||||
{% elif s.status == 'planned' %}bg-primary
|
||||
{% else %}bg-secondary{% endif %} ms-2">
|
||||
{{ {'done':'Terminé','failed':'Échec','planned':'Planifié','cancelled':'Annulé'}.get(s.status, s.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center text-muted py-4">
|
||||
<i class="bi bi-calendar-plus fs-2 d-block mb-2"></i>
|
||||
Impression non encore planifiée.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center text-muted small">
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Cette page est partagée uniquement pour le suivi de votre commande.
|
||||
Les informations de tarification ne sont pas visibles.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+249
-2
@@ -9,7 +9,20 @@
|
||||
</a>
|
||||
<h1 class="mt-1">{{ job.name }}</h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<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>
|
||||
@@ -38,7 +51,20 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th class="text-muted fw-normal">Fichier source</th>
|
||||
<td class="small font-monospace">{{ job.source_file or '—' }}</td></tr>
|
||||
<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>
|
||||
@@ -207,4 +233,225 @@
|
||||
</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 %}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Connexion — 3D Pricing</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f4f6f9; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-card { background: #fff; border-radius: 14px; box-shadow: 0 4px 24px rgba(0,0,0,.08); padding: 2.5rem 2rem; width: 100%; max-width: 380px; text-align: center; }
|
||||
.brand { font-size: 1.6rem; font-weight: 800; color: #1a1d23; margin-bottom: .25rem; }
|
||||
.brand span { color: #ff6b35; }
|
||||
.subtitle { color: #6b7280; font-size: .875rem; margin-bottom: 2rem; }
|
||||
.btn-authelia { background: #ff6b35; color: #fff; border: none; border-radius: 8px; padding: .75rem 1.5rem; font-size: 1rem; font-weight: 600; width: 100%; transition: background .15s; }
|
||||
.btn-authelia:hover { background: #e85d2d; color: #fff; }
|
||||
.powered { font-size: .75rem; color: #9ca3af; margin-top: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div class="brand"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></div>
|
||||
<div class="subtitle">Gestion & tarification d'impressions 3D</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} mb-3 text-start">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<a href="{{ url_for('login') }}" class="btn btn-authelia">
|
||||
<i class="bi bi-shield-lock me-2"></i>Se connecter avec Authelia
|
||||
</a>
|
||||
|
||||
<div class="powered">
|
||||
Authentification sécurisée via SSO
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<title>3D Pricing — Mobile</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f4f6f9; padding-bottom: 2rem; }
|
||||
.topbar {
|
||||
background: #1a1d23; color: #fff; padding: .75rem 1rem;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.topbar .brand { font-weight: 700; font-size: 1.1rem; }
|
||||
.topbar .brand span { color: #ff6b35; }
|
||||
.section-title {
|
||||
font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: #6b7280; padding: 1rem 1rem .4rem;
|
||||
}
|
||||
.slot-card {
|
||||
background: #fff; border-radius: 10px; padding: .9rem 1rem;
|
||||
margin: .4rem .75rem; box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||
}
|
||||
.slot-card .job-name { font-weight: 600; font-size: .95rem; }
|
||||
.slot-card .meta { font-size: .78rem; color: #6b7280; }
|
||||
.slot-card .time { font-size: .82rem; color: #374151; }
|
||||
.tab-bar {
|
||||
display: flex; background: #fff; border-bottom: 2px solid #f0f0f0;
|
||||
position: sticky; top: 53px; z-index: 99;
|
||||
}
|
||||
.tab-bar button {
|
||||
flex: 1; border: none; background: none; padding: .7rem .5rem;
|
||||
font-size: .8rem; color: #6b7280; font-weight: 600;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -2px;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.tab-bar button.active { color: #ff6b35; border-bottom-color: #ff6b35; }
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
.form-card { background: #fff; border-radius: 10px; margin: .75rem; padding: 1rem; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.btn-primary-orange { background: #ff6b35; color: #fff; border: none; border-radius: 8px; }
|
||||
.btn-primary-orange:hover { background: #e85d2d; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="brand"><i class="bi bi-layers-half me-1"></i>3D<span>Pricing</span></div>
|
||||
<a href="/" class="btn btn-sm btn-outline-light btn-sm">
|
||||
<i class="bi bi-grid me-1"></i>Bureau
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-bar">
|
||||
<button class="active" onclick="showTab('planning', this)">
|
||||
<i class="bi bi-calendar3 d-block mb-1"></i>Planning
|
||||
</button>
|
||||
<button onclick="showTab('block', this)">
|
||||
<i class="bi bi-calendar-x d-block mb-1"></i>Indispo
|
||||
</button>
|
||||
<button onclick="showTab('shift', this)">
|
||||
<i class="bi bi-arrow-left-right d-block mb-1"></i>Décaler
|
||||
</button>
|
||||
<button onclick="showTab('fail', this)">
|
||||
<i class="bi bi-exclamation-triangle d-block mb-1"></i>Échec
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} mx-3 mt-2 mb-0 py-2 small">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- ── Tab : Planning ─────────────────────────────────── -->
|
||||
<div class="tab-pane active" id="tab-planning">
|
||||
<div class="section-title">Prochaines impressions</div>
|
||||
{% if upcoming %}
|
||||
{% for s in upcoming %}
|
||||
<div class="slot-card">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="job-name">{{ s.job_name }}</div>
|
||||
<div class="meta">
|
||||
<i class="bi bi-printer me-1"></i>{{ s.printer_name }}
|
||||
{% if s.client_name %} · {{ s.client_name }}{% endif %}
|
||||
</div>
|
||||
<div class="time mt-1">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
{{ s.planned_start[:16].replace('T',' ') }} →
|
||||
{{ s.planned_end[11:16] }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge {% if s.status=='done' %}bg-success{% elif s.status=='failed' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||
{{ s.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-calendar-check fs-1 d-block mb-2"></i>
|
||||
Aucune impression planifiée.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── Tab : Indisponibilité ──────────────────────────── -->
|
||||
<div class="tab-pane" id="tab-block">
|
||||
<div class="section-title">Ajouter une indisponibilité</div>
|
||||
<div class="form-card">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="block">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Date</label>
|
||||
<input type="date" name="date" class="form-control" value="{{ today }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Type</label>
|
||||
<select name="type" class="form-select">
|
||||
<option value="off">🔴 Jour off (toutes machines)</option>
|
||||
<option value="maintenance">🔧 Maintenance</option>
|
||||
<option value="other">📌 Autre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Machine (optionnel)</label>
|
||||
<select name="printer_id" class="form-select">
|
||||
<option value="">Toutes les machines</option>
|
||||
{% for p in printers %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Note</label>
|
||||
<input type="text" name="notes" class="form-control" placeholder="Raison…">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary-orange w-100">
|
||||
<i class="bi bi-calendar-plus me-1"></i>Ajouter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab : Décaler ──────────────────────────────────── -->
|
||||
<div class="tab-pane" id="tab-shift">
|
||||
<div class="section-title">Décaler un créneau</div>
|
||||
<div class="form-card">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="shift">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Créneau à décaler</label>
|
||||
<select name="slot_id" class="form-select" required>
|
||||
<option value="">Choisir…</option>
|
||||
{% for s in upcoming %}
|
||||
<option value="{{ s.id }}">
|
||||
{{ s.job_name }} — {{ s.planned_start[:16].replace('T',' ') }} [{{ s.printer_name }}]
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Nouveau début</label>
|
||||
<input type="datetime-local" name="new_start" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary-orange w-100">
|
||||
<i class="bi bi-arrow-left-right me-1"></i>Décaler
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab : Échec ────────────────────────────────────── -->
|
||||
<div class="tab-pane" id="tab-fail">
|
||||
<div class="section-title">Signaler un échec d'impression</div>
|
||||
<div class="form-card">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="fail">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Créneau concerné</label>
|
||||
<select name="slot_id" class="form-select" required>
|
||||
<option value="">Choisir…</option>
|
||||
{% for s in upcoming %}
|
||||
<option value="{{ s.id }}">
|
||||
{{ s.job_name }} — {{ s.planned_start[:16].replace('T',' ') }} [{{ s.printer_name }}]
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Description du problème</label>
|
||||
<textarea name="failure_note" class="form-control" rows="3"
|
||||
placeholder="Détachement, sous-extrusion, bourrage…"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger w-100">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Signaler l'échec
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
function showTab(name, btn) {
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-bar button').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
btn.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+183
-13
@@ -50,9 +50,16 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Fichier source</label>
|
||||
<input type="text" name="source_file" id="sourceFile" class="form-control"
|
||||
placeholder="nom_fichier.3mf"
|
||||
value="{{ prefill.source_file if prefill else '' }}">
|
||||
<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">
|
||||
@@ -282,35 +289,109 @@
|
||||
</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 ────────────────────────────────────────────────────────────────
|
||||
// ── 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.textContent = 'Analyse...';
|
||||
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 => {
|
||||
let filled = [];
|
||||
if (d.weight_g) { document.getElementById('weight_g').value = d.weight_g; filled.push('poids'); }
|
||||
if (d.hours != null) { document.getElementById('hours').value = d.hours; filled.push('heures'); }
|
||||
if (d.minutes != null){ document.getElementById('minutes').value = d.minutes; }
|
||||
if (d.filename) {
|
||||
document.getElementById('sourceFile').value = d.filename;
|
||||
if (!document.getElementById('jobName').value)
|
||||
document.getElementById('jobName').value = d.filename;
|
||||
}
|
||||
status.textContent = filled.length ? 'Extrait: ' + filled.join(', ') : 'Donnees non trouvees';
|
||||
status.style.color = filled.length ? '#22c55e' : '#f59e0b';
|
||||
recalc();
|
||||
// ── 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.textContent = 'Erreur de lecture'; status.style.color = '#ef4444'; });
|
||||
.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 ────────────────────────────────────────────────────────
|
||||
@@ -595,5 +676,94 @@ const _initMargin = parseFloat(document.getElementById('gross_margin_pct').value
|
||||
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 %}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Commande introuvable</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>body { background:#f4f6f9; display:flex; align-items:center; justify-content:center; min-height:100vh; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="text-center">
|
||||
<i class="bi bi-question-circle fs-1 text-muted d-block mb-3"></i>
|
||||
<h4>Commande introuvable</h4>
|
||||
<p class="text-muted">Ce lien de suivi est invalide ou a été révoqué.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -194,6 +194,83 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-cloud me-2"></i>Nextcloud — Intégration fichiers 3D</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">URL Nextcloud</label>
|
||||
<input type="url" name="nc_url" class="form-control"
|
||||
placeholder="https://nextcloud.tondomain.com"
|
||||
value="{{ settings.get('nc_url','') }}">
|
||||
<div class="form-text">Sans slash final.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">Identifiant</label>
|
||||
<input type="text" name="nc_username" class="form-control"
|
||||
placeholder="ton_login"
|
||||
value="{{ settings.get('nc_username','') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">Mot de passe d'application</label>
|
||||
<input type="password" name="nc_app_password" class="form-control"
|
||||
placeholder="••••••••••••"
|
||||
value="{{ settings.get('nc_app_password','') }}">
|
||||
<div class="form-text">Générer dans Nextcloud → Paramètres → Sécurité.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Dossier racine 3D</label>
|
||||
<input type="text" name="nc_root_path" class="form-control"
|
||||
placeholder="/Impressions3D"
|
||||
value="{{ settings.get('nc_root_path','/3D') }}">
|
||||
<div class="form-text">Chemin de base pour l'explorateur de fichiers.</div>
|
||||
</div>
|
||||
<div class="col-md-8 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="testNcBtn">
|
||||
<i class="bi bi-plug me-1"></i>Tester la connexion
|
||||
</button>
|
||||
<span id="testNcResult" class="ms-3 small"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iCal -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-calendar-check me-2"></i>Calendrier iCal — Abonnement Android / iOS</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Token secret (optionnel)</label>
|
||||
<input type="text" name="ical_token" class="form-control font-monospace"
|
||||
placeholder="Laisser vide = pas de protection"
|
||||
value="{{ settings.get('ical_token','') }}">
|
||||
<div class="form-text">Si renseigné, l'URL contiendra <code>?token=xxx</code>.</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">URL d'abonnement</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="icalUrl" class="form-control font-monospace small" readonly
|
||||
value="{{ request.host_url }}calendar.ics{% if settings.get('ical_token','') %}?token={{ settings.get('ical_token','') }}{% endif %}">
|
||||
<button class="btn btn-outline-secondary" type="button" id="copyIcal" title="Copier">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Dans Google Calendar → Autres agendas → Depuis une URL.
|
||||
Samsung / iOS : Paramètres → Comptes → Ajouter → Calendrier.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
@@ -203,3 +280,31 @@
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('copyIcal').addEventListener('click', function () {
|
||||
const url = document.getElementById('icalUrl').value;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.innerHTML = '<i class="bi bi-check text-success"></i>';
|
||||
setTimeout(() => { this.innerHTML = '<i class="bi bi-clipboard"></i>'; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('testNcBtn').addEventListener('click', async function () {
|
||||
const res = document.getElementById('testNcResult');
|
||||
res.textContent = 'Test en cours…';
|
||||
try {
|
||||
const r = await fetch('/api/nextcloud/browse');
|
||||
const d = await r.json();
|
||||
if (d.error) {
|
||||
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${d.error}</span>`;
|
||||
} else {
|
||||
res.innerHTML = `<span class="text-success"><i class="bi bi-check-circle me-1"></i>Connexion OK — ${d.items.length} élément(s) dans ${d.path}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${e.message}</span>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user