Added Configuration from Item and Plate

This commit is contained in:
2026-05-26 16:26:31 +02:00
parent 50e677c729
commit c5d2e32355
8 changed files with 766 additions and 8 deletions
+316 -5
View File
@@ -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;
}
</style>
</head>
<body>
@@ -275,6 +332,88 @@
</form>
</section>
<section class="card wide">
<h2>Konfiguration — Layout-Vorschau</h2>
<p class="muted">Kombiniert Platte und Item; berechnet maximale Stückzahl unter Einhaltung aller Margins und Abstände.</p>
<form @submit.prevent="saveConfiguration()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="configForm.name" placeholder="z.B. A3 × Sticker 80">
</div>
<div>
<label>Platte</label>
<select x-model="configForm.plate_id" @change="refreshLayoutPreview()" required>
<option value="">— wählen —</option>
<template x-for="p in plates" :key="p.id">
<option :value="p.id" x-text="labelPlate(p)"></option>
</template>
</select>
</div>
<div>
<label>Item</label>
<select x-model="configForm.item_id" @change="refreshLayoutPreview()" required>
<option value="">— wählen —</option>
<template x-for="it in items" :key="it.id">
<option :value="it.id" x-text="labelItem(it)"></option>
</template>
</select>
</div>
<div>
<label>Abstand zwischen Items (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="configForm.spacing_mm" @input="refreshLayoutPreview()">
</div>
</div>
<button type="submit" :disabled="configSaving || !configForm.plate_id || !configForm.item_id">
<span x-text="configSaving ? 'Speichere…' : 'Konfiguration speichern'"></span>
</button>
<p class="msg-err" x-show="configError" x-text="configError"></p>
<p class="msg-ok" x-show="configSuccess" x-text="configSuccess"></p>
</form>
<template x-if="layoutPreview">
<div class="layout-preview-wrap">
<div x-html="layoutPreviewSvg()"></div>
<div class="layout-stats">
<span><strong x-text="layoutPreview.count"></strong> Items passen</span>
<span><strong x-text="layoutPreview.columns + ' × ' + layoutPreview.rows"></strong> Raster</span>
<span>Fußabdruck <strong x-text="layoutPreview.footprint_width_mm.toFixed(1) + ' mm'"></strong></span>
<span>Zellenabstand <strong x-text="layoutPreview.cell_width_mm.toFixed(1) + ' mm'"></strong></span>
<span>Druckfläche <strong x-text="layoutPreview.printable_width_mm.toFixed(1) + ' × ' + layoutPreview.printable_height_mm.toFixed(1) + ' mm'"></strong></span>
</div>
</div>
</template>
<p class="empty" x-show="!layoutPreview && configForm.plate_id && configForm.item_id && !layoutLoading">
Keine Vorschau (Platte zu klein oder API-Fehler).
</p>
<p class="muted" x-show="layoutLoading">Berechne Layout…</p>
</section>
<section class="card wide" x-show="configurations.length > 0">
<h2>Gespeicherte Konfigurationen</h2>
<div class="config-list">
<template x-for="c in configurations" :key="c.id">
<article class="config-entry">
<header>
<div>
<h3 x-text="c.name || ('Konfiguration ' + c.id.slice(0, 8))"></h3>
<p class="muted" x-text="configSummary(c)"></p>
</div>
<button type="button" class="danger" @click="deleteConfiguration(c.id)">Löschen</button>
</header>
<p class="msg-err" x-show="c.preview_error" x-text="c.preview_error"></p>
<div class="layout-preview-wrap" x-show="c.preview && !c.preview_error">
<div x-html="layoutPreviewSvg(c.preview)"></div>
<div class="layout-stats">
<span><strong x-text="c.preview.count"></strong> Items</span>
<span><strong x-text="c.preview.columns + ' × ' + c.preview.rows"></strong></span>
</div>
</div>
</article>
</template>
</div>
</section>
<section class="card wide">
<h2>Items</h2>
<template x-if="items.length === 0 && !itemsLoading">
@@ -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, '&amp;').replace(/</g, '&lt;');
let items = '';
const fpW = p.footprint_width_mm;
const fpH = p.footprint_height_mm;
for (const pos of p.positions || []) {
items += `<rect x="${pos.x_mm * scale}" y="${pos.y_mm * scale}" width="${fpW * scale}" height="${fpH * scale}" rx="3" fill="#22c55e" fill-opacity="0.35" stroke="#22c55e" stroke-width="1"/>`;
}
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 `<svg xmlns="http://www.w3.org/2000/svg" width="${pw * scale}" height="${ph * scale}" viewBox="0 0 ${pw * scale} ${ph * scale}" role="img" aria-label="Layout-Vorschau">
<rect width="100%" height="100%" fill="#2d3a4d"/>
<rect x="${px}" y="${py}" width="${prW}" height="${prH}" fill="#1a2332" stroke="#8b9cb3" stroke-width="1" stroke-dasharray="4,3"/>
${items}
<text x="8" y="16" fill="#e7ecf3" font-size="11" font-family="system-ui,sans-serif">${esc(p.count)}× · ${esc(p.columns)}×${esc(p.rows)}</text>
</svg>`;
},
fmtSize(w, h) {
return `${w} × ${h} mm`;
},