Files
POPS/app/templates/job_detail.html
Martin Hohenberg bc6e06a2e2 Add cost/time estimate card to job detail page
app/estimation.py:
  - 20% infill → material volume → weight (density per material)
  - print time at 20 g/h (conservative FDM average)
  - filament cost at FILAMENT_PRICE_PER_KG EUR/kg (env, default 15)
  - +20% misprint buffer, +30% gross profit margin
  - net price (ex. VAT) and gross price (VAT_RATE %, env, default 19)

Card shows three columns: Material & Time / Production cost / Suggested price.
Only rendered when STL volume is available. Assumptions shown in card header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:40:52 +02:00

316 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Job {{ job.job_uuid[:8] if job.job_uuid else job.id }} — POPS{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-3 mb-4">
<a href="{{ '/brackets/' ~ job.bracket.id if job.bracket else '/jobs' }}" class="text-secondary text-decoration-none">
<i class="bi bi-arrow-left"></i>
</a>
<div class="flex-grow-1 me-3">
<div class="d-flex align-items-center gap-2">
<h5 class="text-white mb-0">{{ job.file_name or job.name }}</h5>
{% set s = job.status.value %}
{% if job.priority %}<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-circle me-1"></i>Priority</span>{% endif %}
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
{% elif s == 'done' %}<span class="badge bg-success">done</span>
{% elif s == 'failed' %}<span class="badge bg-danger">failed</span>
{% elif s == 'cancelled' %}<span class="badge bg-warning text-dark">cancelled</span>
{% else %}<span class="badge bg-secondary">queued</span>
{% endif %}
</div>
<div class="text-secondary small font-monospace mt-1">{{ job.job_uuid or '—' }}</div>
</div>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editJobModal">
<i class="bi bi-pencil me-1"></i>Edit
</button>
</div>
<div class="row g-3 mb-4">
{# ── Job info ── #}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">Job</div>
<div class="card-body">
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0" style="width:40%">Customer</td>
<td class="border-0">{{ job.customer or '—' }}</td></tr>
<tr><td class="text-secondary">Material</td>
<td>{{ job.job_material or '—' }}{% if job.job_color %} · {{ job.job_color }}{% endif %}</td></tr>
<tr><td class="text-secondary">Printer</td>
<td>{{ job.printer.name if job.printer else '—' }}</td></tr>
<tr><td class="text-secondary">Bracket</td>
<td>{% if job.bracket %}<a href="/jobs?bracket_id={{ job.bracket.id }}" class="text-white">{{ job.bracket.name }}</a>{% else %}—{% endif %}</td></tr>
<tr><td class="text-secondary">Created</td>
<td>{{ job.created_at.strftime('%d.%m.%Y %H:%M') }}</td></tr>
{% if job.started_at %}
<tr><td class="text-secondary">Started</td>
<td>{{ job.started_at.strftime('%d.%m.%Y %H:%M') }}</td></tr>
{% endif %}
{% if job.finished_at %}
<tr><td class="text-secondary">Finished</td>
<td>{{ job.finished_at.strftime('%d.%m.%Y %H:%M') }}</td></tr>
{% endif %}
{% if job.duration_minutes %}
<tr><td class="text-secondary">Duration</td>
<td>{{ job.duration_minutes }} min</td></tr>
{% endif %}
{% if job.cost_eur %}
<tr><td class="text-secondary">Cost</td>
<td>€ {{ "%.2f"|format(job.cost_eur) }}</td></tr>
{% endif %}
{% if job.notes %}
<tr><td class="text-secondary">Notes</td>
<td>{{ job.notes }}</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{# ── STL geometry ── #}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header border-secondary d-flex align-items-center justify-content-between" style="border-color:#21262d">
<span class="text-secondary small text-uppercase fw-normal">File &amp; Geometry</span>
{% if job.file_name and not job.file_name.endswith('.gcode') %}
<form method="POST" action="/jobs/{{ job.id }}/analyse">
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2" style="font-size:.75rem">
<i class="bi bi-cpu me-1"></i>Compute now
</button>
</form>
{% endif %}
</div>
<div class="card-body">
{% if job.file_name %}
<div class="text-white mb-3 font-monospace" style="font-size:.85rem">{{ job.file_name }}</div>
{% endif %}
{% if stl %}
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Bounding box</td>
<td class="border-0">
{{ stl.cube_x|round(1) }} × {{ stl.cube_y|round(1) }} × {{ stl.cube_z|round(1) }} mm
</td></tr>
<tr><td class="text-secondary">Volume</td>
<td>{{ stl.volume_ccm }} cm³</td></tr>
</tbody>
</table>
{% elif job.file_name and job.file_name.endswith('.gcode') %}
<p class="text-secondary small mb-0">Geometry not available for gcode files.</p>
{% elif job.file_name %}
<p class="text-secondary small mb-0">Not yet analysed.</p>
{% else %}
<p class="text-secondary small mb-0">No file attached.</p>
{% endif %}
</div>
</div>
</div>
</div>
{# ── Estimate ── #}
{% if estimation %}
<div class="card mb-4">
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">
Estimate
<span class="text-secondary fw-normal ms-2" style="font-size:.7rem">
20 % infill · {{ estimation.price_per_kg }} €/kg · {{ estimation.vat_rate }}% VAT
</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="text-secondary small mb-2">Material &amp; Time</div>
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Volume used</td>
<td class="border-0">{{ estimation.material_ccm }} cm³</td></tr>
<tr><td class="text-secondary">Weight</td>
<td>{{ estimation.weight_g }} g</td></tr>
<tr><td class="text-secondary">Print time</td>
<td>
{% set h = (estimation.print_minutes // 60) %}
{% set m = (estimation.print_minutes % 60) %}
{% if h %}{{ h }}h {% endif %}{{ m }}min
<span class="text-secondary" style="font-size:.75rem">(est.)</span>
</td></tr>
</tbody>
</table>
</div>
<div class="col-md-4">
<div class="text-secondary small mb-2">Production cost</div>
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Filament</td>
<td class="border-0">{{ "%.2f"|format(estimation.filament_cost) }} €</td></tr>
<tr><td class="text-secondary">+ 20 % misprint</td>
<td>{{ "%.2f"|format(estimation.filament_cost * 1.2) }} €</td></tr>
</tbody>
</table>
</div>
<div class="col-md-4">
<div class="text-secondary small mb-2">Suggested price</div>
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Net (ex. VAT)</td>
<td class="border-0 fw-medium text-white">{{ "%.2f"|format(estimation.net_price) }} €</td></tr>
<tr><td class="text-secondary">Gross ({{ estimation.vat_rate }}% VAT)</td>
<td class="fw-medium text-white">{{ "%.2f"|format(estimation.gross_price) }} €</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{# ── Log ── #}
<div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="text-white mb-0">Log</h6>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addLogModal">
<i class="bi bi-plus-lg me-1"></i>Add entry
</button>
</div>
<div class="card">
{% if job.logs %}
<table class="table table-dark table-hover mb-0 align-middle">
<tbody>
{% for entry in job.logs %}
<tr>
<td class="text-secondary small" style="width:140px;white-space:nowrap">
{{ entry.created_at.strftime('%d.%m.%Y %H:%M') }}
</td>
<td>{{ entry.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body text-secondary">No log entries.</div>
{% endif %}
</div>
{# ── Edit Job Modal ── #}
<div class="modal fade" id="editJobModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content bg-dark border-secondary">
<form method="POST" action="/jobs/{{ job.id }}/edit">
<div class="modal-header border-secondary">
<h6 class="modal-title text-white">Edit Job</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body d-flex flex-column gap-3">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-secondary small">Customer</label>
<input type="text" name="customer" value="{{ job.customer or '' }}"
class="form-control bg-dark border-secondary text-white">
</div>
<div class="col-md-6">
<label class="form-label text-secondary small">Status</label>
<select name="status" class="form-select bg-dark border-secondary text-white">
{% for s in all_statuses %}
<option value="{{ s }}" {% if job.status.value == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-secondary small">Material</label>
<select name="job_material" class="form-select bg-dark border-secondary text-white">
<option value="">— none —</option>
{% for m in materials %}
<option value="{{ m }}" {% if job.job_material == m %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label text-secondary small">Color</label>
<select name="job_color" class="form-select bg-dark border-secondary text-white">
<option value="">— none —</option>
{% for c in colors %}
<option value="{{ c }}" {% if job.job_color == c %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-secondary small">Printer</label>
<select name="printer_id" class="form-select bg-dark border-secondary text-white">
<option value="">— unassigned —</option>
{% for p in printers %}
<option value="{{ p.id }}" {% if job.printer_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label text-secondary small">Bracket</label>
<select name="bracket_id" class="form-select bg-dark border-secondary text-white">
<option value="">— none —</option>
{% for b in brackets %}
<option value="{{ b.id }}" {% if job.bracket_id == b.id %}selected{% endif %}>{{ b.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="priority" id="editPriorityCheck"
value="1" {% if job.priority %}checked{% endif %}>
<label class="form-check-label text-secondary small" for="editPriorityCheck">
<i class="bi bi-exclamation-circle text-warning me-1"></i>Priority
</label>
</div>
<div>
<label class="form-label text-secondary small">Notes</label>
<textarea name="notes" class="form-control bg-dark border-secondary text-white"
rows="2">{{ job.notes or '' }}</textarea>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</form>
</div>
</div>
</div>
{# ── Add log entry modal ── #}
<div class="modal fade" id="addLogModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark border-secondary">
<form method="POST" action="/jobs/{{ job.id }}/log">
<div class="modal-header border-secondary">
<h6 class="modal-title text-white">Add Log Entry</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<textarea name="message" class="form-control bg-dark border-secondary text-white"
rows="3" placeholder="What happened?" required autofocus></textarea>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}