Added Meta Data to Orders and made the frontend english

This commit is contained in:
simon 2026-05-26 17:53:30 +02:00
parent 8bc4691992
commit 929969d03a
4 changed files with 156 additions and 41 deletions

View File

@ -15,6 +15,9 @@ type OrderImage struct {
type Order struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Printed bool `json:"printed"`
Shipped bool `json:"shipped"`
Ref string `json:"ref,omitempty"`
Images []OrderImage `json:"images"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`

View File

@ -331,6 +331,33 @@
padding: 0.5rem;
font-size: 0.82rem;
}
.form-check-row {
display: flex;
flex-wrap: wrap;
gap: 1rem 1.5rem;
margin-top: 0.75rem;
}
.form-check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text);
cursor: pointer;
margin-top: 0;
}
.form-check input[type="checkbox"] {
width: auto;
margin: 0;
accent-color: var(--accent);
}
.ref-code {
margin-top: 0.35rem;
font-size: 0.82rem;
font-family: ui-monospace, monospace;
color: var(--muted);
word-break: break-all;
}
@media (max-width: 720px) {
.app { flex-direction: column; }
.sidebar {
@ -430,7 +457,7 @@
</button>
<div class="nav-children" x-show="expanded.orders">
<template x-if="orders.length === 0 && !ordersLoading">
<p class="nav-empty">Keine Orders</p>
<p class="nav-empty">No orders</p>
</template>
<template x-for="o in orders" :key="o.id">
<button type="button" class="nav-item"
@ -440,7 +467,7 @@
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'orders' && !orderEditingId }"
@click="newOrder()">+ Neu</button>
@click="newOrder()">+ New</button>
</div>
</div>
</nav>
@ -630,57 +657,72 @@
<!-- Orders -->
<div x-show="section === 'orders'">
<div class="main-header">
<h2 x-text="orderEditingId ? 'Order bearbeiten' : 'Neue Order'"></h2>
<p class="muted" x-show="!orderEditingId">Order anlegen, danach Bilder hochladen.</p>
<p class="muted" x-show="orderEditingId">Beliebig viele Bilder pro Order (PNG, JPEG, WebP, GIF).</p>
<h2 x-text="orderEditingId ? 'Edit order' : 'New order'"></h2>
<p class="muted" x-show="!orderEditingId">Create an order, then upload images.</p>
<p class="muted" x-show="orderEditingId">Any number of images per order (PNG, JPEG, WebP, GIF).</p>
</div>
<div class="panel">
<form @submit.prevent="saveOrder()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="orderForm.name" placeholder="z.B. Kunde Müller">
<input type="text" x-model="orderForm.name" placeholder="e.g. Customer Smith">
</div>
<div>
<label>Reference code</label>
<input type="text" x-model="orderForm.ref" placeholder="e.g. SHOP-10482" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="form-check-row">
<label class="form-check">
<input type="checkbox" x-model="orderForm.printed">
Printed
</label>
<label class="form-check">
<input type="checkbox" x-model="orderForm.shipped">
Shipped
</label>
</div>
<p class="ref-code" x-show="orderForm.ref" x-text="'Ref: ' + orderForm.ref"></p>
<div class="btn-row">
<button type="submit" :disabled="orderSaving">
<span x-text="orderSaving ? 'Speichere…' : (orderEditingId ? 'Speichern' : 'Anlegen')"></span>
<span x-text="orderSaving ? 'Saving…' : (orderEditingId ? 'Save' : 'Create')"></span>
</button>
<button type="button" class="danger" x-show="orderEditingId" @click="deleteOrder(orderEditingId)">Löschen</button>
<button type="button" class="danger" x-show="orderEditingId" @click="deleteOrder(orderEditingId)">Delete</button>
</div>
<p class="msg-err" x-show="orderError" x-text="orderError"></p>
<p class="msg-ok" x-show="orderSuccess" x-text="orderSuccess"></p>
</form>
<div x-show="orderEditingId" style="margin-top: 1.5rem;">
<label>Bilder hochladen</label>
<label>Upload images</label>
<input type="file" class="file-input" accept="image/*" multiple
:disabled="orderImageUploading || orderLoading"
@change="uploadOrderImages($event)">
<p class="muted" x-show="orderImageUploading">Lade hoch</p>
<p class="muted" x-show="orderLoading">Lade Bilder</p>
<p class="muted" x-show="orderImageUploading">Uploading</p>
<p class="muted" x-show="orderLoading">Loading images</p>
<div class="order-images" x-show="orderImages.length > 0">
<template x-for="img in orderImages" :key="img.id">
<div class="order-image-card">
<button type="button" class="order-image-thumb"
@click="openOrderImage(img)"
:title="(img.original_name || img.filename) + ' — Klicken für Vollbild'">
:title="(img.original_name || img.filename) + ' — click for full size'">
<img :src="orderImageUrl(orderEditingId, img.id)"
:alt="img.original_name || 'Bild'"
:alt="img.original_name || 'Image'"
loading="lazy"
@error="onOrderImageError($event, img)">
</button>
<p class="order-image-meta" x-text="img.original_name || img.filename"></p>
<div class="order-image-actions">
<button type="button" class="secondary" @click="openOrderImage(img)">Vergrößern</button>
<button type="button" class="danger" @click="deleteOrderImage(img.id)">Entfernen</button>
<button type="button" class="secondary" @click="openOrderImage(img)">Enlarge</button>
<button type="button" class="danger" @click="deleteOrderImage(img.id)">Remove</button>
</div>
</div>
</template>
</div>
<p class="muted" x-show="orderImages.length === 0 && !orderImageUploading && !orderLoading" style="margin-top: 1rem;">
Noch keine Bilder — Dateien oben auswählen.
No images yet — choose files above.
</p>
</div>
</div>
@ -694,7 +736,7 @@
<div @click.stop style="display: contents;">
<img :src="orderImageUrl(orderLightbox.orderId, orderLightbox.imageId)" :alt="orderLightbox.label">
<p class="order-lightbox-caption" x-text="orderLightbox.label"></p>
<p class="order-lightbox-hint">Klicken oder Esc zum Schließen</p>
<p class="order-lightbox-hint">Click or press Esc to close</p>
</div>
</template>
</div>
@ -765,7 +807,7 @@
orderSuccess: '',
orderEditingId: null,
orderImages: [],
orderForm: { name: '' },
orderForm: { name: '', printed: false, shipped: false, ref: '' },
orderLoading: false,
orderImageVersion: 0,
orderLightbox: null,
@ -837,9 +879,23 @@
},
sidebarOrderLabel(o) {
const n = o.name || ('Order ' + o.id.slice(0, 8));
const n = o.name || o.ref || ('Order ' + o.id.slice(0, 8));
const parts = [];
if (o.name && o.ref) parts.push(o.ref);
if (o.printed) parts.push('printed');
if (o.shipped) parts.push('shipped');
const count = (o.images || []).length;
return n + (count ? ' · ' + count + ' Bild' + (count === 1 ? '' : 'er') : '');
if (count) parts.push(count + ' img' + (count === 1 ? '' : 's'));
return parts.length ? n + ' · ' + parts.join(' · ') : n;
},
orderFormFrom(o) {
return {
name: o.name || '',
printed: !!o.printed,
shipped: !!o.shipped,
ref: o.ref || o.ref_link || '',
};
},
apiUrl(path) {
@ -859,7 +915,7 @@
this.orderLightbox = {
orderId: this.orderEditingId,
imageId: img.id,
label: img.original_name || img.filename || 'Bild',
label: img.original_name || img.filename || 'Image',
};
},
@ -1326,14 +1382,14 @@
async editOrder(o) {
this.orderEditingId = o.id;
this.orderForm = { name: o.name || '' };
this.orderForm = this.orderFormFrom(o);
this.orderImages = o.images ? [...o.images] : [];
this.orderError = '';
this.orderSuccess = '';
this.orderLoading = true;
try {
const fresh = await this.apiJSON('GET', '/orders/' + o.id);
this.orderForm = { name: fresh.name || '' };
this.orderForm = this.orderFormFrom(fresh);
this.orderImages = fresh.images ? [...fresh.images] : [];
this.orderImageVersion++;
} catch (e) {
@ -1345,7 +1401,7 @@
cancelOrderEdit() {
this.orderEditingId = null;
this.orderForm = { name: '' };
this.orderForm = { name: '', printed: false, shipped: false, ref: '' };
this.orderImages = [];
this.orderLightbox = null;
this.orderError = '';
@ -1356,15 +1412,20 @@
this.orderSaving = true;
this.orderError = '';
this.orderSuccess = '';
const body = { name: this.orderForm.name };
const body = {
name: this.orderForm.name,
printed: !!this.orderForm.printed,
shipped: !!this.orderForm.shipped,
ref: (this.orderForm.ref || '').trim(),
};
try {
let saved;
if (this.orderEditingId) {
saved = await this.apiJSON('PUT', '/orders/' + this.orderEditingId, body);
this.orderSuccess = 'Order aktualisiert.';
this.orderSuccess = 'Order updated.';
} else {
saved = await this.apiJSON('POST', '/orders', body);
this.orderSuccess = 'Order angelegt.';
this.orderSuccess = 'Order created.';
}
const keepId = saved?.id || this.orderEditingId;
await this.loadOrders();
@ -1419,7 +1480,7 @@
if (o) await this.editOrder(o);
}
const n = (result?.added || []).length;
this.orderSuccess = n === 1 ? '1 Bild hochgeladen.' : n + ' Bilder hochgeladen.';
this.orderSuccess = n === 1 ? '1 image uploaded.' : n + ' images uploaded.';
} catch (e) {
this.orderError = String(e.message || e);
} finally {
@ -1430,7 +1491,7 @@
async deleteOrderImage(imageId) {
if (!this.orderEditingId) return;
if (!confirm('Bild wirklich entfernen?')) return;
if (!confirm('Remove this image?')) return;
this.orderError = '';
try {
const res = await fetch(
@ -1445,14 +1506,14 @@
await this.loadOrders();
const o = this.orders.find(x => x.id === this.orderEditingId);
if (o) await this.editOrder(o);
this.orderSuccess = 'Bild entfernt.';
this.orderSuccess = 'Image removed.';
} catch (e) {
this.orderError = String(e.message || e);
}
},
async deleteOrder(id) {
if (!confirm('Order inkl. aller Bilder wirklich löschen?')) return;
if (!confirm('Delete this order and all images?')) return;
this.orderError = '';
try {
const res = await fetch(this.apiUrl('/orders/' + id), { method: 'DELETE' });

View File

@ -46,6 +46,18 @@ func listOrders(s *store.OrderStore) http.HandlerFunc {
type saveOrderRequest struct {
Name string `json:"name"`
Printed bool `json:"printed"`
Shipped bool `json:"shipped"`
Ref string `json:"ref"`
}
func orderFromRequest(req saveOrderRequest) model.Order {
return model.Order{
Name: req.Name,
Printed: req.Printed,
Shipped: req.Shipped,
Ref: req.Ref,
}
}
func saveOrder(s *store.OrderStore) http.HandlerFunc {
@ -55,7 +67,7 @@ func saveOrder(s *store.OrderStore) http.HandlerFunc {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Save(model.Order{Name: req.Name})
saved, err := s.Save(orderFromRequest(req))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
@ -84,7 +96,7 @@ func updateOrder(s *store.OrderStore) http.HandlerFunc {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Update(id, model.Order{Name: req.Name})
saved, err := s.Update(id, orderFromRequest(req))
if err != nil {
writeError(w, http.StatusNotFound, err)
return

View File

@ -1,10 +1,12 @@
package store
import (
"encoding/json"
"fmt"
"mime"
"os"
"path/filepath"
"sort"
"strings"
"time"
@ -28,23 +30,60 @@ func NewOrderStore(dir string) *OrderStore {
// List returns all orders sorted by creation time (newest first).
func (s *OrderStore) List() ([]model.Order, error) {
return listFromDir(s.dir,
func(name string) bool { return filepath.Ext(name) == ".json" },
"orders dir",
func(o model.Order) time.Time { return o.CreatedAt },
)
if err := ensureDir(s.dir); err != nil {
return nil, err
}
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, fmt.Errorf("read orders dir: %w", err)
}
var out []model.Order
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
o, err := readOrderFile(filepath.Join(s.dir, e.Name()))
if err != nil {
return nil, err
}
out = append(out, o)
}
sort.Slice(out, func(i, j int) bool {
return out[i].CreatedAt.After(out[j].CreatedAt)
})
return out, nil
}
// Get returns an order by ID.
func (s *OrderStore) Get(id string) (model.Order, error) {
path := s.metaPath(id)
o, err := readJSON[model.Order](path)
o, err := readOrderFile(s.metaPath(id))
if err != nil {
if os.IsNotExist(err) {
return model.Order{}, fmt.Errorf("order not found: %s", id)
}
return model.Order{}, err
}
return o, nil
}
func readOrderFile(path string) (model.Order, error) {
var aux struct {
model.Order
RefLink string `json:"ref_link,omitempty"`
}
data, err := os.ReadFile(path)
if err != nil {
return model.Order{}, err
}
if err := json.Unmarshal(data, &aux); err != nil {
return model.Order{}, err
}
o := aux.Order
if o.Ref == "" && aux.RefLink != "" {
o.Ref = aux.RefLink
}
if o.Images == nil {
o.Images = []model.OrderImage{}
}