Added Printjobs

This commit is contained in:
2026-05-26 18:27:37 +02:00
parent 929969d03a
commit 2432a34198
17 changed files with 1255 additions and 8 deletions
+353 -1
View File
@@ -197,6 +197,19 @@
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.msg-err { color: var(--err); font-size: 0.85rem; margin-top: 0.75rem; }
.msg-warn { color: #f59e0b; font-size: 0.85rem; margin-top: 0.75rem; }
.print-job-orders {
margin-top: 0.75rem;
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.75rem;
background: var(--bg);
}
.print-job-orders .form-check {
margin: 0.35rem 0;
}
.msg-ok { color: var(--ok); font-size: 0.85rem; margin-top: 0.75rem; }
.item-preview-panel {
margin-bottom: 1.25rem;
@@ -470,6 +483,29 @@
@click="newOrder()">+ New</button>
</div>
</div>
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'printJobs' }"
@click="openSection('printJobs')">
Print-Jobs
<span class="nav-chevron" :class="{ open: expanded.printJobs }"></span>
</button>
<div class="nav-children" x-show="expanded.printJobs">
<template x-if="printJobs.length === 0 && !printJobsLoading">
<p class="nav-empty">Keine Print-Jobs</p>
</template>
<template x-for="j in printJobs" :key="j.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'printJobs' && printJobEditingId === j.id }"
@click="selectPrintJob(j)"
x-text="sidebarPrintJobLabel(j)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'printJobs' && !printJobEditingId }"
@click="newPrintJob()">+ Neu</button>
</div>
</div>
</nav>
<div class="sidebar-footer">
@@ -654,6 +690,77 @@
</div>
</div>
<!-- Print-Jobs -->
<div x-show="section === 'printJobs'">
<div class="main-header">
<h2 x-text="printJobEditingId ? 'Print-Job bearbeiten' : 'Neuer Print-Job'"></h2>
<p class="muted">Konfiguration und Orders kombinieren; Bilder werden auf die Item-Positionen verteilt.</p>
</div>
<div class="panel">
<form @submit.prevent="savePrintJob()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="printJobForm.name" placeholder="z.B. Batch Montag">
</div>
<div>
<label>Konfiguration</label>
<select x-model="printJobForm.configuration_id" @change="refreshPrintJobSummary()" required>
<option value="">— wählen —</option>
<template x-for="c in configurations" :key="c.id">
<option :value="c.id" x-text="sidebarConfigLabel(c)"></option>
</template>
</select>
</div>
</div>
<label style="margin-top: 1rem;">Orders</label>
<p class="muted" x-show="orders.length === 0">Zuerst Orders anlegen und Bilder hochladen.</p>
<div class="print-job-orders" x-show="orders.length > 0">
<template x-for="o in orders" :key="o.id">
<label class="form-check">
<input type="checkbox"
:checked="isPrintJobOrderSelected(o.id)"
@change="togglePrintJobOrder(o.id)">
<span x-text="sidebarOrderLabel(o)"></span>
</label>
</template>
</div>
<div class="btn-row" style="margin-top: 1.25rem;">
<button type="submit"
:disabled="printJobSaving || !printJobForm.configuration_id || printJobForm.order_ids.length === 0">
<span x-text="printJobSaving ? 'Speichere…' : (printJobEditingId ? 'Speichern' : 'Anlegen')"></span>
</button>
<button type="button" class="secondary" @click="downloadPrintJobPDF()"
:disabled="printJobPdfGenerating || !printJobEditingId">
<span x-text="printJobPdfGenerating ? 'PDF…' : 'PDF erzeugen'"></span>
</button>
<button type="button" class="danger" x-show="printJobEditingId" @click="deletePrintJob(printJobEditingId)">Löschen</button>
</div>
<p class="msg-err" x-show="printJobError" x-text="printJobError"></p>
<p class="msg-ok" x-show="printJobSuccess" x-text="printJobSuccess"></p>
<p class="msg-warn" x-show="printJobSummary?.image_overflow" x-text="printJobSummary.warning"></p>
</form>
<template x-if="printJobSummary && printJobForm.configuration_id">
<div class="layout-preview-wrap" style="margin-top: 1.5rem;">
<div x-html="layoutPreviewSvg(printJobSummary.preview)"></div>
<div class="layout-stats">
<span><strong x-text="printJobSummary.image_count"></strong> Bilder</span>
<span><strong x-text="printJobSummary.slot_count"></strong> Item-Positionen</span>
<span><strong x-text="printJobSummary.assignments?.length || Math.min(printJobSummary.image_count, printJobSummary.slot_count)"></strong> zugeordnet</span>
<span x-show="printJobSummary.image_overflow">Überschuss <strong x-text="printJobSummary.image_count - printJobSummary.slot_count"></strong></span>
</div>
</div>
</template>
<p class="muted" x-show="printJobForm.configuration_id && !printJobSummary && !printJobSummaryLoading">
Keine Layout-Vorschau für diese Konfiguration.
</p>
<p class="muted" x-show="printJobSummaryLoading">Berechne Zuordnung…</p>
</div>
</div>
<!-- Orders -->
<div x-show="section === 'orders'">
<div class="main-header">
@@ -749,7 +856,7 @@
apiOk: null,
section: 'plates',
expanded: { plates: true, items: false, configurations: false, orders: false },
expanded: { plates: true, items: false, configurations: false, orders: false, printJobs: false },
plates: [],
platesLoading: false,
@@ -812,6 +919,18 @@
orderImageVersion: 0,
orderLightbox: null,
printJobs: [],
printJobsLoading: false,
printJobSaving: false,
printJobPdfGenerating: false,
printJobError: '',
printJobSuccess: '',
printJobEditingId: null,
printJobForm: { name: '', configuration_id: '', order_ids: [] },
printJobSummary: null,
printJobSummaryLoading: false,
_printJobSummaryTimer: null,
openSection(name) {
if (this.section === name) {
this.expanded[name] = !this.expanded[name];
@@ -869,6 +988,18 @@
this.cancelOrderEdit();
},
async selectPrintJob(j) {
this.section = 'printJobs';
this.expanded.printJobs = true;
await this.editPrintJob(j);
},
newPrintJob() {
this.section = 'printJobs';
this.expanded.printJobs = true;
this.cancelPrintJobEdit();
},
sidebarPlateLabel(p) {
const n = p.name || 'Platte';
return n + ' · ' + this.fmtSize(p.width_mm, p.height_mm);
@@ -889,6 +1020,32 @@
return parts.length ? n + ' · ' + parts.join(' · ') : n;
},
sidebarPrintJobLabel(j) {
const n = j.name || ('Print-Job ' + j.id.slice(0, 8));
const parts = [];
const cfg = this.configurations.find(c => c.id === j.configuration_id);
if (cfg) parts.push(this.sidebarConfigLabel(cfg));
const orderCount = (j.order_ids || []).length;
if (orderCount) parts.push(orderCount + ' Order' + (orderCount === 1 ? '' : 's'));
const s = j.summary;
if (s?.image_count != null) parts.push(s.image_count + ' Bilder');
if (s?.image_overflow) parts.push('!');
return parts.length ? n + ' · ' + parts.join(' · ') : n;
},
isPrintJobOrderSelected(orderId) {
return (this.printJobForm.order_ids || []).includes(orderId);
},
togglePrintJobOrder(orderId) {
const ids = [...(this.printJobForm.order_ids || [])];
const i = ids.indexOf(orderId);
if (i >= 0) ids.splice(i, 1);
else ids.push(orderId);
this.printJobForm.order_ids = ids;
this.refreshPrintJobSummary();
},
orderFormFrom(o) {
return {
name: o.name || '',
@@ -951,6 +1108,7 @@
this.loadItems(),
this.loadConfigurations(),
this.loadOrders(),
this.loadPrintJobs(),
]);
}
},
@@ -1523,11 +1681,205 @@
}
await this.loadOrders();
if (this.orderEditingId === id) this.cancelOrderEdit();
if (this.section === 'printJobs') this.refreshPrintJobSummary();
} catch (e) {
this.orderError = String(e.message || e);
}
},
async loadPrintJobs() {
this.printJobsLoading = true;
try {
this.printJobs = await this.apiJSON('GET', '/print-jobs') || [];
} catch (e) {
this.printJobError = String(e.message || e);
} finally {
this.printJobsLoading = false;
}
},
defaultPrintJobForm() {
return { name: '', configuration_id: '', order_ids: [] };
},
async editPrintJob(j) {
this.printJobEditingId = j.id;
this.printJobForm = {
name: j.name || '',
configuration_id: j.configuration_id || '',
order_ids: [...(j.order_ids || [])],
};
this.printJobError = '';
this.printJobSuccess = '';
try {
const fresh = await this.apiJSON('GET', '/print-jobs/' + j.id);
this.printJobForm = {
name: fresh.name || '',
configuration_id: fresh.configuration_id || '',
order_ids: [...(fresh.order_ids || [])],
};
if (fresh.summary) this.printJobSummary = fresh.summary;
} catch (e) {
this.printJobError = String(e.message || e);
}
await this.refreshPrintJobSummary();
},
cancelPrintJobEdit() {
this.printJobEditingId = null;
this.printJobForm = this.defaultPrintJobForm();
this.printJobSummary = null;
this.printJobError = '';
this.printJobSuccess = '';
},
async savePrintJob() {
this.printJobSaving = true;
this.printJobError = '';
this.printJobSuccess = '';
const body = {
name: this.printJobForm.name,
configuration_id: this.printJobForm.configuration_id,
order_ids: [...(this.printJobForm.order_ids || [])],
};
try {
let saved;
if (this.printJobEditingId) {
saved = await this.apiJSON('PUT', '/print-jobs/' + this.printJobEditingId, body);
this.printJobSuccess = 'Print-Job gespeichert.';
} else {
saved = await this.apiJSON('POST', '/print-jobs', body);
this.printJobSuccess = 'Print-Job angelegt.';
}
const keepId = saved?.id || this.printJobEditingId;
await this.loadPrintJobs();
if (keepId) {
const j = this.printJobs.find(x => x.id === keepId);
if (j) await this.editPrintJob(j);
else this.cancelPrintJobEdit();
} else {
this.cancelPrintJobEdit();
}
} catch (e) {
this.printJobError = String(e.message || e);
} finally {
this.printJobSaving = false;
}
},
async deletePrintJob(id) {
if (!confirm('Print-Job wirklich löschen?')) return;
this.printJobError = '';
try {
const res = await fetch(this.apiUrl('/print-jobs/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
await this.loadPrintJobs();
if (this.printJobEditingId === id) this.cancelPrintJobEdit();
} catch (e) {
this.printJobError = String(e.message || e);
}
},
refreshPrintJobSummary() {
clearTimeout(this._printJobSummaryTimer);
this._printJobSummaryTimer = setTimeout(() => this.fetchPrintJobSummary(), 200);
},
printJobFormMatchesSaved() {
if (!this.printJobEditingId) return false;
const saved = this.printJobs.find(j => j.id === this.printJobEditingId);
if (!saved) return false;
const a = [...(this.printJobForm.order_ids || [])].sort().join(',');
const b = [...(saved.order_ids || [])].sort().join(',');
return saved.configuration_id === this.printJobForm.configuration_id && a === b;
},
async fetchPrintJobSummary() {
if (!this.printJobForm.configuration_id) {
this.printJobSummary = null;
return;
}
this.printJobSummaryLoading = true;
let serverSummary = null;
try {
if (this.printJobEditingId && this.printJobForm.order_ids?.length && this.printJobFormMatchesSaved()) {
serverSummary = await this.apiJSON('GET', '/print-jobs/' + this.printJobEditingId + '/summary');
}
} catch (_) {}
this.printJobSummary = this.computePrintJobSummaryLocal(serverSummary);
this.printJobSummaryLoading = false;
},
computePrintJobSummaryLocal(serverSummary) {
const cfg = this.configurations.find(c => c.id === this.printJobForm.configuration_id);
const preview = cfg?.preview;
if (!preview) return serverSummary || null;
const selected = this.orders.filter(o => this.printJobForm.order_ids.includes(o.id));
let imageCount = 0;
for (const o of selected) {
imageCount += (o.images || []).length;
}
const slots = preview.count || 0;
const overflow = imageCount > slots;
let warning = '';
if (overflow) {
warning = `${imageCount} Bilder für ${slots} Item-Positionen: ${imageCount - slots} Bilder werden nicht platziert`;
}
const base = serverSummary || {};
return {
...base,
image_count: imageCount,
slot_count: slots,
image_overflow: overflow,
warning: warning || base.warning,
preview,
assignments: base.assignments || [],
};
},
async downloadPrintJobPDF() {
if (!this.printJobEditingId) return;
this.printJobPdfGenerating = true;
this.printJobError = '';
try {
const res = await fetch(this.apiUrl('/print-jobs/' + this.printJobEditingId + '/pdf'));
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch (_) {}
throw new Error(msg);
}
const warn = res.headers.get('X-Print-Job-Warning');
if (warn) {
this.printJobSummary = {
...(this.printJobSummary || {}),
image_overflow: true,
warning: warn,
};
}
const blob = await res.blob();
const name = (this.printJobForm.name || 'print-job-' + this.printJobEditingId.slice(0, 8))
.replace(/[^\w\-]+/g, '_') + '.pdf';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
this.printJobSuccess = warn ? 'PDF erzeugt (mit Warnung).' : 'PDF erzeugt.';
} catch (e) {
this.printJobError = String(e.message || e);
} finally {
this.printJobPdfGenerating = false;
}
},
refreshLayoutPreview() {
clearTimeout(this._layoutTimer);
this._layoutTimer = setTimeout(() => this.fetchLayoutPreview(), 200);
+2
View File
@@ -17,6 +17,7 @@ func NewHandler() http.Handler {
items := store.NewItemStore("")
configs := store.NewConfigurationStore("")
orders := store.NewOrderStore("")
printJobs := store.NewPrintJobStore("")
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
@@ -32,6 +33,7 @@ func NewHandler() http.Handler {
mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items))
registerConfigurationRoutes(mux, configs, plates, items)
registerOrderRoutes(mux, orders)
registerPrintJobRoutes(mux, printJobs, configs, plates, items, orders)
return withCORS(mux)
}
+255
View File
@@ -0,0 +1,255 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"printer.backend/internal/model"
"printer.backend/internal/printjob"
"printer.backend/internal/store"
)
func registerPrintJobRoutes(
mux *http.ServeMux,
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) {
mux.HandleFunc("GET /print-jobs", listPrintJobs(jobs, configs, plates, items, orders))
mux.HandleFunc("POST /print-jobs", savePrintJob(jobs, configs, orders))
mux.HandleFunc("GET /print-jobs/{id}", getPrintJob(jobs, configs, plates, items, orders))
mux.HandleFunc("PUT /print-jobs/{id}", updatePrintJob(jobs, configs, orders))
mux.HandleFunc("DELETE /print-jobs/{id}", deletePrintJob(jobs))
mux.HandleFunc("GET /print-jobs/{id}/summary", printJobSummary(jobs, configs, plates, items, orders))
mux.HandleFunc("GET /print-jobs/{id}/pdf", printJobPDF(jobs, configs, plates, items, orders))
}
type printJobResponse struct {
model.PrintJob
Summary *model.PrintJobSummary `json:"summary,omitempty"`
}
type savePrintJobRequest struct {
Name string `json:"name"`
ConfigurationID string `json:"configuration_id"`
OrderIDs []string `json:"order_ids"`
}
func listPrintJobs(
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
list, err := jobs.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if list == nil {
list = []model.PrintJob{}
}
out := make([]printJobResponse, 0, len(list))
for _, j := range list {
out = append(out, printJobResponseFor(j, configs, plates, items, orders))
}
writeJSON(w, http.StatusOK, out)
}
}
func savePrintJob(jobs *store.PrintJobStore, configs *store.ConfigurationStore, orders *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req savePrintJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := validatePrintJobRequest(req, configs, orders); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
j := model.PrintJob{
Name: req.Name,
ConfigurationID: req.ConfigurationID,
OrderIDs: req.OrderIDs,
CreatedAt: time.Now().UTC(),
}
saved, err := jobs.Save(j)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, saved)
}
}
func getPrintJob(
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
j, err := jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, printJobResponseFor(j, configs, plates, items, orders))
}
}
func updatePrintJob(jobs *store.PrintJobStore, configs *store.ConfigurationStore, orders *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req savePrintJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := validatePrintJobRequest(req, configs, orders); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
j := model.PrintJob{
Name: req.Name,
ConfigurationID: req.ConfigurationID,
OrderIDs: req.OrderIDs,
}
saved, err := jobs.Update(id, j)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, saved)
}
}
func deletePrintJob(jobs *store.PrintJobStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := jobs.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func printJobSummary(
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
j, err := jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
summary, err := buildPrintJobSummary(j, configs, plates, items, orders)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, summary)
}
}
func printJobPDF(
jobs *store.PrintJobStore,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
j, err := jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
pdf, summary, err := printjob.RenderPDF(j, configs, plates, items, orders)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if summary.ImageOverflow {
w.Header().Set("X-Print-Job-Warning", summary.Warning)
}
name := j.Name
if name == "" {
name = "print-job-" + j.ID[:8]
}
servePDF(w, pdf, sanitizeFilename(name)+".pdf")
}
}
func validatePrintJobRequest(req savePrintJobRequest, configs *store.ConfigurationStore, orders *store.OrderStore) error {
if req.ConfigurationID == "" {
return errors.New("configuration_id is required")
}
if _, err := configs.Get(req.ConfigurationID); err != nil {
return errors.New("configuration not found: " + req.ConfigurationID)
}
if len(req.OrderIDs) == 0 {
return errors.New("at least one order_id is required")
}
for _, id := range req.OrderIDs {
if _, err := orders.Get(id); err != nil {
return errors.New("order not found: " + id)
}
}
return nil
}
func printJobResponseFor(
j model.PrintJob,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) printJobResponse {
resp := printJobResponse{PrintJob: j}
if summary, err := buildPrintJobSummary(j, configs, plates, items, orders); err == nil {
resp.Summary = &summary
}
return resp
}
func buildPrintJobSummary(
j model.PrintJob,
configs *store.ConfigurationStore,
plates *store.PlateStore,
items *store.ItemStore,
orders *store.OrderStore,
) (model.PrintJobSummary, error) {
cfg, err := configs.Get(j.ConfigurationID)
if err != nil {
return model.PrintJobSummary{}, err
}
preview, err := buildPreview(plates, items, cfg.PlateID, cfg.ItemID, cfg.SpacingMM)
if err != nil {
return model.PrintJobSummary{}, err
}
orderList := make([]model.Order, 0, len(j.OrderIDs))
for _, id := range j.OrderIDs {
o, err := orders.Get(id)
if err != nil {
return model.PrintJobSummary{}, err
}
orderList = append(orderList, o)
}
return printjob.Summary(orderList, preview), nil
}