Files
2026-07-07 12:46:37 +02:00

962 lines
44 KiB
HTML
Raw Permalink 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">
<div class="table-responsive">
<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'] %}
{% set is_nc_path = job.source_file.startswith('/') %}
{% if is_nc_path %}
<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 }}" data-src="nextcloud">
<i class="bi bi-box me-1"></i>Voir
</button>
{% else %}
<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="" data-ext="{{ ext }}" data-src="upload">
<i class="bi bi-upload me-1"></i>Charger pour voir
</button>
{% endif %}
{% endif %}
{% else %}—{% endif %}
</td></tr>
{% if job.plate_name %}
<tr><th class="text-muted fw-normal">Référence plateau</th>
<td><span class="badge bg-light text-dark border">{{ job.plate_name }}</span></td></tr>
{% endif %}
<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>
</div>
{% 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>
<!-- ── Plateaux d'impression ────────────────────────────────────────────── -->
{% set plates_done = slots | selectattr('status','in',['done']) | list | length %}
{% set plates_running = slots | selectattr('status','equalto','running') | list | length %}
{% set plates_planned = slots | selectattr('status','in',['planned','running']) | list | length %}
{% set plates_total = slots | rejectattr('status','in',['cancelled']) | list | length %}
{% set missing_plates = [total_plates_needed - plates_total, 0] | max %}
<div class="row g-4 mt-0">
<div class="col-12">
<div class="card">
<div class="card-header py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<span><i class="bi bi-layers-half me-2"></i>Plateaux d'impression</span>
<div class="d-flex align-items-center gap-2 flex-wrap">
<!-- Progress pill: X/Y plateaux -->
{% if total_plates_needed > 1 %}
<span class="badge rounded-pill
{% if plates_done >= total_plates_needed %}bg-success
{% elif missing_plates > 0 %}bg-warning text-dark
{% else %}bg-primary{% endif %}"
style="font-size:.8rem">
{{ plates_done }}/{{ total_plates_needed }} terminé{{ 's' if plates_done > 1 else '' }}
</span>
{% endif %}
{% if missing_plates > 0 %}
<span class="badge bg-warning text-dark" style="font-size:.75rem">
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ missing_plates }} plateau{{ 'x' if missing_plates > 1 else '' }} à planifier
</span>
{% endif %}
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-primary py-0">
<i class="bi bi-calendar-plus me-1"></i>Planning
</a>
</div>
</div>
{% if slots %}
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th class="small ps-3">#</th>
<th class="small">Début</th>
<th class="small">Machine</th>
<th class="small">Pièces</th>
<th class="small">Statut / Progression</th>
</tr>
</thead>
<tbody>
{% for s in slots %}
{% if s.status != 'cancelled' %}
<tr id="slotRow{{ s.id }}">
<td class="ps-3 text-muted small">{{ loop.index }}</td>
<td class="small" style="white-space:nowrap">
{{ s.planned_start[:16].replace('T',' ') }}
</td>
<td class="small fw-semibold">{{ s.printer_name }}</td>
<td class="small">
{% set ep = s.effective_pieces %}
{% set ig = s.pieces_ignored %}
{% if ig > 0 %}
<span class="text-warning fw-semibold">{{ ep - ig }}/{{ ep }}</span>
<span class="text-muted" style="font-size:.7rem"> ({{ ig }} ignorée{{ 's' if ig > 1 }})</span>
{% else %}
{{ ep }}
{% endif %}
</td>
<td>
{% set sc = {'planned':'secondary','running':'primary','done':'success','failed':'danger'} %}
{% set sl = {'planned':'Planifié','running':'En cours','done':'Terminé','failed':'Échec'} %}
<span class="badge bg-{{ sc.get(s.status,'secondary') }}">{{ sl.get(s.status, s.status) }}</span>
{% if s.status == 'running' and s.ha_entity_prefix %}
<!-- Live HA progress injected by JS -->
<span id="haLive{{ s.id }}" class="ms-2 small text-muted">
<span class="spinner-border spinner-border-sm"></span>
</span>
{% endif %}
{% if s.failure_note %}
<br><span class="text-muted" style="font-size:.72rem">{{ s.failure_note }}</span>
{% endif %}
</td>
</tr>
<!-- HA live progress bar row (hidden until JS fills it) -->
{% if s.status == 'running' and s.ha_entity_prefix %}
<tr id="haBar{{ s.id }}" class="table-primary">
<td colspan="5" class="px-3 py-1">
<div class="progress" style="height:6px;border-radius:3px">
<div id="haBarInner{{ s.id }}" class="progress-bar bg-primary" style="width:0%"></div>
</div>
<div id="haBarInfo{{ s.id }}" class="text-muted mt-1" style="font-size:.72rem"></div>
</td>
</tr>
{% endif %}
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-center text-muted py-4 small">
<i class="bi bi-calendar-x fs-3 d-block mb-2"></i>
Aucun créneau planifié pour cette commande.
<br><a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-primary mt-2">
<i class="bi bi-calendar-plus me-1"></i>Aller au planning
</a>
</div>
{% endif %}
<!-- Global progress bar for multi-plate jobs -->
{% if total_plates_needed > 1 %}
<div class="card-footer py-2 px-3" style="background:#fafafa;border-top:1px solid #f0f0f0">
{% set pct = ((plates_done / total_plates_needed) * 100) | round(0) | int %}
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Progression commande</small>
<small class="fw-semibold">{{ pct }}%</small>
</div>
<div class="progress" style="height:8px;border-radius:4px">
<div class="progress-bar bg-success" style="width:{{ pct }}%"></div>
{% if plates_running > 0 %}
<div class="progress-bar bg-primary progress-bar-striped progress-bar-animated"
style="width:{{ ((plates_running / total_plates_needed) * 100) | round(0) | int }}%"></div>
{% endif %}
</div>
<div class="mt-1 d-flex gap-3" style="font-size:.72rem;color:#6b7280">
<span><i class="bi bi-check-circle-fill text-success me-1"></i>{{ plates_done }} terminé{{ 's' if plates_done > 1 }}</span>
{% if plates_running %}<span><i class="bi bi-arrow-repeat text-primary me-1"></i>{{ plates_running }} en cours</span>{% endif %}
{% set plates_sched = plates_planned - plates_running %}
{% if plates_sched %}<span><i class="bi bi-clock text-secondary me-1"></i>{{ plates_sched }} planifié{{ 's' if plates_sched > 1 }}</span>{% endif %}
{% if missing_plates %}<span class="text-warning"><i class="bi bi-exclamation-triangle-fill me-1"></i>{{ missing_plates }} manquant{{ 's' if missing_plates > 1 }}</span>{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- ── Plateaux d'impression ────────────────────────────────────────────── -->
{% set plates_done = slots | selectattr('status','in',['done']) | list | length %}
{% set plates_running = slots | selectattr('status','equalto','running') | list | length %}
{% set plates_planned = slots | selectattr('status','in',['planned','running']) | list | length %}
{% set plates_total = slots | rejectattr('status','in',['cancelled']) | list | length %}
{% set missing_plates = [total_plates_needed - plates_total, 0] | max %}
<div class="row g-4 mt-0">
<div class="col-12">
<div class="card">
<div class="card-header py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<span><i class="bi bi-layers-half me-2"></i>Plateaux d'impression</span>
<div class="d-flex align-items-center gap-2 flex-wrap">
{% if total_plates_needed > 1 %}
<span class="badge rounded-pill
{% if plates_done >= total_plates_needed %}bg-success
{% elif missing_plates > 0 %}bg-warning text-dark
{% else %}bg-primary{% endif %}"
style="font-size:.8rem">
{{ plates_done }}/{{ total_plates_needed }} terminé{{ 's' if plates_done > 1 else '' }}
</span>
{% endif %}
{% if missing_plates > 0 %}
<span class="badge bg-warning text-dark" style="font-size:.75rem">
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ missing_plates }} plateau{{ 'x' if missing_plates > 1 else '' }} à planifier
</span>
{% endif %}
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-primary py-0">
<i class="bi bi-calendar-plus me-1"></i>Planning
</a>
</div>
</div>
{% if slots %}
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th class="small ps-3">#</th>
<th class="small">Début</th>
<th class="small">Machine</th>
<th class="small">Pièces</th>
<th class="small">Statut / Progression</th>
</tr>
</thead>
<tbody>
{% for s in slots %}
{% if s.status != 'cancelled' %}
<tr id="slotRow{{ s.id }}">
<td class="ps-3 text-muted small">{{ loop.index }}</td>
<td class="small" style="white-space:nowrap">{{ s.planned_start[:16].replace('T',' ') }}</td>
<td class="small fw-semibold">{{ s.printer_name }}</td>
<td class="small">
{% set ep = s.effective_pieces %}
{% set ig = s.pieces_ignored %}
{% if ig > 0 %}
<span class="text-warning fw-semibold">{{ ep - ig }}/{{ ep }}</span>
<span class="text-muted" style="font-size:.7rem"> ({{ ig }} ignorée{{ 's' if ig > 1 }})</span>
{% else %}{{ ep }}{% endif %}
</td>
<td>
{% set sc = {'planned':'secondary','running':'primary','done':'success','failed':'danger'} %}
{% set sl = {'planned':'Planifié','running':'En cours','done':'Terminé','failed':'Échec'} %}
<span class="badge bg-{{ sc.get(s.status,'secondary') }}">{{ sl.get(s.status, s.status) }}</span>
{% if s.status == 'running' and s.ha_entity_prefix %}
<span id="haLive{{ s.id }}" class="ms-2 small text-muted">
<span class="spinner-border spinner-border-sm"></span>
</span>
{% endif %}
{% if s.failure_note %}<br><span class="text-muted" style="font-size:.72rem">{{ s.failure_note }}</span>{% endif %}
</td>
</tr>
{% if s.status == 'running' and s.ha_entity_prefix %}
<tr id="haBar{{ s.id }}" class="table-primary">
<td colspan="5" class="px-3 py-1">
<div class="progress" style="height:6px;border-radius:3px">
<div id="haBarInner{{ s.id }}" class="progress-bar bg-primary" style="width:0%"></div>
</div>
<div id="haBarInfo{{ s.id }}" class="text-muted mt-1" style="font-size:.72rem"></div>
</td>
</tr>
{% endif %}
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-center text-muted py-4 small">
<i class="bi bi-calendar-x fs-3 d-block mb-2"></i>Aucun créneau planifié.
<br><a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-primary mt-2">
<i class="bi bi-calendar-plus me-1"></i>Aller au planning
</a>
</div>
{% endif %}
{% if total_plates_needed > 1 %}
<div class="card-footer py-2 px-3" style="background:#fafafa;border-top:1px solid #f0f0f0">
{% set pct = ((plates_done / total_plates_needed) * 100) | round(0) | int %}
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Progression commande</small>
<small class="fw-semibold">{{ pct }}%</small>
</div>
<div class="progress" style="height:8px;border-radius:4px">
<div class="progress-bar bg-success" style="width:{{ pct }}%"></div>
{% if plates_running > 0 %}
<div class="progress-bar bg-primary progress-bar-striped progress-bar-animated"
style="width:{{ ((plates_running / total_plates_needed) * 100) | round(0) | int }}%"></div>
{% endif %}
</div>
<div class="mt-1 d-flex gap-3" style="font-size:.72rem;color:#6b7280">
<span><i class="bi bi-check-circle-fill text-success me-1"></i>{{ plates_done }} terminé{{ 's' if plates_done > 1 }}</span>
{% if plates_running %}<span><i class="bi bi-arrow-repeat text-primary me-1"></i>{{ plates_running }} en cours</span>{% endif %}
{% set plates_sched = plates_planned - plates_running %}
{% if plates_sched %}<span><i class="bi bi-clock text-secondary me-1"></i>{{ plates_sched }} planifié{{ 's' if plates_sched > 1 }}</span>{% endif %}
{% if missing_plates %}<span class="text-warning"><i class="bi bi-exclamation-triangle-fill me-1"></i>{{ missing_plates }} manquant{{ 's' if missing_plates > 1 }}</span>{% endif %}
</div>
</div>
{% endif %}
</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 id="viewerUploadZone" class="p-4 text-center" style="display:none">
<i class="bi bi-cloud-upload fs-1 text-white-50 d-block mb-3"></i>
<p class="text-white-50 mb-3">Le fichier source n'est pas sur Nextcloud.<br>Chargez-le manuellement pour le visualiser.</p>
<input type="file" id="viewerLocalFile" class="form-control form-control-sm mx-auto" style="max-width:300px" accept=".stl,.3mf,.obj">
</div>
<div class="modal-body p-0 position-relative" style="height:75vh" id="viewerCanvasWrap">
<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 %}
<!-- ── Live HA status for running slots ──────────────────────────────────── -->
{% set running_slots = slots | selectattr('status','equalto','running') | selectattr('ha_entity_prefix') | list %}
{% if running_slots %}
<script>
(async function pollHA() {
const runningPrinters = {
{% for s in running_slots %}
"{{ s.printer_id }}": { slotId: {{ s.id }}, prefix: "{{ s.ha_entity_prefix }}" },
{% endfor %}
};
async function refresh() {
try {
const data = await fetch('/api/ha/status').then(r => r.json());
for (const [printerId, info] of Object.entries(runningPrinters)) {
const p = data[printerId];
if (!p) continue;
const liveEl = document.getElementById('haLive' + info.slotId);
const barEl = document.getElementById('haBarInner' + info.slotId);
const barInfo = document.getElementById('haBarInfo' + info.slotId);
if (!liveEl) continue;
const pct = p.progress || 0;
const layer = p.layer && p.layers_total ? `Couche ${p.layer}/${p.layers_total}` : '';
const fin = p.heure_fin ? `Fin ≈ ${p.heure_fin.slice(11,16)}` : '';
liveEl.innerHTML = `<strong>${pct}%</strong>${layer ? ' · ' + layer : ''}${fin ? ' · ' + fin : ''}`;
if (barEl) barEl.style.width = pct + '%';
if (barInfo) barInfo.textContent = [layer, fin].filter(Boolean).join(' · ');
}
} catch(e) {}
setTimeout(refresh, 30000);
}
refresh();
})();
</script>
{% endif %}
<!-- ── Live HA status for running slots ──────────────────────────────────── -->
{% set running_slots = slots | selectattr('status','equalto','running') | selectattr('ha_entity_prefix') | list %}
{% if running_slots %}
<script>
(async function pollHA() {
const HA_POLL_MS = {{ ha_poll_interval * 1000 }};
const runningPrinters = {
{% for s in running_slots %}
"{{ s.printer_id }}": { slotId: {{ s.id }}, prefix: "{{ s.ha_entity_prefix }}" },
{% endfor %}
};
async function refresh() {
try {
const data = await fetch('/api/ha/status').then(r => r.json());
for (const [printerId, info] of Object.entries(runningPrinters)) {
const p = data[printerId];
if (!p) continue;
const liveEl = document.getElementById('haLive' + info.slotId);
const barEl = document.getElementById('haBarInner' + info.slotId);
const barInfo = document.getElementById('haBarInfo' + info.slotId);
if (!liveEl) continue;
const pct = p.progress || 0;
const layer = p.layer && p.layers_total ? `Couche ${p.layer}/${p.layers_total}` : '';
const fin = p.heure_fin ? `Fin ≈ ${p.heure_fin.slice(11,16)}` : '';
liveEl.innerHTML = `<strong>${pct}%</strong>${layer ? ' · ' + layer : ''}${fin ? ' · ' + fin : ''}`;
if (barEl) barEl.style.width = pct + '%';
if (barInfo) barInfo.textContent = [layer, fin].filter(Boolean).join(' · ');
}
} catch(e) {}
setTimeout(refresh, HA_POLL_MS);
}
refresh();
})();
</script>
{% endif %}
<!-- JSZip pour parser les 3MF Bambu Studio -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<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();
}
const uploadZone = document.getElementById('viewerUploadZone');
const canvasWrap = document.getElementById('viewerCanvasWrap');
const localFileIn = document.getElementById('viewerLocalFile');
function makeMat() {
return new THREE.MeshPhongMaterial({ color: 0xff6b35, specular: 0x333333, shininess: 40, side: THREE.DoubleSide });
}
function loadFromUrl(url, ext) {
loader.style.display = '';
errDiv.style.display = 'none';
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
if (ext === 'stl') {
const mat = makeMat();
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, (e) => {
loader.style.display = 'none';
errDiv.style.display = '';
errMsg.textContent = 'Erreur STL : ' + (e.message || e);
});
} else if (ext === '3mf') {
// Parser Bambu custom : lit directement les 3D/Objects/*.model
loadBambu3MF(url);
} else {
loader.style.display = 'none';
errDiv.style.display = '';
errMsg.textContent = `Format .${ext} non supporté (STL et 3MF uniquement).`;
}
}
async function loadBambu3MF(url) {
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const buffer = await resp.arrayBuffer();
const JSZip = window.JSZip;
const zip = await JSZip.loadAsync(buffer);
const allPositions = [];
const tmpVec = new THREE.Vector3();
// ── Stratégie A : fichiers externes 3D/Objects/*.model (projet Bambu) ──
const objectFiles = Object.keys(zip.files)
.filter(n => n.startsWith('3D/Objects/') && n.endsWith('.model'));
if (objectFiles.length) {
// Lire les transforms depuis 3dmodel.model
const transforms = {};
const mainFile = zip.files['3D/3dmodel.model'];
if (mainFile) {
const mainXml = await mainFile.async('string');
const doc = new DOMParser().parseFromString(mainXml, 'text/xml');
doc.querySelectorAll('component').forEach(c => {
const path = (c.getAttribute('p:path') || '').replace(/^\//, '');
const objId = c.getAttribute('objectid');
const tStr = c.getAttribute('transform');
if (path && objId) transforms[path + '#' + objId] = tStr || null;
});
}
for (const objFile of objectFiles) {
const xmlStr = await zip.files[objFile].async('string');
const doc = new DOMParser().parseFromString(xmlStr, 'text/xml');
for (const objEl of doc.querySelectorAll('object')) {
const meshEl = objEl.querySelector('mesh');
if (!meshEl) continue;
const objId = objEl.getAttribute('id');
const tStr = transforms[objFile + '#' + objId];
let matrix = null;
if (tStr) {
const v = tStr.trim().split(/\s+/).map(Number);
if (v.length === 12) {
matrix = new THREE.Matrix4();
matrix.set(v[0],v[3],v[6],v[9], v[1],v[4],v[7],v[10], v[2],v[5],v[8],v[11], 0,0,0,1);
}
}
parseMeshEl(meshEl, matrix, tmpVec, allPositions);
}
}
// ── Stratégie B : géométrie inline dans 3dmodel.model (generic / standard 3MF) ──
} else {
const mainFile = zip.files['3D/3dmodel.model'];
if (!mainFile) throw new Error('Aucun fichier 3D trouvé dans le 3MF');
const mainXml = await mainFile.async('string');
const doc = new DOMParser().parseFromString(mainXml, 'text/xml');
// Construire la map id→transform depuis <build><item>
const buildTransforms = {};
doc.querySelectorAll('build item').forEach(item => {
const objId = item.getAttribute('objectid');
const tStr = item.getAttribute('transform');
buildTransforms[objId] = tStr || null;
});
for (const objEl of doc.querySelectorAll('resources object')) {
const meshEl = objEl.querySelector('mesh');
if (!meshEl) continue;
const objId = objEl.getAttribute('id');
const tStr = buildTransforms[objId];
let matrix = null;
if (tStr) {
const v = tStr.trim().split(/\s+/).map(Number);
if (v.length === 12) {
matrix = new THREE.Matrix4();
matrix.set(v[0],v[3],v[6],v[9], v[1],v[4],v[7],v[10], v[2],v[5],v[8],v[11], 0,0,0,1);
}
}
parseMeshEl(meshEl, matrix, tmpVec, allPositions);
}
}
if (!allPositions.length) throw new Error('Aucune géométrie dans le 3MF');
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(allPositions, 3));
geo.computeVertexNormals();
modelMesh = new THREE.Mesh(geo, makeMat());
modelMesh.rotation.x = -Math.PI / 2;
scene.add(modelMesh);
loader.style.display = 'none';
fitCamera();
} catch (e) {
loader.style.display = 'none';
errDiv.style.display = '';
errMsg.textContent = 'Erreur 3MF : ' + (e.message || e);
}
}
function parseMeshEl(meshEl, matrix, tmpVec, allPositions) {
const verts = Array.from(meshEl.querySelectorAll('vertices vertex'))
.map(v => [+v.getAttribute('x'), +v.getAttribute('y'), +v.getAttribute('z')]);
for (const tri of meshEl.querySelectorAll('triangles triangle')) {
for (const attr of ['v1', 'v2', 'v3']) {
const [x, y, z] = verts[+tri.getAttribute(attr)];
if (matrix) {
tmpVec.set(x, y, z).applyMatrix4(matrix);
allPositions.push(tmpVec.x, tmpVec.y, tmpVec.z);
} else {
allPositions.push(x, y, z);
}
}
}
}
function showUploadZone(ext) {
uploadZone.style.display = '';
canvasWrap.style.display = 'none';
loader.style.display = 'none';
// Reset file input
localFileIn.value = '';
localFileIn.onchange = (ev) => {
const file = ev.target.files[0];
if (!file) return;
const detectedExt = file.name.split('.').pop().toLowerCase();
const objectUrl = URL.createObjectURL(file);
title.textContent = file.name;
uploadZone.style.display = 'none';
canvasWrap.style.display = '';
initRenderer();
loadFromUrl(objectUrl, detectedExt);
// Revoke the blob URL after loading
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
};
}
// Ouvrir la modale
modal.addEventListener('show.bs.modal', e => {
const btn = e.relatedTarget;
const path = btn.dataset.file;
const ext = (btn.dataset.ext || '').toLowerCase();
const src = btn.dataset.src;
if (src === 'upload') {
title.textContent = 'Charger un fichier local';
uploadZone.style.display = '';
canvasWrap.style.display = 'none';
showUploadZone(ext);
} else {
title.textContent = path.split('/').pop();
uploadZone.style.display = 'none';
canvasWrap.style.display = '';
initRenderer();
loadFromUrl('/api/nextcloud/file?path=' + encodeURIComponent(path), ext);
}
});
// Fermer → libérer
modal.addEventListener('hidden.bs.modal', () => {
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
uploadZone.style.display = 'none';
canvasWrap.style.display = '';
});
// 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 %}