diff --git a/internal/layout/layout.go b/internal/layout/layout.go
new file mode 100644
index 0000000..962f479
--- /dev/null
+++ b/internal/layout/layout.go
@@ -0,0 +1,75 @@
+package layout
+
+import (
+ "printer.backend/internal/model"
+)
+
+// Footprint returns the physical width and height one item occupies on the plate (mm).
+// Includes bleed on all sides and the item's margin as a safe zone.
+func Footprint(spec model.ItemSpec) (width, height float64) {
+ w := spec.SizeMM + 2*spec.BleedMM + 2*spec.MarginMM
+ return w, w
+}
+
+// CellPitch returns width and height of one grid cell (footprint + spacing between items).
+func CellPitch(spec model.ItemSpec, spacingMM float64) (width, height float64) {
+ fw, fh := Footprint(spec)
+ return fw + spacingMM, fh + spacingMM
+}
+
+// Pack computes the maximum grid of items that fit on the plate.
+func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.LayoutPreview {
+ pw := plate.PrintableWidth()
+ ph := plate.PrintableHeight()
+ fw, fh := Footprint(spec)
+ cw, ch := CellPitch(spec, spacingMM)
+
+ cols, rows := gridCount(pw, ph, cw, ch)
+ count := cols * rows
+
+ ox := plate.MarginLeft
+ oy := plate.MarginTop
+
+ positions := make([]model.LayoutPosition, 0, count)
+ for row := 0; row < rows; row++ {
+ for col := 0; col < cols; col++ {
+ positions = append(positions, model.LayoutPosition{
+ XMM: ox + float64(col)*cw,
+ YMM: oy + float64(row)*ch,
+ })
+ }
+ }
+
+ return model.LayoutPreview{
+ PlateWidthMM: plate.WidthMM,
+ PlateHeightMM: plate.HeightMM,
+ PrintableXMM: ox,
+ PrintableYMM: oy,
+ PrintableWMM: pw,
+ PrintableHMM: ph,
+ CellWidthMM: cw,
+ CellHeightMM: ch,
+ FootprintWMM: fw,
+ FootprintHMM: fh,
+ SpacingMM: spacingMM,
+ Columns: cols,
+ Rows: rows,
+ Count: count,
+ Positions: positions,
+ }
+}
+
+func gridCount(printableW, printableH, cellW, cellH float64) (cols, rows int) {
+ if printableW <= 0 || printableH <= 0 || cellW <= 0 || cellH <= 0 {
+ return 0, 0
+ }
+ cols = int(printableW / cellW)
+ rows = int(printableH / cellH)
+ if cols < 0 {
+ cols = 0
+ }
+ if rows < 0 {
+ rows = 0
+ }
+ return cols, rows
+}
diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go
new file mode 100644
index 0000000..7011b40
--- /dev/null
+++ b/internal/layout/layout_test.go
@@ -0,0 +1,46 @@
+package layout
+
+import (
+ "testing"
+
+ "printer.backend/internal/model"
+)
+
+func TestPack(t *testing.T) {
+ plate := model.Plate{
+ WidthMM: 300,
+ HeightMM: 400,
+ MarginTop: 10,
+ MarginRight: 10,
+ MarginBottom: 10,
+ MarginLeft: 10,
+ }
+ spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5}
+ // footprint = 80+4+10 = 94, cell = 94+2 = 96
+ // printable 280x380 -> cols=2, rows=3 -> 6
+ preview := Pack(plate, spec, 2)
+ if preview.Columns != 2 {
+ t.Fatalf("columns: got %d want 2", preview.Columns)
+ }
+ if preview.Rows != 3 {
+ t.Fatalf("rows: got %d want 3", preview.Rows)
+ }
+ if preview.Count != 6 {
+ t.Fatalf("count: got %d want 6", preview.Count)
+ }
+ if len(preview.Positions) != 6 {
+ t.Fatalf("positions: got %d want 6", len(preview.Positions))
+ }
+ if preview.Positions[0].XMM != 10 || preview.Positions[0].YMM != 10 {
+ t.Fatalf("first position: got (%v,%v)", preview.Positions[0].XMM, preview.Positions[0].YMM)
+ }
+}
+
+func TestPackZeroCell(t *testing.T) {
+ plate := model.Plate{WidthMM: 10, HeightMM: 10}
+ spec := model.ItemSpec{SizeMM: 100}
+ preview := Pack(plate, spec, 0)
+ if preview.Count != 0 {
+ t.Fatalf("expected 0 items, got %d", preview.Count)
+ }
+}
diff --git a/internal/model/configuration.go b/internal/model/configuration.go
new file mode 100644
index 0000000..e341bda
--- /dev/null
+++ b/internal/model/configuration.go
@@ -0,0 +1,40 @@
+package model
+
+import "time"
+
+// Configuration links a plate and item with layout spacing for maximum packing.
+type Configuration struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ PlateID string `json:"plate_id"`
+ ItemID string `json:"item_id"`
+ SpacingMM float64 `json:"spacing_mm"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// LayoutPosition is the top-left corner of one item on the plate (mm).
+type LayoutPosition struct {
+ XMM float64 `json:"x_mm"`
+ YMM float64 `json:"y_mm"`
+}
+
+// LayoutPreview describes how many items fit on a plate.
+type LayoutPreview struct {
+ PlateID string `json:"plate_id"`
+ ItemID string `json:"item_id"`
+ SpacingMM float64 `json:"spacing_mm"`
+ PlateWidthMM float64 `json:"plate_width_mm"`
+ PlateHeightMM float64 `json:"plate_height_mm"`
+ PrintableXMM float64 `json:"printable_x_mm"`
+ PrintableYMM float64 `json:"printable_y_mm"`
+ PrintableWMM float64 `json:"printable_width_mm"`
+ PrintableHMM float64 `json:"printable_height_mm"`
+ CellWidthMM float64 `json:"cell_width_mm"`
+ CellHeightMM float64 `json:"cell_height_mm"`
+ FootprintWMM float64 `json:"footprint_width_mm"`
+ FootprintHMM float64 `json:"footprint_height_mm"`
+ Columns int `json:"columns"`
+ Rows int `json:"rows"`
+ Count int `json:"count"`
+ Positions []LayoutPosition `json:"positions"`
+}
diff --git a/internal/paths/paths.go b/internal/paths/paths.go
index 539988b..8fb0007 100644
--- a/internal/paths/paths.go
+++ b/internal/paths/paths.go
@@ -2,7 +2,8 @@ package paths
// Runtime data directories (relative to process working directory).
const (
- DataDir = "data"
- PlatesDir = "data/plates"
- SVGTemplateDir = "data/svg_template"
+ DataDir = "data"
+ PlatesDir = "data/plates"
+ SVGTemplateDir = "data/svg_template"
+ ConfigurationsDir = "data/configurations"
)
diff --git a/internal/server/admin/static/index.html b/internal/server/admin/static/index.html
index 20ce2b3..a64bb58 100644
--- a/internal/server/admin/static/index.html
+++ b/internal/server/admin/static/index.html
@@ -151,6 +151,63 @@
padding: 0 0.75rem 0.75rem;
}
.row-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
+ .layout-preview-wrap {
+ margin-top: 1rem;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1rem;
+ overflow: auto;
+ }
+ .layout-preview-wrap svg {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin: 0 auto;
+ }
+ .layout-stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem 2rem;
+ margin-top: 0.75rem;
+ font-size: 0.85rem;
+ }
+ .layout-stats strong { color: var(--text); }
+ .layout-stats span { color: var(--muted); }
+ select {
+ width: 100%;
+ margin-top: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 0.875rem;
+ }
+ .config-list {
+ display: grid;
+ gap: 1rem;
+ margin-top: 1rem;
+ }
+ .config-entry {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1rem;
+ }
+ .config-entry header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ margin-bottom: 0;
+ padding-bottom: 0.5rem;
+ border-bottom: none;
+ }
+ .config-entry header h3 {
+ font-size: 0.95rem;
+ font-weight: 600;
+ }
@@ -275,6 +332,88 @@
+
+ Konfiguration — Layout-Vorschau
+ Kombiniert Platte und Item; berechnet maximale Stückzahl unter Einhaltung aller Margins und Abstände.
+
+
+
+
+
+
+ Items passen
+ Raster
+ Fußabdruck
+ Zellenabstand
+ Druckfläche
+
+
+
+
+ Keine Vorschau (Platte zu klein oder API-Fehler).
+
+ Berechne Layout…
+
+
+
+ Gespeicherte Konfigurationen
+
+
+
Items
@@ -335,6 +474,20 @@
padding_mm: 3,
},
+ configurations: [],
+ configSaving: false,
+ configError: '',
+ configSuccess: '',
+ configForm: {
+ name: '',
+ plate_id: '',
+ item_id: '',
+ spacing_mm: 2,
+ },
+ layoutPreview: null,
+ layoutLoading: false,
+ _layoutTimer: null,
+
apiUrl(path) {
return `${this.apiBase.replace(/\/$/, '')}${path}`;
},
@@ -358,7 +511,7 @@
async onApiBaseChange() {
await this.checkAPI();
if (this.apiOk) {
- await Promise.all([this.loadPlates(), this.loadItems()]);
+ await Promise.all([this.loadPlates(), this.loadItems(), this.loadConfigurations()]);
}
},
@@ -419,6 +572,7 @@
});
this.plateSuccess = 'Platte gespeichert.';
await this.loadPlates();
+ this.refreshLayoutPreview();
} catch (e) {
this.plateError = String(e.message || e);
} finally {
@@ -426,8 +580,42 @@
}
},
+ configsUsingPlate(plateId) {
+ return (this.configurations || []).filter(c => c.plate_id === plateId);
+ },
+
+ configsUsingItem(itemId) {
+ return (this.configurations || []).filter(c => c.item_id === itemId);
+ },
+
+ confirmDeletePlate(id) {
+ const used = this.configsUsingPlate(id);
+ if (used.length === 0) {
+ return confirm('Platte wirklich löschen?');
+ }
+ const names = used.map(c => '• ' + this.configSummary(c)).join('\n');
+ return confirm(
+ 'Diese Platte wird in ' + used.length + ' Konfiguration(en) verwendet:\n\n'
+ + names + '\n\n'
+ + 'Platte trotzdem wirklich löschen? Die Konfigurationen bleiben gespeichert, verweisen dann aber auf eine gelöschte Platte.'
+ );
+ },
+
+ confirmDeleteItem(id) {
+ const used = this.configsUsingItem(id);
+ if (used.length === 0) {
+ return confirm('Item inkl. SVG wirklich löschen?');
+ }
+ const names = used.map(c => '• ' + this.configSummary(c)).join('\n');
+ return confirm(
+ 'Dieses Item wird in ' + used.length + ' Konfiguration(en) verwendet:\n\n'
+ + names + '\n\n'
+ + 'Item inkl. SVG trotzdem wirklich löschen? Die Konfigurationen bleiben gespeichert, verweisen dann aber auf ein gelöschtes Item.'
+ );
+ },
+
async deletePlate(id) {
- if (!confirm('Platte wirklich löschen?')) return;
+ if (!this.confirmDeletePlate(id)) return;
this.plateError = '';
try {
const res = await fetch(this.apiUrl('/plates/' + id), { method: 'DELETE' });
@@ -435,7 +623,11 @@
const text = await res.text();
throw new Error(text || res.statusText);
}
- await this.loadPlates();
+ await Promise.all([this.loadPlates(), this.loadConfigurations()]);
+ if (this.configForm.plate_id === id) {
+ this.configForm.plate_id = '';
+ this.layoutPreview = null;
+ }
} catch (e) {
this.plateError = String(e.message || e);
}
@@ -467,6 +659,7 @@
this.itemSuccess = 'Item erzeugt.';
this.itemForm.name = '';
await this.loadItems();
+ this.refreshLayoutPreview();
} catch (e) {
this.itemError = String(e.message || e);
} finally {
@@ -475,7 +668,7 @@
},
async deleteItem(id) {
- if (!confirm('Item inkl. SVG wirklich löschen?')) return;
+ if (!this.confirmDeleteItem(id)) return;
this.itemError = '';
try {
const res = await fetch(this.apiUrl('/items/' + id), { method: 'DELETE' });
@@ -483,15 +676,133 @@
const text = await res.text();
throw new Error(text || res.statusText);
}
- await this.loadItems();
+ await Promise.all([this.loadItems(), this.loadConfigurations()]);
+ if (this.configForm.item_id === id) {
+ this.configForm.item_id = '';
+ this.layoutPreview = null;
+ }
} catch (e) {
this.itemError = String(e.message || e);
}
},
+ async loadConfigurations() {
+ try {
+ this.configurations = await this.apiJSON('GET', '/configurations') || [];
+ } catch (e) {
+ this.configError = String(e.message || e);
+ }
+ },
+
+ async saveConfiguration() {
+ this.configSaving = true;
+ this.configError = '';
+ this.configSuccess = '';
+ try {
+ await this.apiJSON('POST', '/configurations', {
+ name: this.configForm.name,
+ plate_id: this.configForm.plate_id,
+ item_id: this.configForm.item_id,
+ spacing_mm: Number(this.configForm.spacing_mm) || 0,
+ });
+ this.configSuccess = 'Konfiguration gespeichert.';
+ await this.loadConfigurations();
+ } catch (e) {
+ this.configError = String(e.message || e);
+ } finally {
+ this.configSaving = false;
+ }
+ },
+
+ async deleteConfiguration(id) {
+ if (!confirm('Konfiguration wirklich löschen?')) return;
+ this.configError = '';
+ try {
+ const res = await fetch(this.apiUrl('/configurations/' + id), { method: 'DELETE' });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || res.statusText);
+ }
+ await this.loadConfigurations();
+ } catch (e) {
+ this.configError = String(e.message || e);
+ }
+ },
+
+ refreshLayoutPreview() {
+ clearTimeout(this._layoutTimer);
+ this._layoutTimer = setTimeout(() => this.fetchLayoutPreview(), 200);
+ },
+
+ async fetchLayoutPreview() {
+ const plateId = this.configForm.plate_id;
+ const itemId = this.configForm.item_id;
+ if (!plateId || !itemId || !this.apiOk) {
+ this.layoutPreview = null;
+ return;
+ }
+ this.layoutLoading = true;
+ try {
+ const q = new URLSearchParams({
+ plate_id: plateId,
+ item_id: itemId,
+ spacing_mm: String(Number(this.configForm.spacing_mm) || 0),
+ });
+ this.layoutPreview = await this.apiJSON('GET', '/layout/preview?' + q);
+ } catch (e) {
+ this.layoutPreview = null;
+ } finally {
+ this.layoutLoading = false;
+ }
+ },
+
+ labelPlate(p) {
+ const n = p.name ? p.name + ' — ' : '';
+ return n + this.fmtSize(p.width_mm, p.height_mm);
+ },
+
labelItem(it) {
return it.name || it.svg_template;
},
+
+ configSummary(c) {
+ const plate = this.plates.find(p => p.id === c.plate_id);
+ const item = this.items.find(i => i.id === c.item_id);
+ const plateLabel = plate ? this.labelPlate(plate) : c.plate_id;
+ const itemLabel = item ? this.labelItem(item) : c.item_id;
+ return plateLabel + ' · ' + itemLabel + ' · Abstand ' + c.spacing_mm + ' mm';
+ },
+
+ layoutPreviewSvg(preview) {
+ const p = preview || this.layoutPreview;
+ if (!p || !p.plate_width_mm) return '';
+
+ const pw = p.plate_width_mm;
+ const ph = p.plate_height_mm;
+ const maxPx = 520;
+ const scale = Math.min(maxPx / pw, maxPx / ph, 2);
+
+ const esc = (s) => String(s).replace(/&/g, '&').replace(/`;
+ }
+
+ const px = p.printable_x_mm * scale;
+ const py = p.printable_y_mm * scale;
+ const prW = p.printable_width_mm * scale;
+ const prH = p.printable_height_mm * scale;
+
+ return ``;
+ },
fmtSize(w, h) {
return `${w} × ${h} mm`;
},
diff --git a/internal/server/api/api.go b/internal/server/api/api.go
index 6854ec5..9882e27 100644
--- a/internal/server/api/api.go
+++ b/internal/server/api/api.go
@@ -15,6 +15,7 @@ import (
func NewHandler() http.Handler {
plates := store.NewPlateStore("")
items := store.NewItemStore("")
+ configs := store.NewConfigurationStore("")
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
@@ -26,6 +27,7 @@ func NewHandler() http.Handler {
mux.HandleFunc("POST /items", saveItem(items))
mux.HandleFunc("DELETE /items/{id}", deleteItem(items))
mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items))
+ registerConfigurationRoutes(mux, configs, plates, items)
return withCORS(mux)
}
diff --git a/internal/server/api/configuration.go b/internal/server/api/configuration.go
new file mode 100644
index 0000000..fa74204
--- /dev/null
+++ b/internal/server/api/configuration.go
@@ -0,0 +1,218 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "time"
+
+ "printer.backend/internal/layout"
+ "printer.backend/internal/model"
+ "printer.backend/internal/store"
+)
+
+func registerConfigurationRoutes(mux *http.ServeMux, configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) {
+ mux.HandleFunc("GET /configurations", listConfigurations(configs, plates, items))
+ mux.HandleFunc("POST /configurations", saveConfiguration(configs, plates, items))
+ mux.HandleFunc("DELETE /configurations/{id}", deleteConfiguration(configs))
+ mux.HandleFunc("GET /configurations/{id}/preview", previewConfiguration(configs, plates, items))
+ mux.HandleFunc("GET /layout/preview", layoutPreview(plates, items))
+}
+
+type configurationResponse struct {
+ model.Configuration
+ Preview *model.LayoutPreview `json:"preview,omitempty"`
+ PreviewError string `json:"preview_error,omitempty"`
+}
+
+func listConfigurations(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ list, err := configs.List()
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err)
+ return
+ }
+ if list == nil {
+ list = []model.Configuration{}
+ }
+
+ out := make([]configurationResponse, 0, len(list))
+ for _, c := range list {
+ out = append(out, configurationResponseFor(c, plates, items))
+ }
+ writeJSON(w, http.StatusOK, out)
+ }
+}
+
+type saveConfigurationRequest struct {
+ Name string `json:"name"`
+ PlateID string `json:"plate_id"`
+ ItemID string `json:"item_id"`
+ SpacingMM float64 `json:"spacing_mm"`
+}
+
+func saveConfiguration(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req saveConfigurationRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ if req.PlateID == "" || req.ItemID == "" {
+ writeError(w, http.StatusBadRequest, errors.New("plate_id and item_id are required"))
+ return
+ }
+ if _, err := findPlate(plates, req.PlateID); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ if _, err := items.Get(req.ItemID); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ if req.SpacingMM < 0 {
+ writeError(w, http.StatusBadRequest, errors.New("spacing_mm must be non-negative"))
+ return
+ }
+
+ c := model.Configuration{
+ Name: req.Name,
+ PlateID: req.PlateID,
+ ItemID: req.ItemID,
+ SpacingMM: req.SpacingMM,
+ CreatedAt: time.Now().UTC(),
+ }
+ saved, err := configs.Save(c)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err)
+ return
+ }
+
+ resp := configurationResponseFor(saved, plates, items)
+ if resp.PreviewError != "" {
+ writeError(w, http.StatusInternalServerError, errors.New(resp.PreviewError))
+ return
+ }
+ writeJSON(w, http.StatusCreated, resp)
+ }
+}
+
+func deleteConfiguration(configs *store.ConfigurationStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if id == "" {
+ writeError(w, http.StatusBadRequest, errors.New("id required"))
+ return
+ }
+ if err := configs.Delete(id); err != nil {
+ writeError(w, http.StatusNotFound, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+func previewConfiguration(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ c, err := configs.Get(id)
+ if err != nil {
+ writeError(w, http.StatusNotFound, err)
+ return
+ }
+ preview, err := buildPreview(plates, items, c.PlateID, c.ItemID, c.SpacingMM)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ preview.PlateID = c.PlateID
+ preview.ItemID = c.ItemID
+ writeJSON(w, http.StatusOK, preview)
+ }
+}
+
+func layoutPreview(plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ plateID := r.URL.Query().Get("plate_id")
+ itemID := r.URL.Query().Get("item_id")
+ if plateID == "" || itemID == "" {
+ writeError(w, http.StatusBadRequest, errors.New("plate_id and item_id query params are required"))
+ return
+ }
+ spacing, err := parseSpacing(r.URL.Query().Get("spacing_mm"))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ preview, err := buildPreview(plates, items, plateID, itemID, spacing)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ preview.PlateID = plateID
+ preview.ItemID = itemID
+ writeJSON(w, http.StatusOK, preview)
+ }
+}
+
+func configurationResponseFor(c model.Configuration, plates *store.PlateStore, items *store.ItemStore) configurationResponse {
+ resp := configurationResponse{Configuration: c}
+ preview, err := buildPreview(plates, items, c.PlateID, c.ItemID, c.SpacingMM)
+ if err != nil {
+ resp.PreviewError = err.Error()
+ return resp
+ }
+ preview.PlateID = c.PlateID
+ preview.ItemID = c.ItemID
+ resp.Preview = &preview
+ return resp
+}
+
+func buildPreview(plates *store.PlateStore, items *store.ItemStore, plateID, itemID string, spacingMM float64) (model.LayoutPreview, error) {
+ plate, err := findPlate(plates, plateID)
+ if err != nil {
+ return model.LayoutPreview{}, err
+ }
+ item, err := items.Get(itemID)
+ if err != nil {
+ return model.LayoutPreview{}, err
+ }
+ return layout.Pack(plate, item.Spec, spacingMM), nil
+}
+
+func findPlate(plates *store.PlateStore, id string) (model.Plate, error) {
+ list, err := plates.List()
+ if err != nil {
+ return model.Plate{}, err
+ }
+ plate, err := storeFindPlate(list, id)
+ if err != nil {
+ return model.Plate{}, errors.New("plate not found: " + id)
+ }
+ return plate, nil
+}
+
+func storeFindPlate(list []model.Plate, id string) (model.Plate, error) {
+ for _, p := range list {
+ if p.ID == id {
+ return p, nil
+ }
+ }
+ return model.Plate{}, errors.New("not found")
+}
+
+func parseSpacing(s string) (float64, error) {
+ if s == "" {
+ return 0, nil
+ }
+ v, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return 0, errors.New("invalid spacing_mm")
+ }
+ if v < 0 {
+ return 0, errors.New("spacing_mm must be non-negative")
+ }
+ return v, nil
+}
diff --git a/internal/store/configuration.go b/internal/store/configuration.go
new file mode 100644
index 0000000..f809c89
--- /dev/null
+++ b/internal/store/configuration.go
@@ -0,0 +1,65 @@
+package store
+
+import (
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "github.com/google/uuid"
+ "printer.backend/internal/model"
+ "printer.backend/internal/paths"
+)
+
+// ConfigurationStore persists configurations as JSON files.
+type ConfigurationStore struct {
+ dir string
+}
+
+// NewConfigurationStore creates a store under dir (default data/configurations).
+func NewConfigurationStore(dir string) *ConfigurationStore {
+ if dir == "" {
+ dir = paths.ConfigurationsDir
+ }
+ return &ConfigurationStore{dir: dir}
+}
+
+// List returns all configurations sorted by creation time (newest first).
+func (s *ConfigurationStore) List() ([]model.Configuration, error) {
+ return listFromDir(s.dir,
+ func(name string) bool { return filepath.Ext(name) == ".json" },
+ "configurations dir",
+ func(c model.Configuration) time.Time { return c.CreatedAt },
+ )
+}
+
+// Get returns a configuration by ID.
+func (s *ConfigurationStore) Get(id string) (model.Configuration, error) {
+ list, err := s.List()
+ if err != nil {
+ return model.Configuration{}, err
+ }
+ return findByID(list, id, func(c model.Configuration) string { return c.ID })
+}
+
+// Save writes a new configuration and assigns an ID when empty.
+func (s *ConfigurationStore) Save(c model.Configuration) (model.Configuration, error) {
+ if err := ensureDir(s.dir); err != nil {
+ return model.Configuration{}, err
+ }
+ if c.ID == "" {
+ c.ID = uuid.NewString()
+ }
+ c.CreatedAt = stampNew(&c.CreatedAt)
+
+ path := filepath.Join(s.dir, c.ID+".json")
+ if err := writeJSON(path, c); err != nil {
+ return model.Configuration{}, fmt.Errorf("write configuration: %w", err)
+ }
+ return c, nil
+}
+
+// Delete removes a configuration by ID.
+func (s *ConfigurationStore) Delete(id string) error {
+ path := filepath.Join(s.dir, id+".json")
+ return removeFile(path, fmt.Sprintf("configuration not found: %s", id))
+}