Add Jinja2 web UI with Bootstrap 5 dark theme
- app/templates/: base layout with sidebar nav + 6 pages (dashboard, printers, jobs, filaments, todos, pricing) - app/routers/ui.py: HTML routes querying the DB live - API routes moved to /api/* prefix - requirements: jinja2, python-multipart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
18
app/main.py
18
app/main.py
@@ -5,6 +5,7 @@ from fastapi import FastAPI
|
||||
|
||||
from app.db import check_db, run_migrations
|
||||
from app.routers import filaments, pricing, print_jobs, printers, todos
|
||||
from app.routers import ui
|
||||
|
||||
logger = logging.getLogger("pops")
|
||||
|
||||
@@ -22,16 +23,15 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="POPS", lifespan=lifespan)
|
||||
|
||||
app.include_router(printers.router)
|
||||
app.include_router(filaments.router)
|
||||
app.include_router(print_jobs.router)
|
||||
app.include_router(todos.router)
|
||||
app.include_router(pricing.router)
|
||||
# JSON API
|
||||
app.include_router(printers.router, prefix="/api")
|
||||
app.include_router(filaments.router, prefix="/api")
|
||||
app.include_router(print_jobs.router, prefix="/api")
|
||||
app.include_router(todos.router, prefix="/api")
|
||||
app.include_router(pricing.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Hello from POPS"}
|
||||
# Web UI (must come after API to avoid route shadowing)
|
||||
app.include_router(ui.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
76
app/routers/ui.py
Normal file
76
app/routers/ui.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models.filament import Filament
|
||||
from app.models.print_job import JobStatus, PrintJob
|
||||
from app.models.printer import Printer, PrinterStatus
|
||||
from app.models.pricing import PricingConfig
|
||||
from app.models.todo import Todo
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
printers = db.query(Printer).order_by(Printer.name).all()
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"active": "dashboard",
|
||||
"printers": printers,
|
||||
"total_printers": len(printers),
|
||||
"printing_count": sum(1 for p in printers if p.status == PrinterStatus.printing),
|
||||
"active_jobs": db.query(PrintJob).filter(
|
||||
PrintJob.status.in_([JobStatus.queued, JobStatus.printing])
|
||||
).count(),
|
||||
"total_filaments": db.query(Filament).count(),
|
||||
"recent_jobs": db.query(PrintJob).order_by(PrintJob.created_at.desc()).limit(5).all(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/printers", response_class=HTMLResponse)
|
||||
def printers_page(request: Request, db: Session = Depends(get_db)):
|
||||
return templates.TemplateResponse("printers.html", {
|
||||
"request": request,
|
||||
"active": "printers",
|
||||
"printers": db.query(Printer).order_by(Printer.name).all(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/jobs", response_class=HTMLResponse)
|
||||
def jobs_page(request: Request, db: Session = Depends(get_db)):
|
||||
return templates.TemplateResponse("jobs.html", {
|
||||
"request": request,
|
||||
"active": "jobs",
|
||||
"jobs": db.query(PrintJob).order_by(PrintJob.created_at.desc()).all(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/filaments", response_class=HTMLResponse)
|
||||
def filaments_page(request: Request, db: Session = Depends(get_db)):
|
||||
return templates.TemplateResponse("filaments.html", {
|
||||
"request": request,
|
||||
"active": "filaments",
|
||||
"filaments": db.query(Filament).order_by(Filament.brand, Filament.material).all(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/todos", response_class=HTMLResponse)
|
||||
def todos_page(request: Request, db: Session = Depends(get_db)):
|
||||
return templates.TemplateResponse("todos.html", {
|
||||
"request": request,
|
||||
"active": "todos",
|
||||
"todos": db.query(Todo).order_by(Todo.done, Todo.created_at.desc()).all(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/pricing", response_class=HTMLResponse)
|
||||
def pricing_page(request: Request, db: Session = Depends(get_db)):
|
||||
return templates.TemplateResponse("pricing.html", {
|
||||
"request": request,
|
||||
"active": "pricing",
|
||||
"configs": db.query(PricingConfig).order_by(PricingConfig.valid_from.desc()).all(),
|
||||
})
|
||||
65
app/templates/base.html
Normal file
65
app/templates/base.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}POPS{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #0d1117; }
|
||||
#sidebar {
|
||||
width: 230px;
|
||||
min-height: 100vh;
|
||||
background: #010409;
|
||||
border-right: 1px solid #21262d;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#sidebar .brand { border-bottom: 1px solid #21262d; }
|
||||
#sidebar .nav-link {
|
||||
color: #8b949e;
|
||||
border-radius: 6px;
|
||||
padding: .4rem .75rem;
|
||||
font-size: .9rem;
|
||||
}
|
||||
#sidebar .nav-link i { width: 18px; }
|
||||
#sidebar .nav-link:hover { color: #e6edf3; background: #161b22; }
|
||||
#sidebar .nav-link.active { color: #e6edf3; background: #21262d; font-weight: 500; }
|
||||
#content { min-height: 100vh; }
|
||||
.card { border-color: #21262d !important; background: #161b22 !important; }
|
||||
.table-dark { --bs-table-bg: transparent; }
|
||||
.table > :not(caption) > * > * { border-color: #21262d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="d-flex">
|
||||
|
||||
<nav id="sidebar" class="p-3 d-flex flex-column gap-3">
|
||||
<a href="/" class="brand text-decoration-none pb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-printer-fill text-primary fs-5"></i>
|
||||
<div>
|
||||
<div class="text-white fw-semibold lh-sm">POPS</div>
|
||||
<div class="text-secondary lh-sm" style="font-size:.68rem;letter-spacing:.03em">PRINT FARM MANAGER</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<ul class="nav flex-column gap-1">
|
||||
<li><a href="/" class="nav-link {% if active == 'dashboard' %}active{% endif %}"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
||||
<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="/filaments" class="nav-link {% if active == 'filaments' %}active{% endif %}"><i class="bi bi-record-circle me-2"></i>Filaments</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="/pricing" class="nav-link {% if active == 'pricing' %}active{% endif %}"><i class="bi bi-currency-euro me-2"></i>Pricing</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main id="content" class="flex-grow-1 p-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
106
app/templates/dashboard.html
Normal file
106
app/templates/dashboard.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — POPS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="text-white mb-4">Dashboard</h5>
|
||||
|
||||
{# ── Stats row ── #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card p-3">
|
||||
<div class="text-secondary small mb-1">Printers</div>
|
||||
<div class="fs-2 fw-bold text-white">{{ total_printers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card p-3">
|
||||
<div class="text-secondary small mb-1">Printing Now</div>
|
||||
<div class="fs-2 fw-bold text-primary">{{ printing_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card p-3">
|
||||
<div class="text-secondary small mb-1">Active Jobs</div>
|
||||
<div class="fs-2 fw-bold text-warning">{{ active_jobs }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card p-3">
|
||||
<div class="text-secondary small mb-1">Filament Spools</div>
|
||||
<div class="fs-2 fw-bold text-white">{{ total_filaments }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
{# ── Printer overview ── #}
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">Printers</div>
|
||||
{% if printers %}
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
<tr>
|
||||
<td>{{ p.name }}</td>
|
||||
<td class="text-secondary small">{{ p.model or '—' }}</td>
|
||||
<td class="text-end">
|
||||
{% set s = p.status.value %}
|
||||
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
|
||||
{% elif s == 'idle' %}<span class="badge bg-success">idle</span>
|
||||
{% elif s == 'error' %}<span class="badge bg-danger">error</span>
|
||||
{% else %}<span class="badge bg-secondary">offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-secondary">No printers configured yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Recent jobs ── #}
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">Recent Jobs</div>
|
||||
{% if recent_jobs %}
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="text-secondary small">
|
||||
<th class="fw-normal">Name</th>
|
||||
<th class="fw-normal">Printer</th>
|
||||
<th class="fw-normal">Status</th>
|
||||
<th class="fw-normal text-end">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in recent_jobs %}
|
||||
<tr>
|
||||
<td>{{ j.name }}</td>
|
||||
<td class="text-secondary small">{{ j.printer.name if j.printer else '—' }}</td>
|
||||
<td>
|
||||
{% set s = j.status.value %}
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td class="text-secondary small text-end">{{ j.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-secondary">No jobs yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
app/templates/filaments.html
Normal file
52
app/templates/filaments.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Filaments — POPS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="text-white mb-4">Filament Inventory</h5>
|
||||
<div class="card">
|
||||
{% if filaments %}
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="text-secondary small">
|
||||
<th class="fw-normal">Brand</th>
|
||||
<th class="fw-normal">Material</th>
|
||||
<th class="fw-normal">Color</th>
|
||||
<th class="fw-normal">Remaining</th>
|
||||
<th class="fw-normal text-end">Price / kg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in filaments %}
|
||||
{% set pct = (f.weight_remaining_g / f.weight_total_g * 100) | int if f.weight_total_g else 0 %}
|
||||
<tr>
|
||||
<td class="fw-medium text-white">{{ f.brand or '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ f.material.value }}</span></td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if f.color_hex %}
|
||||
<span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:{{ f.color_hex }};border:1px solid #444"></span>
|
||||
{% endif %}
|
||||
{{ f.color or '—' }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="min-width:160px">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress flex-grow-1" style="height:6px">
|
||||
<div class="progress-bar {% if pct < 20 %}bg-danger{% elif pct < 50 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
style="width:{{ pct }}%"></div>
|
||||
</div>
|
||||
<span class="text-secondary small" style="min-width:70px">{{ f.weight_remaining_g | int }}g / {{ f.weight_total_g | int }}g</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-secondary small text-end">
|
||||
{% if f.price_per_kg_eur %}€ {{ "%.2f"|format(f.price_per_kg_eur) }}{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-secondary">No filaments in inventory yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
app/templates/jobs.html
Normal file
52
app/templates/jobs.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Jobs — POPS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="text-white mb-4">Print Jobs</h5>
|
||||
<div class="card">
|
||||
{% if jobs %}
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="text-secondary small">
|
||||
<th class="fw-normal">Name</th>
|
||||
<th class="fw-normal">Printer</th>
|
||||
<th class="fw-normal">Filament</th>
|
||||
<th class="fw-normal">Status</th>
|
||||
<th class="fw-normal">Duration</th>
|
||||
<th class="fw-normal text-end">Cost</th>
|
||||
<th class="fw-normal text-end">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in jobs %}
|
||||
<tr>
|
||||
<td class="fw-medium text-white">{{ j.name }}</td>
|
||||
<td class="text-secondary small">{{ j.printer.name if j.printer else '—' }}</td>
|
||||
<td class="text-secondary small">
|
||||
{% if j.filament %}{{ j.filament.material.value }} {{ j.filament.color or '' }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set s = j.status.value %}
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td class="text-secondary small">
|
||||
{% if j.duration_minutes %}{{ j.duration_minutes }} min{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-secondary small text-end">
|
||||
{% if j.cost_eur %}€ {{ "%.2f"|format(j.cost_eur) }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-secondary small text-end">{{ j.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-secondary">No jobs yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
30
app/templates/pricing.html
Normal file
30
app/templates/pricing.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Pricing — POPS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="text-white mb-4">Pricing Configuration</h5>
|
||||
{% if configs %}
|
||||
<div class="row g-3">
|
||||
{% for c in configs %}
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<div class="card p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<span class="fw-semibold text-white">{{ c.name }}</span>
|
||||
<span class="text-secondary small">from {{ c.valid_from.strftime('%d.%m.%Y') }}</span>
|
||||
</div>
|
||||
<table class="table table-dark table-sm mb-0" style="font-size:.85rem">
|
||||
<tbody>
|
||||
<tr><td class="text-secondary border-0">Electricity</td><td class="text-end border-0">€ {{ "%.4f"|format(c.electricity_kwh_eur) }} / kWh</td></tr>
|
||||
<tr><td class="text-secondary">Printer draw</td><td class="text-end">{{ c.printer_watt }} W</td></tr>
|
||||
<tr><td class="text-secondary">Hourly rate</td><td class="text-end">€ {{ "%.2f"|format(c.hourly_rate_eur) }} / h</td></tr>
|
||||
<tr><td class="text-secondary">Markup</td><td class="text-end">{{ c.markup_percent }} %</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card"><div class="card-body text-secondary">No pricing config yet.</div></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
41
app/templates/printers.html
Normal file
41
app/templates/printers.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Printers — POPS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="text-white mb-4">Printers</h5>
|
||||
<div class="card">
|
||||
{% if printers %}
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="text-secondary small">
|
||||
<th class="fw-normal">Name</th>
|
||||
<th class="fw-normal">Model</th>
|
||||
<th class="fw-normal">Location</th>
|
||||
<th class="fw-normal">Status</th>
|
||||
<th class="fw-normal">Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
<tr>
|
||||
<td class="fw-medium text-white">{{ p.name }}</td>
|
||||
<td class="text-secondary">{{ p.model or '—' }}</td>
|
||||
<td class="text-secondary">{{ p.location or '—' }}</td>
|
||||
<td>
|
||||
{% set s = p.status.value %}
|
||||
{% if s == 'printing' %}<span class="badge bg-primary">printing</span>
|
||||
{% elif s == 'idle' %}<span class="badge bg-success">idle</span>
|
||||
{% elif s == 'error' %}<span class="badge bg-danger">error</span>
|
||||
{% else %}<span class="badge bg-secondary">offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-secondary small">{{ p.created_at.strftime('%d.%m.%Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-secondary">No printers configured yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
40
app/templates/todos.html
Normal file
40
app/templates/todos.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}To-Do — POPS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="text-white mb-4">To-Do</h5>
|
||||
<div class="card">
|
||||
{% if todos %}
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="text-secondary small">
|
||||
<th class="fw-normal">Task</th>
|
||||
<th class="fw-normal">Printer</th>
|
||||
<th class="fw-normal text-center">Done</th>
|
||||
<th class="fw-normal text-end">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in todos %}
|
||||
<tr class="{% if t.done %}opacity-50{% endif %}">
|
||||
<td class="{% if t.done %}text-decoration-line-through text-secondary{% else %}text-white{% endif %}">
|
||||
{{ t.title }}
|
||||
</td>
|
||||
<td class="text-secondary small">{{ t.printer.name if t.printer else '—' }}</td>
|
||||
<td class="text-center">
|
||||
{% if t.done %}
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-circle text-secondary"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-secondary small text-end">{{ t.created_at.strftime('%d.%m.%Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-secondary">No to-do items yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,3 +4,5 @@ sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
pymysql==1.1.1
|
||||
cryptography==44.0.3
|
||||
jinja2==3.1.4
|
||||
python-multipart==0.0.20
|
||||
|
||||
Reference in New Issue
Block a user