diff --git a/internal/server/admin/static/index.html b/internal/server/admin/static/index.html index 07219d7..b2210d3 100644 --- a/internal/server/admin/static/index.html +++ b/internal/server/admin/static/index.html @@ -234,7 +234,7 @@
-

Platte anlegen

+

@@ -266,9 +266,12 @@
- +
+ + +

@@ -297,6 +300,7 @@ + @@ -306,13 +310,14 @@
-

Item anlegen

-

Erzeugt SVG-Maske in data/svg_template/.

+

+

Erzeugt SVG-Maske in data/svg_template/.

+

Aktualisiert SVG und Metadaten; Dateiname bleibt unverändert.

- +
@@ -331,16 +336,19 @@
- +
+ + +

-

Konfiguration — Layout-Vorschau

+

Kombiniert Platte und Item; berechnet maximale Stückzahl unter Einhaltung aller Margins und Abstände.

@@ -373,8 +381,9 @@
+
+
-
+
+
@@ -470,6 +481,7 @@ plateSaving: false, plateError: '', plateSuccess: '', + plateEditingId: null, plateForm: { name: '', width_mm: 300, @@ -485,6 +497,7 @@ itemSaving: false, itemError: '', itemSuccess: '', + itemEditingId: null, itemForm: { name: '', size_mm: 80, @@ -494,6 +507,7 @@ }, configurations: [], + configEditingId: null, configSaving: false, configError: '', configSuccess: '', @@ -576,21 +590,62 @@ } }, + defaultPlateForm() { + return { + name: '', + width_mm: 300, + height_mm: 400, + margin_top_mm: 10, + margin_right_mm: 10, + margin_bottom_mm: 10, + margin_left_mm: 10, + }; + }, + + editPlate(p) { + this.plateEditingId = p.id; + this.plateForm = { + name: p.name || '', + width_mm: p.width_mm, + height_mm: p.height_mm, + margin_top_mm: p.margin_top_mm, + margin_right_mm: p.margin_right_mm, + margin_bottom_mm: p.margin_bottom_mm, + margin_left_mm: p.margin_left_mm, + }; + this.plateError = ''; + this.plateSuccess = ''; + }, + + cancelPlateEdit() { + this.plateEditingId = null; + this.plateForm = this.defaultPlateForm(); + this.plateError = ''; + this.plateSuccess = ''; + }, + async savePlate() { this.plateSaving = true; this.plateError = ''; this.plateSuccess = ''; + const body = { + name: this.plateForm.name, + width_mm: Number(this.plateForm.width_mm), + height_mm: Number(this.plateForm.height_mm), + margin_top_mm: Number(this.plateForm.margin_top_mm) || 0, + margin_right_mm: Number(this.plateForm.margin_right_mm) || 0, + margin_bottom_mm: Number(this.plateForm.margin_bottom_mm) || 0, + margin_left_mm: Number(this.plateForm.margin_left_mm) || 0, + }; try { - await this.apiJSON('POST', '/plates', { - name: this.plateForm.name, - width_mm: Number(this.plateForm.width_mm), - height_mm: Number(this.plateForm.height_mm), - margin_top_mm: Number(this.plateForm.margin_top_mm) || 0, - margin_right_mm: Number(this.plateForm.margin_right_mm) || 0, - margin_bottom_mm: Number(this.plateForm.margin_bottom_mm) || 0, - margin_left_mm: Number(this.plateForm.margin_left_mm) || 0, - }); - this.plateSuccess = 'Platte gespeichert.'; + if (this.plateEditingId) { + await this.apiJSON('PUT', '/plates/' + this.plateEditingId, body); + this.plateSuccess = 'Platte aktualisiert.'; + } else { + await this.apiJSON('POST', '/plates', body); + this.plateSuccess = 'Platte gespeichert.'; + } + this.cancelPlateEdit(); await this.loadPlates(); this.refreshLayoutPreview(); } catch (e) { @@ -644,6 +699,7 @@ throw new Error(text || res.statusText); } await Promise.all([this.loadPlates(), this.loadConfigurations()]); + if (this.plateEditingId === id) this.cancelPlateEdit(); if (this.configForm.plate_id === id) { this.configForm.plate_id = ''; this.layoutPreview = null; @@ -664,20 +720,59 @@ } }, + defaultItemForm() { + return { + name: '', + size_mm: 80, + bleed_mm: 2, + margin_mm: 5, + padding_mm: 3, + }; + }, + + editItem(it) { + this.itemEditingId = it.id; + this.itemForm = { + name: it.name || '', + size_mm: it.spec.size_mm, + bleed_mm: it.spec.bleed_mm, + margin_mm: it.spec.margin_mm, + padding_mm: it.spec.padding_mm, + }; + this.itemError = ''; + this.itemSuccess = ''; + }, + + cancelItemEdit() { + this.itemEditingId = null; + this.itemForm = this.defaultItemForm(); + this.itemError = ''; + this.itemSuccess = ''; + }, + async saveItem() { this.itemSaving = true; this.itemError = ''; this.itemSuccess = ''; + const body = { + name: this.itemForm.name, + size_mm: Number(this.itemForm.size_mm), + bleed_mm: Number(this.itemForm.bleed_mm) || 0, + margin_mm: Number(this.itemForm.margin_mm) || 0, + padding_mm: Number(this.itemForm.padding_mm) || 0, + }; try { - await this.apiJSON('POST', '/items', { - name: this.itemForm.name, - size_mm: Number(this.itemForm.size_mm), - bleed_mm: Number(this.itemForm.bleed_mm) || 0, - margin_mm: Number(this.itemForm.margin_mm) || 0, - padding_mm: Number(this.itemForm.padding_mm) || 0, - }); - this.itemSuccess = 'Item erzeugt.'; - this.itemForm.name = ''; + if (this.itemEditingId) { + await this.apiJSON('PUT', '/items/' + this.itemEditingId, body); + this.itemSuccess = 'Item aktualisiert.'; + } else { + if (!body.name) { + throw new Error('Name ist erforderlich'); + } + await this.apiJSON('POST', '/items', body); + this.itemSuccess = 'Item erzeugt.'; + } + this.cancelItemEdit(); await this.loadItems(); this.refreshLayoutPreview(); } catch (e) { @@ -697,6 +792,7 @@ throw new Error(text || res.statusText); } await Promise.all([this.loadItems(), this.loadConfigurations()]); + if (this.itemEditingId === id) this.cancelItemEdit(); if (this.configForm.item_id === id) { this.configForm.item_id = ''; this.layoutPreview = null; @@ -714,18 +810,55 @@ } }, + defaultConfigForm() { + return { + name: '', + plate_id: '', + item_id: '', + spacing_mm: 2, + }; + }, + + editConfiguration(c) { + this.configEditingId = c.id; + this.configForm = { + name: c.name || '', + plate_id: c.plate_id, + item_id: c.item_id, + spacing_mm: c.spacing_mm, + }; + this.configError = ''; + this.configSuccess = ''; + this.refreshLayoutPreview(); + }, + + cancelConfigEdit() { + this.configEditingId = null; + this.configForm = this.defaultConfigForm(); + this.layoutPreview = null; + this.configError = ''; + this.configSuccess = ''; + }, + async saveConfiguration() { this.configSaving = true; this.configError = ''; this.configSuccess = ''; + const body = { + name: this.configForm.name, + plate_id: this.configForm.plate_id, + item_id: this.configForm.item_id, + spacing_mm: Number(this.configForm.spacing_mm) || 0, + }; 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.'; + if (this.configEditingId) { + await this.apiJSON('PUT', '/configurations/' + this.configEditingId, body); + this.configSuccess = 'Konfiguration aktualisiert.'; + } else { + await this.apiJSON('POST', '/configurations', body); + this.configSuccess = 'Konfiguration gespeichert.'; + } + this.cancelConfigEdit(); await this.loadConfigurations(); } catch (e) { this.configError = String(e.message || e); @@ -784,6 +917,7 @@ throw new Error(text || res.statusText); } await this.loadConfigurations(); + if (this.configEditingId === id) this.cancelConfigEdit(); } catch (e) { this.configError = String(e.message || e); } diff --git a/internal/server/api/api.go b/internal/server/api/api.go index 9882e27..d2c8bf5 100644 --- a/internal/server/api/api.go +++ b/internal/server/api/api.go @@ -22,9 +22,11 @@ func NewHandler() http.Handler { mux.HandleFunc("GET /", root) mux.HandleFunc("GET /plates", listPlates(plates)) mux.HandleFunc("POST /plates", savePlate(plates)) + mux.HandleFunc("PUT /plates/{id}", updatePlate(plates)) mux.HandleFunc("DELETE /plates/{id}", deletePlate(plates)) mux.HandleFunc("GET /items", listItems(items)) mux.HandleFunc("POST /items", saveItem(items)) + mux.HandleFunc("PUT /items/{id}", updateItem(items)) mux.HandleFunc("DELETE /items/{id}", deleteItem(items)) mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items)) registerConfigurationRoutes(mux, configs, plates, items) @@ -34,7 +36,7 @@ func NewHandler() http.Handler { func withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) @@ -111,6 +113,40 @@ func savePlate(s *store.PlateStore) http.HandlerFunc { } } +func updatePlate(s *store.PlateStore) 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 + } + var req savePlateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if req.WidthMM <= 0 || req.HeightMM <= 0 { + writeError(w, http.StatusBadRequest, errors.New("width_mm and height_mm must be positive")) + return + } + p := model.Plate{ + Name: req.Name, + WidthMM: req.WidthMM, + HeightMM: req.HeightMM, + MarginTop: req.MarginTop, + MarginRight: req.MarginRight, + MarginBottom: req.MarginBottom, + MarginLeft: req.MarginLeft, + } + saved, err := s.Update(id, p) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + writeJSON(w, http.StatusOK, saved) + } +} + func deletePlate(s *store.PlateStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") @@ -170,6 +206,32 @@ func saveItem(s *store.ItemStore) http.HandlerFunc { } } +func updateItem(s *store.ItemStore) 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 + } + var req saveItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + saved, err := s.Update(id, req.Name, model.ItemSpec{ + SizeMM: req.SizeMM, + BleedMM: req.BleedMM, + MarginMM: req.MarginMM, + PaddingMM: req.PaddingMM, + }) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, saved) + } +} + func deleteItem(s *store.ItemStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") diff --git a/internal/server/api/configuration.go b/internal/server/api/configuration.go index 5262501..11f4fcf 100644 --- a/internal/server/api/configuration.go +++ b/internal/server/api/configuration.go @@ -15,6 +15,7 @@ import ( 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("PUT /configurations/{id}", updateConfiguration(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)) @@ -99,6 +100,56 @@ func saveConfiguration(configs *store.ConfigurationStore, plates *store.PlateSto } } +func updateConfiguration(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) 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 + } + 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, + } + saved, err := configs.Update(id, c) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + + resp := configurationResponseFor(saved, plates, items) + if resp.PreviewError != "" { + writeError(w, http.StatusInternalServerError, errors.New(resp.PreviewError)) + return + } + writeJSON(w, http.StatusOK, resp) + } +} + func deleteConfiguration(configs *store.ConfigurationStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") diff --git a/internal/store/configuration.go b/internal/store/configuration.go index f809c89..9a4fa70 100644 --- a/internal/store/configuration.go +++ b/internal/store/configuration.go @@ -58,6 +58,21 @@ func (s *ConfigurationStore) Save(c model.Configuration) (model.Configuration, e return c, nil } +// Update replaces an existing configuration by ID (preserves created_at). +func (s *ConfigurationStore) Update(id string, c model.Configuration) (model.Configuration, error) { + path := filepath.Join(s.dir, id+".json") + existing, err := readJSON[model.Configuration](path) + if err != nil { + return model.Configuration{}, fmt.Errorf("configuration not found: %s", id) + } + c.ID = existing.ID + c.CreatedAt = existing.CreatedAt + 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") diff --git a/internal/store/item.go b/internal/store/item.go index 9ee6508..f9d4e5f 100644 --- a/internal/store/item.go +++ b/internal/store/item.go @@ -65,6 +65,30 @@ func (s *ItemStore) Create(name string, spec model.ItemSpec) (model.Item, error) return svgtemplate.WriteMeta(basename, spec, displayName) } +// Update regenerates the SVG and metadata for an existing item (preserves id, svg filename, created_at). +func (s *ItemStore) Update(id string, name string, spec model.ItemSpec) (model.Item, error) { + if spec.SizeMM <= 0 { + return model.Item{}, fmt.Errorf("size_mm must be positive") + } + item, err := s.Get(id) + if err != nil { + return model.Item{}, err + } + data := svgtemplate.Build(spec.SizeMM, spec.BleedMM, spec.MarginMM, spec.PaddingMM) + if err := svgtemplate.WriteFile(item.SVGTemplate, data); err != nil { + return model.Item{}, fmt.Errorf("write svg: %w", err) + } + if name != "" { + item.Name = name + } + item.Spec = spec + metaPath := filepath.Join(s.dir, model.MetaFilename(item.SVGTemplate)) + if err := writeJSON(metaPath, item); err != nil { + return model.Item{}, fmt.Errorf("write meta: %w", err) + } + return item, nil +} + // Delete removes an item's SVG and metadata by ID. func (s *ItemStore) Delete(id string) error { item, err := s.Get(id) diff --git a/internal/store/plate.go b/internal/store/plate.go index 21e46d3..eb61fc5 100644 --- a/internal/store/plate.go +++ b/internal/store/plate.go @@ -49,6 +49,21 @@ func (s *PlateStore) Save(p model.Plate) (model.Plate, error) { return p, nil } +// Update replaces an existing plate by ID (preserves created_at). +func (s *PlateStore) Update(id string, p model.Plate) (model.Plate, error) { + path := filepath.Join(s.dir, id+".json") + existing, err := readJSON[model.Plate](path) + if err != nil { + return model.Plate{}, fmt.Errorf("plate not found: %s", id) + } + p.ID = existing.ID + p.CreatedAt = existing.CreatedAt + if err := writeJSON(path, p); err != nil { + return model.Plate{}, fmt.Errorf("write plate: %w", err) + } + return p, nil +} + // Delete removes a plate by ID. func (s *PlateStore) Delete(id string) error { path := filepath.Join(s.dir, id+".json")