1016 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Printer Backend — Admin</title>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
<style>
:root {
--bg: #0f1419;
--surface: #1a2332;
--border: #2d3a4d;
--text: #e7ecf3;
--muted: #8b9cb3;
--accent: #3b82f6;
--ok: #22c55e;
--err: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
.page {
max-width: 1000px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 1.5rem; font-weight: 600; }
header p { color: var(--muted); margin-top: 0.25rem; font-size: 0.9rem; }
.grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
}
.card.wide { grid-column: 1 / -1; }
.card h2 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 0.75rem;
}
.muted { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; }
button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
button:hover { filter: brightness(1.1); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.secondary {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
}
button.secondary:hover { background: rgba(59, 130, 246, 0.12); filter: none; }
.btn-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
button.danger {
background: transparent;
border: 1px solid var(--err);
color: var(--err);
margin-top: 0;
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
}
button.danger:hover { background: rgba(239, 68, 68, 0.12); filter: none; }
input {
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;
}
label { font-size: 0.8rem; color: var(--muted); display: block; margin-top: 0.75rem; }
label:first-child { margin-top: 0; }
.form-grid {
display: grid;
gap: 0.5rem 1rem;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-top: 0.75rem;
}
th, td {
text-align: left;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
th { color: var(--muted); font-weight: 500; font-size: 0.75rem; }
.empty { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; }
.msg-err { color: var(--err); font-size: 0.85rem; margin-top: 0.5rem; }
.msg-ok { color: var(--ok); font-size: 0.85rem; margin-top: 0.5rem; }
.item-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
margin-top: 1rem;
}
.item-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.item-preview {
background: #fff;
padding: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
}
.item-preview img {
max-width: 100%;
max-height: 140px;
width: auto;
height: auto;
}
.item-body {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.item-body strong { font-size: 0.9rem; }
.item-body span { font-size: 0.8rem; color: var(--muted); }
.item-actions {
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>
<div class="page" x-data="dashboard()" x-init="init()">
<header>
<h1>Printer Backend</h1>
<p>Platten und Items verwalten</p>
</header>
<div class="grid">
<section class="card">
<h2>API</h2>
<label for="api-base">Basis-URL</label>
<input id="api-base" type="url" x-model="apiBase" @change="onApiBaseChange()" placeholder="http://127.0.0.1:8080">
<p class="muted" x-show="apiOk === true">API erreichbar</p>
<p class="msg-err" x-show="apiOk === false">API nicht erreichbar</p>
</section>
<section class="card wide">
<h2 x-text="plateEditingId ? 'Platte bearbeiten' : 'Platte anlegen'"></h2>
<form @submit.prevent="savePlate()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="plateForm.name" placeholder="z.B. Druckbett A3">
</div>
<div>
<label>Breite (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.width_mm" required>
</div>
<div>
<label>Höhe (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.height_mm" required>
</div>
<div>
<label>Margin oben</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.margin_top_mm">
</div>
<div>
<label>Margin rechts</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.margin_right_mm">
</div>
<div>
<label>Margin unten</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.margin_bottom_mm">
</div>
<div>
<label>Margin links</label>
<input type="number" step="0.1" min="0" x-model.number="plateForm.margin_left_mm">
</div>
</div>
<div class="btn-row">
<button type="submit" :disabled="plateSaving">
<span x-text="plateSaving ? 'Speichere…' : (plateEditingId ? 'Änderungen speichern' : 'Platte anlegen')"></span>
</button>
<button type="button" class="secondary" x-show="plateEditingId" @click="cancelPlateEdit()">Abbrechen</button>
</div>
<p class="msg-err" x-show="plateError" x-text="plateError"></p>
<p class="msg-ok" x-show="plateSuccess" x-text="plateSuccess"></p>
</form>
</section>
<section class="card wide">
<h2>Platten</h2>
<template x-if="plates.length === 0 && !platesLoading">
<p class="empty">Keine Platten.</p>
</template>
<table x-show="plates.length > 0">
<thead>
<tr>
<th>Name</th>
<th>Größe</th>
<th>Margins</th>
<th>Druckfläche</th>
<th></th>
</tr>
</thead>
<tbody>
<template x-for="p in plates" :key="p.id">
<tr>
<td x-text="p.name || '—'"></td>
<td x-text="fmtSize(p.width_mm, p.height_mm)"></td>
<td x-text="fmtMargins(p)"></td>
<td x-text="fmtPrintable(p)"></td>
<td class="row-actions">
<button type="button" class="secondary" @click="editPlate(p)">Bearbeiten</button>
<button type="button" class="danger" @click="deletePlate(p.id)">Löschen</button>
</td>
</tr>
</template>
</tbody>
</table>
</section>
<section class="card wide">
<h2 x-text="itemEditingId ? 'Item bearbeiten' : 'Item anlegen'"></h2>
<p class="muted" x-show="!itemEditingId">Erzeugt SVG-Maske in <code>data/svg_template/</code>.</p>
<p class="muted" x-show="itemEditingId">Aktualisiert SVG und Metadaten; Dateiname bleibt unverändert.</p>
<form @submit.prevent="saveItem()">
<div class="form-grid">
<div>
<label>Name</label>
<input type="text" x-model="itemForm.name" placeholder="z.B. sticker_80" :required="!itemEditingId">
</div>
<div>
<label>Größe (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.size_mm" required>
</div>
<div>
<label>Bleed (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.bleed_mm">
</div>
<div>
<label>Margin (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.margin_mm">
</div>
<div>
<label>Padding (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.padding_mm">
</div>
</div>
<div class="btn-row">
<button type="submit" :disabled="itemSaving">
<span x-text="itemSaving ? 'Speichere…' : (itemEditingId ? 'Änderungen speichern' : 'Item anlegen')"></span>
</button>
<button type="button" class="secondary" x-show="itemEditingId" @click="cancelItemEdit()">Abbrechen</button>
</div>
<p class="msg-err" x-show="itemError" x-text="itemError"></p>
<p class="msg-ok" x-show="itemSuccess" x-text="itemSuccess"></p>
</form>
</section>
<section class="card wide">
<h2 x-text="configEditingId ? 'Konfiguration bearbeiten' : '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>
<div class="btn-row">
<button type="submit" :disabled="configSaving || !configForm.plate_id || !configForm.item_id">
<span x-text="configSaving ? 'Speichere…' : (configEditingId ? 'Änderungen speichern' : 'Konfiguration speichern')"></span>
</button>
<button type="button" class="secondary" x-show="configEditingId" @click="cancelConfigEdit()">Abbrechen</button>
<button type="button" class="secondary" @click="downloadLayoutPDF()"
:disabled="pdfGenerating || !layoutPreview || !configForm.plate_id || !configForm.item_id">
<span x-text="pdfGenerating ? 'PDF…' : 'PDF-Vorschau'"></span>
</button>
</div>
<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>Item <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>
<div class="row-actions">
<button type="button" class="secondary" @click="editConfiguration(c)">Bearbeiten</button>
<button type="button" class="secondary" @click="downloadConfigurationPDF(c.id)"
:disabled="pdfGenerating || !!c.preview_error" x-show="c.preview && !c.preview_error">
PDF
</button>
<button type="button" class="danger" @click="deleteConfiguration(c.id)">Löschen</button>
</div>
</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">
<p class="empty">Keine Items.</p>
</template>
<div class="item-grid" x-show="items.length > 0">
<template x-for="it in items" :key="it.id">
<article class="item-card">
<div class="item-preview">
<img :src="itemSvgUrl(it.id)" :alt="labelItem(it)" loading="lazy">
</div>
<div class="item-body">
<strong x-text="labelItem(it)"></strong>
<span x-text="it.spec.size_mm + ' mm · bleed ' + it.spec.bleed_mm + ' · margin ' + it.spec.margin_mm"></span>
<span><code x-text="it.svg_template"></code></span>
</div>
<div class="item-actions row-actions">
<button type="button" class="secondary" @click="editItem(it)">Bearbeiten</button>
<button type="button" class="danger" @click="deleteItem(it.id)">Löschen</button>
</div>
</article>
</template>
</div>
</section>
</div>
</div>
<script>
function dashboard() {
return {
apiBase: '',
apiOk: null,
plates: [],
platesLoading: false,
plateSaving: false,
plateError: '',
plateSuccess: '',
plateEditingId: null,
plateForm: {
name: '',
width_mm: 300,
height_mm: 400,
margin_top_mm: 10,
margin_right_mm: 10,
margin_bottom_mm: 10,
margin_left_mm: 10,
},
items: [],
itemsLoading: false,
itemSaving: false,
itemError: '',
itemSuccess: '',
itemEditingId: null,
itemForm: {
name: '',
size_mm: 80,
bleed_mm: 2,
margin_mm: 5,
padding_mm: 3,
},
configurations: [],
configEditingId: null,
configSaving: false,
configError: '',
configSuccess: '',
configForm: {
name: '',
plate_id: '',
item_id: '',
spacing_mm: 2,
},
layoutPreview: null,
layoutLoading: false,
pdfGenerating: false,
_layoutTimer: null,
apiUrl(path) {
return `${this.apiBase.replace(/\/$/, '')}${path}`;
},
itemSvgUrl(id) {
return `${this.apiUrl('/items/' + id + '/svg')}?t=${Date.now()}`;
},
async init() {
try {
const res = await fetch('/config.json');
if (res.ok) {
const cfg = await res.json();
this.apiBase = cfg.api_base || this.apiBase;
}
} catch (_) {}
if (!this.apiBase) this.apiBase = 'http://127.0.0.1:8080';
await this.onApiBaseChange();
},
async onApiBaseChange() {
await this.checkAPI();
if (this.apiOk) {
await Promise.all([this.loadPlates(), this.loadItems(), this.loadConfigurations()]);
}
},
async checkAPI() {
try {
const res = await fetch(this.apiUrl('/health'));
this.apiOk = res.ok;
} catch {
this.apiOk = false;
}
},
async apiJSON(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(this.apiUrl(path), opts);
const text = await res.text();
if (!res.ok) {
let msg = res.statusText;
try {
const err = JSON.parse(text);
if (err.error) msg = err.error;
} catch {
if (text) msg = text;
}
throw new Error(msg);
}
return text ? JSON.parse(text) : null;
},
async loadPlates() {
this.platesLoading = true;
try {
this.plates = await this.apiJSON('GET', '/plates') || [];
} catch (e) {
this.plateError = String(e.message || e);
} finally {
this.platesLoading = false;
}
},
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 {
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) {
this.plateError = String(e.message || e);
} finally {
this.plateSaving = false;
}
},
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 (!this.confirmDeletePlate(id)) return;
this.plateError = '';
try {
const res = await fetch(this.apiUrl('/plates/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
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;
}
} catch (e) {
this.plateError = String(e.message || e);
}
},
async loadItems() {
this.itemsLoading = true;
try {
this.items = await this.apiJSON('GET', '/items') || [];
} catch (e) {
this.itemError = String(e.message || e);
} finally {
this.itemsLoading = false;
}
},
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 {
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) {
this.itemError = String(e.message || e);
} finally {
this.itemSaving = false;
}
},
async deleteItem(id) {
if (!this.confirmDeleteItem(id)) return;
this.itemError = '';
try {
const res = await fetch(this.apiUrl('/items/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
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;
}
} 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);
}
},
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 {
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);
} finally {
this.configSaving = false;
}
},
async downloadBlob(path, filename) {
this.pdfGenerating = true;
this.configError = '';
try {
const res = await fetch(this.apiUrl(path));
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch (_) {}
throw new Error(msg);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
this.configError = String(e.message || e);
} finally {
this.pdfGenerating = false;
}
},
downloadLayoutPDF() {
const q = new URLSearchParams({
plate_id: this.configForm.plate_id,
item_id: this.configForm.item_id,
spacing_mm: String(Number(this.configForm.spacing_mm) || 0),
});
this.downloadBlob('/layout/pdf?' + q, 'layout-preview.pdf');
},
downloadConfigurationPDF(id) {
this.downloadBlob('/configurations/' + id + '/pdf', 'configuration-' + id.slice(0, 8) + '.pdf');
},
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();
if (this.configEditingId === id) this.cancelConfigEdit();
} 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`;
},
fmtMargins(p) {
return `${p.margin_top_mm} / ${p.margin_right_mm} / ${p.margin_bottom_mm} / ${p.margin_left_mm}`;
},
fmtPrintable(p) {
const w = p.width_mm - p.margin_left_mm - p.margin_right_mm;
const h = p.height_mm - p.margin_top_mm - p.margin_bottom_mm;
return `${w.toFixed(1)} × ${h.toFixed(1)} mm`;
},
};
}
</script>
</body>
</html>