@@ -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);
diff --git a/internal/server/api/api.go b/internal/server/api/api.go
index 24d7314..93e513f 100644
--- a/internal/server/api/api.go
+++ b/internal/server/api/api.go
@@ -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)
}
diff --git a/internal/server/api/printjob.go b/internal/server/api/printjob.go
new file mode 100644
index 0000000..655933a
--- /dev/null
+++ b/internal/server/api/printjob.go
@@ -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
+}
diff --git a/internal/store/printjob.go b/internal/store/printjob.go
new file mode 100644
index 0000000..7cbce76
--- /dev/null
+++ b/internal/store/printjob.go
@@ -0,0 +1,97 @@
+package store
+
+import (
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "github.com/google/uuid"
+ "printer.backend/internal/model"
+ "printer.backend/internal/paths"
+)
+
+// PrintJobStore persists print jobs as JSON files.
+type PrintJobStore struct {
+ dir string
+}
+
+// NewPrintJobStore creates a store under dir (default data/print_jobs).
+func NewPrintJobStore(dir string) *PrintJobStore {
+ if dir == "" {
+ dir = paths.PrintJobsDir
+ }
+ return &PrintJobStore{dir: dir}
+}
+
+// List returns all print jobs sorted by creation time (newest first).
+func (s *PrintJobStore) List() ([]model.PrintJob, error) {
+ return listFromDir(s.dir,
+ func(name string) bool { return filepath.Ext(name) == ".json" },
+ "print jobs dir",
+ func(j model.PrintJob) time.Time { return j.CreatedAt },
+ )
+}
+
+// Get returns a print job by ID.
+func (s *PrintJobStore) Get(id string) (model.PrintJob, error) {
+ list, err := s.List()
+ if err != nil {
+ return model.PrintJob{}, err
+ }
+ j, err := findByID(list, id, func(j model.PrintJob) string { return j.ID })
+ if err != nil {
+ return model.PrintJob{}, fmt.Errorf("print job not found: %s", id)
+ }
+ if j.OrderIDs == nil {
+ j.OrderIDs = []string{}
+ }
+ return j, nil
+}
+
+// Save writes a new print job and assigns an ID when empty.
+func (s *PrintJobStore) Save(j model.PrintJob) (model.PrintJob, error) {
+ if err := ensureDir(s.dir); err != nil {
+ return model.PrintJob{}, err
+ }
+ if j.ID == "" {
+ j.ID = uuid.NewString()
+ }
+ if j.OrderIDs == nil {
+ j.OrderIDs = []string{}
+ }
+ now := stampNew(&j.CreatedAt)
+ j.CreatedAt = now
+ j.UpdatedAt = now
+
+ path := filepath.Join(s.dir, j.ID+".json")
+ if err := writeJSON(path, j); err != nil {
+ return model.PrintJob{}, fmt.Errorf("write print job: %w", err)
+ }
+ return j, nil
+}
+
+// Update replaces an existing print job (preserves id and created_at).
+func (s *PrintJobStore) Update(id string, j model.PrintJob) (model.PrintJob, error) {
+ existing, err := s.Get(id)
+ if err != nil {
+ return model.PrintJob{}, err
+ }
+ j.ID = existing.ID
+ j.CreatedAt = existing.CreatedAt
+ j.UpdatedAt = time.Now().UTC()
+ if j.OrderIDs == nil {
+ j.OrderIDs = []string{}
+ }
+
+ path := filepath.Join(s.dir, id+".json")
+ if err := writeJSON(path, j); err != nil {
+ return model.PrintJob{}, fmt.Errorf("write print job: %w", err)
+ }
+ return j, nil
+}
+
+// Delete removes a print job by ID.
+func (s *PrintJobStore) Delete(id string) error {
+ path := filepath.Join(s.dir, id+".json")
+ return removeFile(path, fmt.Sprintf("print job not found: %s", id))
+}