Add job brackets for grouping multi-part or repeated jobs
- JobBracket model: name, customer, notes
- Migration 0006: job_brackets table + bracket_id FK on print_jobs
- /brackets: list with per-bracket progress bar
- /brackets/{id}: detail with jobs table + Add Job(s) modal (quantity field
creates N identical jobs in one shot, each with its own UUID and log entry)
- /jobs Add Job modal: optional bracket selector
- Sidebar: Brackets nav item added
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
37
alembic/versions/0006_job_brackets.py
Normal file
37
alembic/versions/0006_job_brackets.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""job_brackets table and bracket_id on print_jobs
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-06-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006"
|
||||
down_revision: Union[str, None] = "0005"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"job_brackets",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("customer", sa.String(200), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.add_column("print_jobs", sa.Column("bracket_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key("fk_job_bracket", "print_jobs", "job_brackets", ["bracket_id"], ["id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_job_bracket", "print_jobs", type_="foreignkey")
|
||||
op.drop_column("print_jobs", "bracket_id")
|
||||
op.drop_table("job_brackets")
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.models.base import Base
|
||||
from app.models.filament import Filament
|
||||
from app.models.job_bracket import JobBracket
|
||||
from app.models.job_log import JobLog
|
||||
from app.models.pricing import PricingConfig
|
||||
from app.models.print_job import PrintJob
|
||||
@@ -8,4 +9,4 @@ from app.models.printer_log import PrinterLog
|
||||
from app.models.printer_type import PrinterType
|
||||
from app.models.todo import Todo
|
||||
|
||||
__all__ = ["Base", "Printer", "PrinterLog", "PrinterType", "Filament", "JobLog", "PrintJob", "Todo", "PricingConfig"]
|
||||
__all__ = ["Base", "Printer", "PrinterLog", "PrinterType", "Filament", "JobBracket", "JobLog", "PrintJob", "Todo", "PricingConfig"]
|
||||
|
||||
21
app/models/job_bracket.py
Normal file
21
app/models/job_bracket.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class JobBracket(Base):
|
||||
__tablename__ = "job_brackets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
customer: Mapped[str | None] = mapped_column(String(200))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
jobs: Mapped[list["PrintJob"]] = relationship(back_populates="bracket")
|
||||
@@ -24,6 +24,7 @@ class PrintJob(Base):
|
||||
job_uuid: Mapped[str | None] = mapped_column(String(36), unique=True, default=lambda: str(_uuid.uuid4()))
|
||||
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"))
|
||||
filament_id: Mapped[int | None] = mapped_column(ForeignKey("filaments.id"))
|
||||
bracket_id: Mapped[int | None] = mapped_column(ForeignKey("job_brackets.id"))
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
customer: Mapped[str | None] = mapped_column(String(200))
|
||||
file_name: Mapped[str | None] = mapped_column(String(255))
|
||||
@@ -42,6 +43,7 @@ class PrintJob(Base):
|
||||
|
||||
printer: Mapped["Printer"] = relationship(back_populates="jobs")
|
||||
filament: Mapped["Filament | None"] = relationship(back_populates="jobs")
|
||||
bracket: Mapped["JobBracket | None"] = relationship(back_populates="jobs")
|
||||
logs: Mapped[list["JobLog"]] = relationship(
|
||||
back_populates="job", order_by="JobLog.created_at.desc()"
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from starlette.status import HTTP_303_SEE_OTHER
|
||||
|
||||
from app.db import get_db
|
||||
from app.models.filament import Filament
|
||||
from app.models.job_bracket import JobBracket
|
||||
from app.models.job_log import JobLog
|
||||
from app.models.print_job import JobStatus, PrintJob
|
||||
from app.models.printer import Printer, PrinterStatus
|
||||
@@ -135,6 +136,7 @@ def jobs_page(request: Request, q: str = "", db: Session = Depends(get_db)):
|
||||
"q": q,
|
||||
"printers": db.query(Printer).order_by(Printer.name).all(),
|
||||
"filaments": db.query(Filament).order_by(Filament.material, Filament.color).all(),
|
||||
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
|
||||
"stl_files": list_stl_files(),
|
||||
})
|
||||
|
||||
@@ -144,6 +146,7 @@ async def create_job(
|
||||
printer_id: str = Form(""),
|
||||
customer: str = Form(""),
|
||||
filament_id: str = Form(""),
|
||||
bracket_id: str = Form(""),
|
||||
notes: str = Form(""),
|
||||
stl_file: UploadFile = File(None),
|
||||
stl_select: str = Form(""),
|
||||
@@ -162,6 +165,7 @@ async def create_job(
|
||||
file_name=filename,
|
||||
printer_id=int(printer_id) if printer_id else None,
|
||||
filament_id=int(filament_id) if filament_id else None,
|
||||
bracket_id=int(bracket_id) if bracket_id else None,
|
||||
customer=customer.strip() or None,
|
||||
notes=notes.strip() or None,
|
||||
)
|
||||
@@ -172,6 +176,78 @@ async def create_job(
|
||||
return RedirectResponse(url="/jobs", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# ── Brackets ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/brackets", response_class=HTMLResponse)
|
||||
def brackets_page(request: Request, db: Session = Depends(get_db)):
|
||||
return templates.TemplateResponse("brackets.html", {
|
||||
"request": request,
|
||||
"active": "brackets",
|
||||
"brackets": db.query(JobBracket).order_by(JobBracket.created_at.desc()).all(),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/brackets")
|
||||
def create_bracket(
|
||||
name: str = Form(...),
|
||||
customer: str = Form(""),
|
||||
notes: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
db.add(JobBracket(name=name.strip(), customer=customer.strip() or None, notes=notes.strip() or None))
|
||||
db.commit()
|
||||
return RedirectResponse(url="/brackets", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/brackets/{bracket_id}", response_class=HTMLResponse)
|
||||
def bracket_detail(bracket_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
from fastapi import HTTPException
|
||||
bracket = db.query(JobBracket).filter(JobBracket.id == bracket_id).first()
|
||||
if not bracket:
|
||||
raise HTTPException(status_code=404, detail="Bracket not found")
|
||||
return templates.TemplateResponse("bracket_detail.html", {
|
||||
"request": request,
|
||||
"active": "brackets",
|
||||
"bracket": bracket,
|
||||
"filaments": db.query(Filament).order_by(Filament.material, Filament.color).all(),
|
||||
"stl_files": list_stl_files(),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/brackets/{bracket_id}/jobs")
|
||||
async def add_jobs_to_bracket(
|
||||
bracket_id: int,
|
||||
quantity: int = Form(1),
|
||||
filament_id: str = Form(""),
|
||||
notes: str = Form(""),
|
||||
stl_file: UploadFile = File(None),
|
||||
stl_select: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
filename = None
|
||||
if stl_file and stl_file.filename:
|
||||
filename = stl_file.filename
|
||||
(get_stl_dir() / filename).write_bytes(await stl_file.read())
|
||||
elif stl_select:
|
||||
filename = stl_select
|
||||
|
||||
for _ in range(max(1, min(quantity, 500))):
|
||||
job = PrintJob(
|
||||
job_uuid=str(_uuid.uuid4()),
|
||||
name=Path(filename).stem if filename else "Unnamed",
|
||||
file_name=filename,
|
||||
bracket_id=bracket_id,
|
||||
filament_id=int(filament_id) if filament_id else None,
|
||||
notes=notes.strip() or None,
|
||||
)
|
||||
db.add(job)
|
||||
db.flush()
|
||||
db.add(JobLog(job_id=job.id, message="Job created"))
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/brackets/{bracket_id}", status_code=HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# ── Filaments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/filaments", response_class=HTMLResponse)
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<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="/brackets" class="nav-link {% if active == 'brackets' %}active{% endif %}"><i class="bi bi-collection me-2"></i>Brackets</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>
|
||||
|
||||
162
app/templates/bracket_detail.html
Normal file
162
app/templates/bracket_detail.html
Normal file
@@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ bracket.name }} — POPS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set total = bracket.jobs | length %}
|
||||
{% set done = bracket.jobs | selectattr('status.value', 'equalto', 'done') | list | length %}
|
||||
{% set printing = bracket.jobs | selectattr('status.value', 'equalto', 'printing') | list | length %}
|
||||
{% set pct = (done / total * 100) | int if total else 0 %}
|
||||
|
||||
<div class="d-flex align-items-center gap-3 mb-4">
|
||||
<a href="/brackets" class="text-secondary text-decoration-none"><i class="bi bi-arrow-left"></i></a>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="text-white mb-0">{{ bracket.name }}</h5>
|
||||
{% if bracket.customer %}<small class="text-secondary">{{ bracket.customer }}</small>{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addJobsModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Job(s)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# ── Progress ── #}
|
||||
{% if total %}
|
||||
<div class="card mb-4 p-3">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary small">{{ done }} done · {{ printing }} printing · {{ total - done - printing }} queued</span>
|
||||
<span class="text-secondary small">{{ pct }}%</span>
|
||||
</div>
|
||||
<div class="progress" style="height:8px">
|
||||
<div class="progress-bar bg-success" style="width:{{ pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Jobs table ── #}
|
||||
<div class="card">
|
||||
{% if bracket.jobs %}
|
||||
<table class="table table-dark table-hover mb-0 align-middle" style="font-size:.875rem">
|
||||
<thead>
|
||||
<tr class="text-secondary small">
|
||||
<th class="fw-normal">UUID</th>
|
||||
<th class="fw-normal">File</th>
|
||||
<th class="fw-normal">Material</th>
|
||||
<th class="fw-normal">Printer</th>
|
||||
<th class="fw-normal">Status</th>
|
||||
<th class="fw-normal text-end">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in bracket.jobs %}
|
||||
<tr>
|
||||
<td class="font-monospace text-secondary" style="font-size:.75rem">
|
||||
{{ j.job_uuid[:8] if j.job_uuid else '—' }}
|
||||
</td>
|
||||
<td class="text-white">{{ j.file_name or j.name }}</td>
|
||||
<td class="text-secondary">
|
||||
{% if j.filament %}{{ j.filament.material.value }}{% if j.filament.color %} · {{ j.filament.color }}{% endif %}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-secondary">{{ 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 text-end">{{ j.created_at.strftime('%d.%m %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-secondary">No jobs in this bracket yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Add Jobs Modal ── #}
|
||||
<div class="modal fade" id="addJobsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content bg-dark border-secondary">
|
||||
<form method="POST" action="/brackets/{{ bracket.id }}/jobs" enctype="multipart/form-data">
|
||||
<div class="modal-header border-secondary">
|
||||
<h6 class="modal-title text-white">Add Job(s) to Bracket</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">
|
||||
|
||||
{# STL file #}
|
||||
<div>
|
||||
<label class="form-label text-secondary small">STL / File</label>
|
||||
<ul class="nav nav-pills mb-2" role="tablist">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active py-1 px-3" style="font-size:.8rem"
|
||||
data-bs-toggle="pill" data-bs-target="#bUploadPane" type="button">
|
||||
<i class="bi bi-upload me-1"></i>Upload
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link py-1 px-3" style="font-size:.8rem"
|
||||
data-bs-toggle="pill" data-bs-target="#bLibraryPane" type="button">
|
||||
<i class="bi bi-folder2-open me-1"></i>Library
|
||||
<span class="badge bg-secondary ms-1">{{ stl_files | length }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="bUploadPane">
|
||||
<input type="file" name="stl_file"
|
||||
class="form-control bg-dark border-secondary text-white"
|
||||
accept=".stl,.3mf,.gcode">
|
||||
</div>
|
||||
<div class="tab-pane fade" id="bLibraryPane">
|
||||
{% if stl_files %}
|
||||
<select name="stl_select" class="form-select bg-dark border-secondary text-white">
|
||||
<option value="">— select file —</option>
|
||||
{% for f in stl_files %}<option value="{{ f }}">{{ f }}</option>{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<p class="text-secondary small mb-0">No files in library yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-secondary small">Material & Color</label>
|
||||
<select name="filament_id" class="form-select bg-dark border-secondary text-white">
|
||||
<option value="">— none —</option>
|
||||
{% for f in filaments %}
|
||||
<option value="{{ f.id }}">{{ f.material.value }}{% if f.color %} · {{ f.color }}{% endif %}{% if f.brand %} ({{ f.brand }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-secondary small">
|
||||
Quantity
|
||||
<span class="text-secondary fw-normal">(copies to create)</span>
|
||||
</label>
|
||||
<input type="number" name="quantity"
|
||||
class="form-control bg-dark border-secondary text-white"
|
||||
value="1" min="1" max="500">
|
||||
</div>
|
||||
</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" placeholder="Optional notes (applied to all copies)"></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">Add Jobs</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
87
app/templates/brackets.html
Normal file
87
app/templates/brackets.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Brackets — POPS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h5 class="text-white mb-0">Job Brackets</h5>
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addBracketModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>New Bracket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if brackets %}
|
||||
<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">Customer</th>
|
||||
<th class="fw-normal">Progress</th>
|
||||
<th class="fw-normal text-end">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in brackets %}
|
||||
{% set total = b.jobs | length %}
|
||||
{% set done = b.jobs | selectattr('status.value', 'equalto', 'done') | list | length %}
|
||||
{% set pct = (done / total * 100) | int if total else 0 %}
|
||||
<tr>
|
||||
<td><a href="/brackets/{{ b.id }}" class="text-white text-decoration-none fw-medium">{{ b.name }}</a></td>
|
||||
<td class="text-secondary">{{ b.customer or '—' }}</td>
|
||||
<td style="min-width:200px">
|
||||
{% if total %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress flex-grow-1" style="height:6px">
|
||||
<div class="progress-bar bg-success" style="width:{{ pct }}%"></div>
|
||||
</div>
|
||||
<span class="text-secondary small">{{ done }}/{{ total }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-secondary small">no jobs</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-secondary small text-end">{{ b.created_at.strftime('%d.%m.%Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-secondary">No brackets yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Create Bracket Modal ── #}
|
||||
<div class="modal fade" id="addBracketModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark border-secondary">
|
||||
<form method="POST" action="/brackets">
|
||||
<div class="modal-header border-secondary">
|
||||
<h6 class="modal-title text-white">New Job Bracket</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>
|
||||
<label class="form-label text-secondary small">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="name" class="form-control bg-dark border-secondary text-white"
|
||||
placeholder="e.g. Chair set, 30× phone stand" required autofocus>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label text-secondary small">Customer</label>
|
||||
<input type="text" name="customer" class="form-control bg-dark border-secondary text-white"
|
||||
placeholder="Customer name or reference">
|
||||
</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" placeholder="Optional notes"></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">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -152,6 +152,16 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label text-secondary small">Bracket <span class="text-secondary fw-normal">(optional)</span></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 }}">{{ b.name }}{% if b.customer %} · {{ b.customer }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label text-secondary small">Notes</label>
|
||||
<textarea name="notes" class="form-control bg-dark border-secondary text-white"
|
||||
|
||||
Reference in New Issue
Block a user