962 lines
44 KiB
HTML
962 lines
44 KiB
HTML
{% 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 %}
|