Compare commits

..

6 Commits

Author SHA1 Message Date
Martin Hohenberg
ff2b618414 Duplicate job 2026-07-06 17:17:22 +02:00
Martin Hohenberg
38e6d38a2f Show open Vikunja task count as badge on To-Do sidebar link
Fetched async via /api/todos/count so it doesn't block page render.
Badge hidden when count is zero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 19:16:19 +02:00
Martin Hohenberg
f713fbe2fb Count cancelled jobs as done in bracket progress bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 19:15:43 +02:00
Martin Hohenberg
7dec15d777 Exclude cancelled jobs from bracket progress bar count
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:26:02 +02:00
Martin Hohenberg
5270d96b92 Sort jobs: internal always last within each priority stratum
Order: priority customer > priority internal > normal customer > normal internal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:21:19 +02:00
Martin Hohenberg
5a61a35839 Default customer to 'internal' when left blank
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:19:52 +02:00
7 changed files with 94 additions and 6 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -4,6 +4,12 @@ from app.vikunja import create_task, delete_task, get_tasks, update_task
router = APIRouter(prefix="/todos", tags=["todos"])
@router.get("/count")
def count_todos():
tasks = get_tasks()
return {"open": sum(1 for t in tasks if not t.get("done"))}
@router.get("/")
def list_todos():
return get_tasks()

View File

