405 lines
19 KiB
HTML
405 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Impression — JH Photomaton{% endblock %}
|
|
|
|
{% block nav_links %}
|
|
<a href="/admin">Dashboard</a>
|
|
<a href="/admin/gallery">Galerie</a>
|
|
<a href="/admin/actions">Actions</a>
|
|
<a href="/admin/print" class="active">Impression</a>
|
|
<a href="/admin/settings">Réglages</a>
|
|
<a href="/admin/events">Événements</a>
|
|
<a href="/admin/logout">Déconnexion</a>
|
|
{% endblock %}
|
|
|
|
{% block head %}
|
|
<style>
|
|
.print-grid { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; display: flex; flex-direction: column; gap: 1.5rem; }
|
|
|
|
/* ── Imprimantes ──────────────────────────────────────────────────────────── */
|
|
.printers-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
|
.printer-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 1.25rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .85rem;
|
|
}
|
|
.printer-card.state-disabled { border-color: rgba(224,123,0,.4); }
|
|
.printer-card.state-offline { border-color: rgba(192,0,0,.3); opacity: .7; }
|
|
.printer-card.state-printing { border-color: var(--primary); }
|
|
|
|
.printer-head { display: flex; align-items: center; gap: .75rem; }
|
|
.printer-icon { font-size: 2rem; }
|
|
.printer-name { font-weight: 700; font-size: 1.05rem; }
|
|
.printer-sublabel { font-size: .78rem; color: var(--text-muted); }
|
|
|
|
.printer-states { display: flex; gap: .5rem; flex-wrap: wrap; }
|
|
|
|
.badge-printer {
|
|
display: inline-flex; align-items: center; gap: .3rem;
|
|
padding: .25rem .65rem; border-radius: 20px; font-size: .8rem; font-weight: 700;
|
|
}
|
|
.bs-idle { background: rgba(26,115,64,.2); color: #4caf50; }
|
|
.bs-printing { background: rgba(25,108,176,.2); color: var(--primary); }
|
|
.bs-disabled { background: rgba(224,123,0,.2); color: #e07b00; }
|
|
.bs-offline { background: rgba(192,0,0,.2); color: #e05050; }
|
|
.bs-unknown { background: rgba(128,128,128,.2);color: #aaa; }
|
|
.bs-accept { background: rgba(26,115,64,.15); color: #66bb6a; border: 1px solid rgba(26,115,64,.3); }
|
|
.bs-reject { background: rgba(192,0,0,.12); color: #ef5350; border: 1px solid rgba(192,0,0,.2); }
|
|
|
|
.printer-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
|
|
.btn-xs { padding: .3rem .65rem; font-size: .8rem; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; transition: opacity .2s; }
|
|
.btn-xs:hover { opacity: .8; }
|
|
.btn-enable { background: #1a7340; color: #fff; }
|
|
.btn-disable { background: #784700; color: #fff; }
|
|
.btn-clear { background: #7a0000; color: #fff; }
|
|
.btn-reject { background: #555; color: #fff; }
|
|
|
|
/* Jobs CUPS détaillés */
|
|
.cups-jobs { font-size: .82rem; }
|
|
.cups-job-row {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: .3rem .5rem;
|
|
border-bottom: 1px solid rgba(255,255,255,.05);
|
|
gap: .5rem;
|
|
}
|
|
.cups-job-id { font-family: monospace; color: var(--primary); }
|
|
.cups-job-info { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.cups-empty { color: var(--text-muted); font-size: .82rem; font-style: italic; padding: .4rem 0; }
|
|
|
|
/* ── Mode d'impression ────────────────────────────────────────────────────── */
|
|
.mode-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; }
|
|
.mode-options { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: .75rem; }
|
|
.mode-option { display: flex; align-items: center; gap: .5rem; cursor: pointer; }
|
|
.mode-option input[type=radio] { accent-color: var(--primary); width: 1rem; height: 1rem; }
|
|
.mode-option label { cursor: pointer; font-size: .95rem; }
|
|
.mode-hint { font-size: .8rem; color: var(--text-muted); line-height: 1.5; }
|
|
|
|
/* ── File d'attente ───────────────────────────────────────────────────────── */
|
|
.queue-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
|
.queue-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); }
|
|
.queue-title { font-size: 1rem; font-weight: 700; color: var(--text); }
|
|
.queue-table { width: 100%; border-collapse: collapse; }
|
|
.queue-table th { padding: .6rem 1rem; text-align: left; font-size: .8rem; color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--border); }
|
|
.queue-table td { padding: .65rem 1rem; border-bottom: 1px solid rgba(255,255,255,.04); font-size: .9rem; vertical-align: middle; }
|
|
.print-thumb { width: 48px; height: 32px; object-fit: cover; border-radius: 4px; background: var(--bg); }
|
|
.copies-input { width: 50px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 4px; padding: 2px 6px; text-align: center; }
|
|
.queue-empty { text-align: center; padding: 2rem; color: var(--text-muted); font-size: .95rem; }
|
|
|
|
/* ── Badges ───────────────────────────────────────────────────────────────── */
|
|
.badge { display: inline-flex; align-items: center; padding: .2rem .55rem; border-radius: 20px; font-size: .78rem; font-weight: 700; }
|
|
.badge-success { background: rgba(26,115,64,.2); color: #4caf50; }
|
|
.badge-info { background: rgba(25,108,176,.2); color: var(--primary); }
|
|
.badge-warning { background: rgba(224,123,0,.2); color: #e07b00; }
|
|
.badge-error { background: rgba(192,0,0,.2); color: #e05050; }
|
|
|
|
/* Section title */
|
|
.section-title { font-size: 1rem; font-weight: 700; color: var(--text); margin-bottom: .75rem; display: flex; align-items: center; gap: .4rem; }
|
|
|
|
/* Inline buttons */
|
|
.btn-sm { padding: .35rem .75rem; border: none; border-radius: 6px; cursor: pointer; font-size: .85rem; font-weight: 600; transition: opacity .2s; }
|
|
.btn-sm:hover { opacity: .8; }
|
|
.btn-print { background: var(--primary); color: #fff; }
|
|
.btn-cancel { background: rgba(192,0,0,.25); color: #e05050; border: 1px solid rgba(192,0,0,.3); }
|
|
.btn-refresh { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="print-grid">
|
|
|
|
<!-- ── IMPRIMANTES ─────────────────────────────────────────────────────── -->
|
|
<div>
|
|
<div class="section-title">🖨 Imprimantes CUPS</div>
|
|
<div class="printers-row" id="printers-container">
|
|
{% for p in printers %}
|
|
<div class="printer-card state-{{ p.state }}" id="pc-{{ p.name }}">
|
|
|
|
<div class="printer-head">
|
|
<span class="printer-icon">
|
|
{% if p.state == 'printing' %}🔵{% elif p.state == 'idle' %}🟢{% elif p.state == 'disabled' %}🟠{% else %}🔴{% endif %}
|
|
</span>
|
|
<div>
|
|
<div class="printer-name">{{ p.label or p.name }}</div>
|
|
<div class="printer-sublabel">{{ p.name }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="printer-states">
|
|
<span class="badge-printer bs-{{ p.state }}">
|
|
{% if p.state == 'idle' %}✅ Disponible
|
|
{% elif p.state == 'printing' %}🖨 En impression
|
|
{% elif p.state == 'disabled' %}⏸ Désactivée
|
|
{% elif p.state == 'offline' %}❌ Hors ligne
|
|
{% else %}❓ {{ p.state }}{% endif %}
|
|
</span>
|
|
<span class="badge-printer {{ 'bs-accept' if p.accepting else 'bs-reject' }}">
|
|
{{ '✓ Accepte les jobs' if p.accepting else '✗ Refuse les jobs' }}
|
|
</span>
|
|
{% if p.jobs_count %}
|
|
<span class="badge-printer bs-printing">{{ p.jobs_count }} job{{ 's' if p.jobs_count > 1 else '' }}</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Actions imprimante -->
|
|
<div class="printer-actions">
|
|
{% if p.state == 'disabled' %}
|
|
<button class="btn-xs btn-enable" onclick="printerAction('{{ p.name }}','enable')">▶ Activer</button>
|
|
{% else %}
|
|
<button class="btn-xs btn-disable" onclick="printerAction('{{ p.name }}','disable')">⏸ Désactiver</button>
|
|
{% endif %}
|
|
{% if p.accepting %}
|
|
<button class="btn-xs btn-reject" onclick="printerAction('{{ p.name }}','reject')">🚫 Refuser jobs</button>
|
|
{% else %}
|
|
<button class="btn-xs btn-enable" onclick="printerAction('{{ p.name }}','enable')">✓ Accepter jobs</button>
|
|
{% endif %}
|
|
{% if p.jobs_count %}
|
|
<button class="btn-xs btn-clear" onclick="clearJobs('{{ p.name }}')">✕ Vider file</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Jobs CUPS en cours -->
|
|
<div class="cups-jobs" id="jobs-{{ p.name }}">
|
|
{% if p.jobs %}
|
|
{% for j in p.jobs %}
|
|
<div class="cups-job-row">
|
|
<span class="cups-job-id">{{ j.id }}</span>
|
|
<span class="cups-job-info">{{ j.user }} — {{ j.size }}</span>
|
|
<button class="btn-xs btn-clear" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelJob('{{ j.id }}')">✕</button>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="cups-empty">Aucun job en cours</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
<div style="margin-top:.5rem">
|
|
<button class="btn-sm btn-refresh" onclick="refreshPrinters()">↻ Actualiser imprimantes</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── MODE D'IMPRESSION ──────────────────────────────────────────────── -->
|
|
<div>
|
|
<div class="section-title">⚙️ Mode d'impression</div>
|
|
<div class="mode-card">
|
|
<div class="mode-options">
|
|
<label class="mode-option">
|
|
<input type="radio" name="print-mode" value="direct" {% if config.print.mode=='direct' %}checked{% endif %}>
|
|
<label>🚀 Direct (auto)</label>
|
|
</label>
|
|
<label class="mode-option">
|
|
<input type="radio" name="print-mode" value="validation" {% if config.print.mode=='validation' %}checked{% endif %}>
|
|
<label>✋ Validation admin</label>
|
|
</label>
|
|
<label class="mode-option">
|
|
<input type="radio" name="print-mode" value="gallery" {% if config.print.mode=='gallery' %}checked{% endif %}>
|
|
<label>🖼 File galerie</label>
|
|
</label>
|
|
</div>
|
|
<div class="mode-hint">
|
|
<b>Direct</b> : chaque demande est imprimée immédiatement (load-balancing automatique entre les Selphy).
|
|
<b>Validation</b> : l'admin doit valider chaque impression via cette page.
|
|
<b>File galerie</b> : les demandes s'accumulent sans impression automatique.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── FILE D'ATTENTE ─────────────────────────────────────────────────── -->
|
|
<div>
|
|
<div class="queue-card">
|
|
<div class="queue-header">
|
|
<div class="queue-title">📋 File d'attente (<span id="pending-count">{{ queue|selectattr('status','equalto','pending')|list|length }}</span> en attente)</div>
|
|
<button class="btn-sm btn-refresh" onclick="refreshQueue()">↻ Actualiser</button>
|
|
</div>
|
|
|
|
<div id="queue-container">
|
|
{% if not queue %}
|
|
<div class="queue-empty">✅ Aucune impression enregistrée</div>
|
|
{% else %}
|
|
<table class="queue-table">
|
|
<thead><tr>
|
|
<th>Aperçu</th>
|
|
<th>Fichier</th>
|
|
<th>Copies</th>
|
|
<th>Statut</th>
|
|
<th>Demandé le</th>
|
|
<th>Imprimante</th>
|
|
<th>Actions</th>
|
|
</tr></thead>
|
|
<tbody id="queue-tbody">
|
|
{% for q in queue %}
|
|
<tr id="row-{{ q.id }}">
|
|
<td><img src="{{ q.thumb_url }}" class="print-thumb" onerror="this.style.opacity=0"></td>
|
|
<td style="font-size:.82rem;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
|
{{ q.filename.split('/')[-1] }}
|
|
</td>
|
|
<td>
|
|
{% if q.status == 'pending' %}
|
|
<input type="number" value="{{ q.copies }}" min="1" max="3"
|
|
id="copies-{{ q.id }}" class="copies-input">
|
|
{% else %}
|
|
{{ q.copies }}
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<span class="badge
|
|
{% if q.status == 'pending' %}badge-warning
|
|
{% elif q.status == 'done' %}badge-success
|
|
{% elif q.status == 'printing' %}badge-info
|
|
{% elif q.status == 'cancelled' %}badge-error
|
|
{% else %}badge-error{% endif %}">
|
|
{% if q.status == 'pending' %}En attente
|
|
{% elif q.status == 'done' %}Imprimé
|
|
{% elif q.status == 'printing' %}En cours
|
|
{% elif q.status == 'cancelled' %}Annulé
|
|
{% else %}Erreur{% endif %}
|
|
</span>
|
|
</td>
|
|
<td style="font-size:.78rem;color:var(--text-muted)">
|
|
{{ q.requested_at|int|timestamp_to_date if q.requested_at else '—' }}
|
|
</td>
|
|
<td style="font-size:.82rem">{{ q.printer or '—' }}</td>
|
|
<td style="display:flex;gap:.4rem;align-items:center">
|
|
{% if q.status == 'pending' %}
|
|
<button class="btn-sm btn-print" onclick="executePrint('{{ q.id }}')">🖨 Imprimer</button>
|
|
<button class="btn-sm btn-cancel" onclick="cancelPrint('{{ q.id }}')">✕</button>
|
|
{% elif q.status == 'error' %}
|
|
<span style="font-size:.75rem;color:#e05050">{{ q.error_msg or 'Erreur' }}</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
// ── Mode impression ────────────────────────────────────────────────────────────
|
|
document.querySelectorAll('input[name="print-mode"]').forEach(r => {
|
|
r.addEventListener('change', async (e) => {
|
|
try {
|
|
await api('POST', `/api/print/mode?mode=${e.target.value}`);
|
|
showToast(`Mode d'impression : ${e.target.value}`, 'success');
|
|
} catch(err) { showToast('Erreur', 'error'); }
|
|
});
|
|
});
|
|
|
|
// ── Imprimantes ────────────────────────────────────────────────────────────────
|
|
async function refreshPrinters() {
|
|
try {
|
|
const printers = await api('GET', '/api/print/printers');
|
|
printers.forEach(p => {
|
|
// Met à jour les badges et compteur de jobs
|
|
const card = document.getElementById('pc-' + p.name);
|
|
if (!card) return;
|
|
card.className = `printer-card state-${p.state}`;
|
|
// Actualise les jobs CUPS
|
|
const jobsDiv = document.getElementById('jobs-' + p.name);
|
|
if (jobsDiv) {
|
|
if (p.jobs && p.jobs.length) {
|
|
jobsDiv.innerHTML = p.jobs.map(j => `
|
|
<div class="cups-job-row">
|
|
<span class="cups-job-id">${j.id}</span>
|
|
<span class="cups-job-info">${j.user} — ${j.size}</span>
|
|
<button class="btn-xs btn-clear" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelJob('${j.id}')">✕</button>
|
|
</div>`).join('');
|
|
} else {
|
|
jobsDiv.innerHTML = '<div class="cups-empty">Aucun job en cours</div>';
|
|
}
|
|
}
|
|
});
|
|
} catch(err) {}
|
|
}
|
|
|
|
async function printerAction(name, action) {
|
|
try {
|
|
await api('POST', `/api/print/printers/${name}/${action}`);
|
|
showToast(`${name} : ${action}`, 'success');
|
|
setTimeout(refreshPrinters, 800);
|
|
} catch(err) { showToast('Erreur : ' + err.message, 'error'); }
|
|
}
|
|
|
|
async function clearJobs(printer) {
|
|
if (!confirm(`Vider toute la file CUPS de ${printer} ?`)) return;
|
|
try {
|
|
await api('POST', `/api/print/cups/cancel/${printer}`);
|
|
showToast('File vidée', 'success');
|
|
setTimeout(refreshPrinters, 500);
|
|
} catch(err) { showToast('Erreur', 'error'); }
|
|
}
|
|
|
|
async function cancelJob(jobId) {
|
|
try {
|
|
await api('DELETE', `/api/print/printers/jobs/${jobId}`);
|
|
showToast(`Job ${jobId} annulé`, 'info');
|
|
setTimeout(refreshPrinters, 500);
|
|
} catch(err) { showToast('Erreur', 'error'); }
|
|
}
|
|
|
|
// ── File d'attente ─────────────────────────────────────────────────────────────
|
|
async function refreshQueue() {
|
|
try {
|
|
const data = await api('GET', '/api/print/queue');
|
|
const queue = data.queue || [];
|
|
const pending = queue.filter(q => q.status === 'pending').length;
|
|
document.getElementById('pending-count').textContent = pending;
|
|
|
|
// Mise à jour des badges de statut existants
|
|
queue.forEach(q => {
|
|
const row = document.getElementById('row-' + q.id);
|
|
if (row) {
|
|
const badge = row.querySelector('.badge');
|
|
if (badge && q.status !== badge.textContent.trim()) {
|
|
const labels = { pending:'En attente', done:'Imprimé', printing:'En cours', cancelled:'Annulé', error:'Erreur' };
|
|
badge.textContent = labels[q.status] || q.status;
|
|
}
|
|
}
|
|
});
|
|
} catch(err) {}
|
|
}
|
|
|
|
async function executePrint(id) {
|
|
const copiesEl = document.getElementById('copies-' + id);
|
|
const copies = copiesEl ? parseInt(copiesEl.value) : 1;
|
|
try {
|
|
const r = await api('POST', `/api/print/execute/${id}?copies=${copies}`);
|
|
showToast(r.success ? `✅ Impression lancée (${r.printer})` : `❌ ${r.error}`, r.success ? 'success' : 'error');
|
|
setTimeout(refreshQueue, 1200);
|
|
} catch(err) { showToast('Erreur impression', 'error'); }
|
|
}
|
|
|
|
async function cancelPrint(id) {
|
|
try {
|
|
await api('POST', `/api/print/cancel/${id}`);
|
|
showToast('Impression annulée', 'info');
|
|
const row = document.getElementById('row-' + id);
|
|
if (row) row.style.opacity = '.4';
|
|
setTimeout(refreshQueue, 500);
|
|
} catch(err) { showToast('Erreur', 'error'); }
|
|
}
|
|
|
|
// ── WebSocket + auto-refresh ───────────────────────────────────────────────────
|
|
function onWsMessage(msg) {
|
|
if (['print_request','print_result','print_cancelled'].includes(msg.type)) {
|
|
refreshQueue();
|
|
if (msg.type === 'print_result') refreshPrinters();
|
|
}
|
|
}
|
|
|
|
setInterval(refreshQueue, 10000);
|
|
setInterval(refreshPrinters, 15000);
|
|
</script>
|
|
{% endblock %}
|
|
|