Added Configuration from Item and Plate
This commit is contained in:
@@ -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, '&').replace(/</g, '<');
|
||||
|
||||
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`;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user