@@ -1,5 +1,6 @@
import math
import os
import re
import uuid as _uuid
from datetime import datetime, timedelta
from pathlib import Path
@@ -90,10 +91,17 @@ def printer_detail(printer_id: int, request: Request, db: Session = Depends(get_
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
current_job = (
db.query(PrintJob)
.filter(PrintJob.printer_id == printer_id, PrintJob.status == JobStatus.printing)
.order_by(PrintJob.started_at.desc())
.first()
)
return templates.TemplateResponse("printer_detail.html", {
"request": request,
"active": "printers",
"printer": printer,
"current_job": current_job,
})
@@ -155,13 +163,14 @@ def jobs_page(request: Request, q: str = "", bracket_id: str = "", show_all: str
or_(~PrintJob.status.in_(finished), PrintJob.created_at >= cutoff)
)
# Sort: printing → queued → rest, then priority, then oldest first
# Sort: printing → queued → rest, then priority flag, then internal last, then oldest first
status_order = case(
(PrintJob.status == JobStatus.printing, 0),
(PrintJob.status == JobStatus.queued, 1),
else_=2,
)
jobs = query.order_by(status_order, PrintJob.priority.desc(), PrintJob.created_at.asc()).all()
internal_last = case((PrintJob.customer == "internal", 1), else_=0)
jobs = query.order_by(status_order, PrintJob.priority.desc(), internal_last, PrintJob.created_at.asc()).all()
stl_files = list_stl_files()
stl_cache = get_cache_map(stl_files, db)
@@ -221,7 +230,7 @@ async def create_job(
filename = stl_select
amount = max(1, amount)
customer_str = customer.strip() or None
customer_str = customer.strip() or "internal"
job_name = Path(filename).stem if filename else (customer_str or "Unnamed")
# Resolve bracket — auto-create one when printing multiple copies
@@ -320,7 +329,7 @@ def edit_job(
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
job.customer = customer.strip() or None
job.customer = customer.strip() or "internal"
job.job_material = job_material or None
job.job_color = job_color or None
job.priority = bool(priority)
@@ -334,6 +343,45 @@ def edit_job(
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)
@router.post("/jobs/{job_id}/duplicate")
def duplicate_job(job_id: int, db: Session = Depends(get_db)):
from fastapi import HTTPException
job = db.query(PrintJob).filter(PrintJob.id == job_id).first()
if not job:
raise HTTPException(404, "Job not found")
copy = PrintJob(
job_uuid=str(_uuid.uuid4()),
name=job.name,
file_name=job.file_name,
printer_id=None,
bracket_id=job.bracket_id,
customer=job.customer,
job_material=job.job_material,
job_color=job.job_color,
priority=job.priority,
status=JobStatus.queued,
notes=job.notes,
)
db.add(copy)
# If the original was in a bracket, extend the "×N" count in its name to match.
if job.bracket and job.bracket.name:
job.bracket.name = re.sub(
r"×(\d+)\s*$",
lambda m: f"×{int(m.group(1)) + 1}",
job.bracket.name,
)
db.flush()
db.add(JobLog(job_id=copy.id, message=f"Job created (duplicate of #{job.id})"))
db.commit()
if copy.bracket_id:
return RedirectResponse(url=f"/jobs?bracket_id={copy.bracket_id}", status_code=HTTP_303_SEE_OTHER)
return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER)
@router.get("/files/{filename}")
def download_file(filename: str):
from fastapi import HTTPException

View File

@@ -60,7 +60,7 @@
<li><a href="/printers" class="nav-link {% if active == 'printers' %}active{% endif %}"><i class="bi bi-printer me-2"></i>Printers</a></li>
<li><a href="/jobs" class="nav-link {% if active == 'jobs' %}active{% endif %}"><i class="bi bi-list-task me-2"></i>Jobs</a></li>
<li><a href="/brackets" class="nav-link {% if active == 'brackets' %}active{% endif %}"><i class="bi bi-collection me-2"></i>Brackets</a></li>
<li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %}"><i class="bi bi-check2-square me-2"></i>To-Do</a></li>
<li><a href="/todos" class="nav-link {% if active == 'todos' %}active{% endif %} d-flex align-items-center justify-content-between"><span><i class="bi bi-check2-square me-2"></i>To-Do</span><span id="todo-count-badge" class="badge rounded-pill bg-primary ms-1" style="display:none!important"></span></a></li>
<li><a href="/pricing" class="nav-link {% if active == 'pricing' %}active{% endif %}"><i class="bi bi-currency-euro me-2"></i>Pricing</a></li>
</ul>
</nav>
@@ -71,5 +71,14 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
fetch('/api/todos/count').then(r => r.json()).then(d => {
if (d.open > 0) {
const b = document.getElementById('todo-count-badge');
b.textContent = d.open;
b.style.removeProperty('display');
}
}).catch(() => {});
</script>
</body>
</html>

View File

@@ -23,7 +23,7 @@
<tbody>
{% for b in brackets %}
{% set total = b.jobs | length %}
{% set done = b.jobs | selectattr('status.value', 'equalto', 'done') | list | length %}
{% set done = b.jobs | selectattr('status.value', 'in', ['done', 'cancelled']) | list | length %}
{% set pct = (done / total * 100) | int if total else 0 %}
<tr>
<td><a href="/jobs?bracket_id={{ b.id }}" class="text-white text-decoration-none fw-medium">{{ b.name }}</a></td>

View File

@@ -50,6 +50,7 @@
<th class="fw-normal">Material</th>
<th class="fw-normal">Status</th>
<th class="fw-normal text-end">Created</th>
<th class="fw-normal"></th>
</tr>
</thead>
<tbody>
@@ -87,6 +88,13 @@
{% endif %}
</td>
<td class="text-secondary text-end">{{ j.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td class="text-end" style="width:32px">
<form method="POST" action="/jobs/{{ j.id }}/duplicate" class="d-inline">
<button type="submit" class="btn btn-sm btn-link text-secondary p-0" title="Duplicate job">
<i class="bi bi-files"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -16,6 +16,23 @@
{% endif %}
</div>
{# ── Currently printing ── #}
{% if current_job %}
<a href="/jobs/{{ current_job.id }}" class="text-decoration-none">
<div class="card mb-4 border-primary">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-printer text-primary fs-4"></i>
<div>
<div class="text-secondary small text-uppercase">Currently printing</div>
<div class="text-white fw-semibold">{{ current_job.file_name or current_job.name or '—' }}</div>
{% if current_job.customer %}<div class="text-secondary small">{{ current_job.customer }}</div>{% endif %}
</div>
<i class="bi bi-chevron-right text-secondary ms-auto"></i>
</div>
</div>
</a>
{% endif %}
<div class="row g-3 mb-4">
{# ── Info card ── #}