2026-05-26 18:27:37 +02:00

1979 lines
71 KiB
HTML
Raw Permalink 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;
--sidebar-w: 260px;
}
* { 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;
}
.app {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}
.sidebar-brand {
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar-brand h1 { font-size: 1.1rem; font-weight: 600; }
.sidebar-brand p { color: var(--muted); font-size: 0.8rem; margin-top: 0.2rem; }
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.nav-section { margin-bottom: 0.25rem; }
.nav-section-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.55rem 1rem;
background: none;
border: none;
color: var(--text);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.nav-section-header:hover { background: rgba(255,255,255,0.04); }
.nav-section-header.active { color: var(--accent); }
.nav-chevron {
width: 0.65rem;
height: 0.65rem;
border-right: 2px solid var(--muted);
border-bottom: 2px solid var(--muted);
transform: rotate(-45deg);
transition: transform 0.15s;
flex-shrink: 0;
margin-left: auto;
}
.nav-chevron.open { transform: rotate(45deg); margin-top: -0.2rem; }
.nav-children {
padding: 0.15rem 0 0.35rem;
}
.nav-item {
display: block;
width: 100%;
padding: 0.4rem 1rem 0.4rem 1.75rem;
background: none;
border: none;
color: var(--muted);
font-size: 0.82rem;
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-item:hover { color: var(--text); background: rgba(255,255,255,0.04); }
.nav-item.selected {
color: var(--text);
background: rgba(59, 130, 246, 0.15);
border-right: 2px solid var(--accent);
}
.nav-item.add {
color: var(--accent);
font-weight: 500;
}
.nav-item.add:hover { background: rgba(59, 130, 246, 0.1); }
.nav-empty {
padding: 0.25rem 1rem 0.25rem 1.75rem;
font-size: 0.78rem;
color: var(--muted);
font-style: italic;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid var(--border);
}
.sidebar-footer label { font-size: 0.75rem; color: var(--muted); display: block; margin-bottom: 0.35rem; }
.sidebar-footer input {
width: 100%;
padding: 0.4rem 0.6rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.78rem;
}
.api-status { font-size: 0.75rem; margin-top: 0.35rem; }
.api-status.ok { color: var(--ok); }
.api-status.err { color: var(--err); }
.main {
flex: 1;
min-width: 0;
padding: 2rem 2rem 3rem;
max-width: 900px;
}
.main-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.main-header h2 { font-size: 1.35rem; font-weight: 600; }
.main-header p { color: var(--muted); margin-top: 0.25rem; font-size: 0.9rem; }
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem;
}
.muted { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; }
button {
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: 1.25rem; align-items: center; }
button.danger {
background: transparent;
border: 1px solid var(--err);
color: var(--err);
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
button.danger:hover { background: rgba(239, 68, 68, 0.12); filter: none; }
input, 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;
}
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));
}
.msg-err { color: var(--err); font-size: 0.85rem; margin-top: 0.75rem; }
.msg-warn { color: #f59e0b; font-size: 0.85rem; margin-top: 0.75rem; }
.print-job-orders {
margin-top: 0.75rem;
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.75rem;
background: var(--bg);
}
.print-job-orders .form-check {
margin: 0.35rem 0;
}
.msg-ok { color: var(--ok); font-size: 0.85rem; margin-top: 0.75rem; }
.item-preview-panel {
margin-bottom: 1.25rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}
.item-preview-panel img {
max-width: 100%;
max-height: 160px;
background: #fff;
padding: 0.5rem;
border-radius: 4px;
}
.layout-preview-wrap {
margin-top: 1.25rem;
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); }
.empty-state {
color: var(--muted);
font-size: 0.95rem;
padding: 2rem 0;
text-align: center;
}
.order-images {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1.25rem;
}
.order-image-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.order-image-thumb {
display: block;
width: 100%;
padding: 0;
border: none;
background: #fff;
cursor: zoom-in;
}
.order-image-thumb img {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
display: block;
background: #fff;
}
.order-lightbox {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.88);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
cursor: zoom-out;
}
.order-lightbox img {
max-width: min(96vw, 1200px);
max-height: 82vh;
object-fit: contain;
background: #fff;
border-radius: 6px;
}
.order-lightbox-caption {
margin-top: 1rem;
color: var(--text);
font-size: 0.9rem;
text-align: center;
max-width: 90vw;
}
.order-lightbox-hint {
margin-top: 0.5rem;
color: var(--muted);
font-size: 0.8rem;
}
.order-image-meta {
padding: 0.5rem 0.65rem;
font-size: 0.75rem;
color: var(--muted);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-image-actions {
padding: 0 0.65rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.order-image-actions button {
width: 100%;
padding: 0.35rem 0.5rem;
font-size: 0.78rem;
}
[x-cloak] { display: none !important; }
input[type="file"].file-input {
margin-top: 0.75rem;
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 {
width: 100%;
height: auto;
position: relative;
max-height: 45vh;
}
.main { padding: 1.25rem; }
}
</style>
</head>
<body>
<div class="app" x-data="dashboard()" x-init="init()">
<aside class="sidebar">
<div class="sidebar-brand">
<h1>Printer Backend</h1>
<p>Admin</p>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'plates' }"
@click="openSection('plates')">
Platten
<span class="nav-chevron" :class="{ open: expanded.plates }"></span>
</button>
<div class="nav-children" x-show="expanded.plates">
<template x-if="plates.length === 0 && !platesLoading">
<p class="nav-empty">Keine Platten</p>
</template>
<template x-for="p in plates" :key="p.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'plates' && plateEditingId === p.id }"
@click="selectPlate(p)"
x-text="sidebarPlateLabel(p)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'plates' && !plateEditingId }"
@click="newPlate()">+ Neu</button>
</div>
</div>
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'items' }"
@click="openSection('items')">
Items
<span class="nav-chevron" :class="{ open: expanded.items }"></span>
</button>
<div class="nav-children" x-show="expanded.items">
<template x-if="items.length === 0 && !itemsLoading">
<p class="nav-empty">Keine Items</p>
</template>
<template x-for="it in items" :key="it.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'items' && itemEditingId === it.id }"
@click="selectItem(it)"
x-text="labelItem(it)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'items' && !itemEditingId }"
@click="newItem()">+ Neu</button>
</div>
</div>
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'configurations' }"
@click="openSection('configurations')">
Konfigurationen
<span class="nav-chevron" :class="{ open: expanded.configurations }"></span>
</button>
<div class="nav-children" x-show="expanded.configurations">
<template x-if="configurations.length === 0">
<p class="nav-empty">Keine Konfigurationen</p>
</template>
<template x-for="c in configurations" :key="c.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'configurations' && configEditingId === c.id }"
@click="selectConfiguration(c)"
x-text="sidebarConfigLabel(c)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'configurations' && !configEditingId }"
@click="newConfiguration()">+ Neu</button>
</div>
</div>
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'orders' }"
@click="openSection('orders')">
Orders
<span class="nav-chevron" :class="{ open: expanded.orders }"></span>
</button>
<div class="nav-children" x-show="expanded.orders">
<template x-if="orders.length === 0 && !ordersLoading">
<p class="nav-empty">No orders</p>
</template>
<template x-for="o in orders" :key="o.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'orders' && orderEditingId === o.id }"
@click="selectOrder(o)"
x-text="sidebarOrderLabel(o)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'orders' && !orderEditingId }"
@click="newOrder()">+ New</button>
</div>
</div>
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'printJobs' }"
@click="openSection('printJobs')">
Print-Jobs
<span class="nav-chevron" :class="{ open: expanded.printJobs }"></span>
</button>
<div class="nav-children" x-show="expanded.printJobs">
<template x-if="printJobs.length === 0 && !printJobsLoading">
<p class="nav-empty">Keine Print-Jobs</p>
</template>
<template x-for="j in printJobs" :key="j.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'printJobs' && printJobEditingId === j.id }"
@click="selectPrintJob(j)"
x-text="sidebarPrintJobLabel(j)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'printJobs' && !printJobEditingId }"
@click="newPrintJob()">+ Neu</button>
</div>
</div>
</nav>
<div class="sidebar-footer">
<label for="api-base">API</label>
<input id="api-base" type="url" x-model="apiBase" @change="onApiBaseChange()" placeholder="http://127.0.0.1:8080">
<p class="api-status ok" x-show="apiOk === true">Erreichbar</p>
<p class="api-status err" x-show="apiOk === false">Nicht erreichbar</p>
</div>
</aside>
<main class="main">
<!-- Platten -->
<div x-show="section === 'plates'">
<div class="main-header">
<h2 x-text="plateEditingId ? 'Platte bearbeiten' : 'Neue Platte'"></h2>
<p class="muted" x-show="!plateEditingId">Druckbett mit Abmessungen und Rändern anlegen.</p>
</div>
<div class="panel">
<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 ? 'Speichern' : 'Anlegen')"></span>
</button>
<button type="button" class="danger" x-show="plateEditingId" @click="deletePlate(plateEditingId)">Löschen</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>
</div>
</div>
<!-- Items -->
<div x-show="section === 'items'">
<div class="main-header">
<h2 x-text="itemEditingId ? 'Item bearbeiten' : 'Neues Item'"></h2>
<p class="muted" x-show="!itemEditingId">Erzeugt SVG-Maske in <code>data/svg_template/</code>.</p>
<p class="muted" x-show="itemEditingId">SVG und Metadaten aktualisieren; Dateiname bleibt unverändert.</p>
</div>
<div class="panel">
<div class="item-preview-panel" x-show="itemEditingId">
<img :src="itemSvgUrl(itemEditingId)" alt="SVG-Vorschau" loading="lazy">
</div>
<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>Breite (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.width_mm" required>
</div>
<div>
<label>Höhe (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.height_mm" required>
</div>
<div>
<label>Eckenradius (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="itemForm.corner_radius_mm">
</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 ? 'Speichern' : 'Anlegen')"></span>
</button>
<button type="button" class="danger" x-show="itemEditingId" @click="deleteItem(itemEditingId)">Löschen</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>
</div>
</div>
<!-- Konfigurationen -->
<div x-show="section === 'configurations'">
<div class="main-header">
<h2 x-text="configEditingId ? 'Konfiguration bearbeiten' : 'Neue Konfiguration'"></h2>
<p class="muted">Kombiniert Platte und Item; berechnet maximale Stückzahl.</p>
</div>
<div class="panel">
<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 ? 'Speichern' : 'Anlegen')"></span>
</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>
<button type="button" class="danger" x-show="configEditingId" @click="deleteConfiguration(configEditingId)">Löschen</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) + ' × ' + layoutPreview.footprint_height_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="muted" 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>
</div>
</div>
<!-- Print-Jobs -->
<div x-show="section === 'printJobs'">
<div class="main-header">
<h2 x-text="printJobEditingId ? 'Print-Job bearbeiten' : 'Neuer Print-Job'"></h2>
<p class="muted">Konfiguration und Orders kombinieren; Bilder werden auf die Item-Positionen verteilt.</p>
</div>
<div class="panel">
<form @submit.prevent="savePrintJob()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="printJobForm.name" placeholder="z.B. Batch Montag">
</div>
<div>
<label>Konfiguration</label>
<select x-model="printJobForm.configuration_id" @change="refreshPrintJobSummary()" required>
<option value="">— wählen —</option>
<template x-for="c in configurations" :key="c.id">
<option :value="c.id" x-text="sidebarConfigLabel(c)"></option>
</template>
</select>
</div>
</div>
<label style="margin-top: 1rem;">Orders</label>
<p class="muted" x-show="orders.length === 0">Zuerst Orders anlegen und Bilder hochladen.</p>
<div class="print-job-orders" x-show="orders.length > 0">
<template x-for="o in orders" :key="o.id">
<label class="form-check">
<input type="checkbox"
:checked="isPrintJobOrderSelected(o.id)"
@change="togglePrintJobOrder(o.id)">
<span x-text="sidebarOrderLabel(o)"></span>
</label>
</template>
</div>
<div class="btn-row" style="margin-top: 1.25rem;">
<button type="submit"
:disabled="printJobSaving || !printJobForm.configuration_id || printJobForm.order_ids.length === 0">
<span x-text="printJobSaving ? 'Speichere…' : (printJobEditingId ? 'Speichern' : 'Anlegen')"></span>
</button>
<button type="button" class="secondary" @click="downloadPrintJobPDF()"
:disabled="printJobPdfGenerating || !printJobEditingId">
<span x-text="printJobPdfGenerating ? 'PDF…' : 'PDF erzeugen'"></span>
</button>
<button type="button" class="danger" x-show="printJobEditingId" @click="deletePrintJob(printJobEditingId)">Löschen</button>
</div>
<p class="msg-err" x-show="printJobError" x-text="printJobError"></p>
<p class="msg-ok" x-show="printJobSuccess" x-text="printJobSuccess"></p>
<p class="msg-warn" x-show="printJobSummary?.image_overflow" x-text="printJobSummary.warning"></p>
</form>
<template x-if="printJobSummary && printJobForm.configuration_id">
<div class="layout-preview-wrap" style="margin-top: 1.5rem;">
<div x-html="layoutPreviewSvg(printJobSummary.preview)"></div>
<div class="layout-stats">
<span><strong x-text="printJobSummary.image_count"></strong> Bilder</span>
<span><strong x-text="printJobSummary.slot_count"></strong> Item-Positionen</span>
<span><strong x-text="printJobSummary.assignments?.length || Math.min(printJobSummary.image_count, printJobSummary.slot_count)"></strong> zugeordnet</span>
<span x-show="printJobSummary.image_overflow">Überschuss <strong x-text="printJobSummary.image_count - printJobSummary.slot_count"></strong></span>
</div>
</div>
</template>
<p class="muted" x-show="printJobForm.configuration_id && !printJobSummary && !printJobSummaryLoading">
Keine Layout-Vorschau für diese Konfiguration.
</p>
<p class="muted" x-show="printJobSummaryLoading">Berechne Zuordnung…</p>
</div>
</div>
<!-- Orders -->
<div x-show="section === 'orders'">
<div class="main-header">
<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="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 ? 'Saving…' : (orderEditingId ? 'Save' : 'Create')"></span>
</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>Upload images</label>
<input type="file" class="file-input" accept="image/*" multiple
:disabled="orderImageUploading || orderLoading"
@change="uploadOrderImages($event)">
<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) + ' — click for full size'">
<img :src="orderImageUrl(orderEditingId, img.id)"
: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)">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;">
No images yet — choose files above.
</p>
</div>
</div>
</div>
</main>
<div class="order-lightbox" x-show="orderLightbox" x-cloak
@click="closeOrderLightbox()"
@keydown.escape.window="closeOrderLightbox()">
<template x-if="orderLightbox">
<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">Click or press Esc to close</p>
</div>
</template>
</div>
</div>
<script>
function dashboard() {
return {
apiBase: '',
apiOk: null,
section: 'plates',
expanded: { plates: true, items: false, configurations: false, orders: false, printJobs: false },
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: '',
width_mm: 80,
height_mm: 80,
corner_radius_mm: 5,
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,
orders: [],
ordersLoading: false,
orderSaving: false,
orderImageUploading: false,
orderError: '',
orderSuccess: '',
orderEditingId: null,
orderImages: [],
orderForm: { name: '', printed: false, shipped: false, ref: '' },
orderLoading: false,
orderImageVersion: 0,
orderLightbox: null,
printJobs: [],
printJobsLoading: false,
printJobSaving: false,
printJobPdfGenerating: false,
printJobError: '',
printJobSuccess: '',
printJobEditingId: null,
printJobForm: { name: '', configuration_id: '', order_ids: [] },
printJobSummary: null,
printJobSummaryLoading: false,
_printJobSummaryTimer: null,
openSection(name) {
if (this.section === name) {
this.expanded[name] = !this.expanded[name];
} else {
this.section = name;
this.expanded[name] = true;
}
},
selectPlate(p) {
this.section = 'plates';
this.expanded.plates = true;
this.editPlate(p);
},
newPlate() {
this.section = 'plates';
this.expanded.plates = true;
this.cancelPlateEdit();
},
selectItem(it) {
this.section = 'items';
this.expanded.items = true;
this.editItem(it);
},
newItem() {
this.section = 'items';
this.expanded.items = true;
this.cancelItemEdit();
},
selectConfiguration(c) {
this.section = 'configurations';
this.expanded.configurations = true;
this.editConfiguration(c);
},
newConfiguration() {
this.section = 'configurations';
this.expanded.configurations = true;
this.cancelConfigEdit();
},
async selectOrder(o) {
this.section = 'orders';
this.expanded.orders = true;
await this.editOrder(o);
},
newOrder() {
this.section = 'orders';
this.expanded.orders = true;
this.cancelOrderEdit();
},
async selectPrintJob(j) {
this.section = 'printJobs';
this.expanded.printJobs = true;
await this.editPrintJob(j);
},
newPrintJob() {
this.section = 'printJobs';
this.expanded.printJobs = true;
this.cancelPrintJobEdit();
},
sidebarPlateLabel(p) {
const n = p.name || 'Platte';
return n + ' · ' + this.fmtSize(p.width_mm, p.height_mm);
},
sidebarConfigLabel(c) {
return c.name || ('Konfiguration ' + c.id.slice(0, 8));
},
sidebarOrderLabel(o) {
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;
if (count) parts.push(count + ' img' + (count === 1 ? '' : 's'));
return parts.length ? n + ' · ' + parts.join(' · ') : n;
},
sidebarPrintJobLabel(j) {
const n = j.name || ('Print-Job ' + j.id.slice(0, 8));
const parts = [];
const cfg = this.configurations.find(c => c.id === j.configuration_id);
if (cfg) parts.push(this.sidebarConfigLabel(cfg));
const orderCount = (j.order_ids || []).length;
if (orderCount) parts.push(orderCount + ' Order' + (orderCount === 1 ? '' : 's'));
const s = j.summary;
if (s?.image_count != null) parts.push(s.image_count + ' Bilder');
if (s?.image_overflow) parts.push('!');
return parts.length ? n + ' · ' + parts.join(' · ') : n;
},
isPrintJobOrderSelected(orderId) {
return (this.printJobForm.order_ids || []).includes(orderId);
},
togglePrintJobOrder(orderId) {
const ids = [...(this.printJobForm.order_ids || [])];
const i = ids.indexOf(orderId);
if (i >= 0) ids.splice(i, 1);
else ids.push(orderId);
this.printJobForm.order_ids = ids;
this.refreshPrintJobSummary();
},
orderFormFrom(o) {
return {
name: o.name || '',
printed: !!o.printed,
shipped: !!o.shipped,
ref: o.ref || o.ref_link || '',
};
},
apiUrl(path) {
return `${this.apiBase.replace(/\/$/, '')}${path}`;
},
itemSvgUrl(id) {
return `${this.apiUrl('/items/' + id + '/svg')}?t=${Date.now()}`;
},
orderImageUrl(orderId, imageId) {
return `${this.apiUrl('/orders/' + orderId + '/images/' + imageId)}?v=${this.orderImageVersion}`;
},
openOrderImage(img) {
if (!this.orderEditingId || !img?.id) return;
this.orderLightbox = {
orderId: this.orderEditingId,
imageId: img.id,
label: img.original_name || img.filename || 'Image',
};
},
closeOrderLightbox() {
this.orderLightbox = null;
},
onOrderImageError(event, img) {
const el = event.target;
if (!el || el.dataset.retried) return;
el.dataset.retried = '1';
this.orderImageVersion++;
el.src = this.orderImageUrl(this.orderEditingId, img.id);
},
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(),
this.loadOrders(),
this.loadPrintJobs(),
]);
}
},
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.';
}
const keepId = this.plateEditingId;
await this.loadPlates();
if (keepId) {
const p = this.plates.find(x => x.id === keepId);
if (p) this.editPlate(p);
else this.cancelPlateEdit();
} else {
this.cancelPlateEdit();
}
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: '',
width_mm: 80,
height_mm: 80,
corner_radius_mm: 5,
bleed_mm: 2,
margin_mm: 5,
padding_mm: 3,
};
},
itemSizeLabel(it) {
const w = it.spec.width_mm || it.spec.size_mm;
const h = it.spec.height_mm || it.spec.size_mm;
let s = w + ' × ' + h + ' mm';
if (it.spec.corner_radius_mm) s += ' · r ' + it.spec.corner_radius_mm;
s += ' · bleed ' + it.spec.bleed_mm + ' · margin ' + it.spec.margin_mm;
return s;
},
editItem(it) {
this.itemEditingId = it.id;
const w = it.spec.width_mm || it.spec.size_mm;
const h = it.spec.height_mm || it.spec.size_mm;
this.itemForm = {
name: it.name || '',
width_mm: w,
height_mm: h,
corner_radius_mm: it.spec.corner_radius_mm || 0,
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,
width_mm: Number(this.itemForm.width_mm),
height_mm: Number(this.itemForm.height_mm),
corner_radius_mm: Number(this.itemForm.corner_radius_mm) || 0,
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.';
}
const keepId = this.itemEditingId;
await this.loadItems();
if (keepId) {
const it = this.items.find(x => x.id === keepId);
if (it) this.editItem(it);
else this.cancelItemEdit();
} else {
this.cancelItemEdit();
}
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.';
}
const keepId = this.configEditingId;
await this.loadConfigurations();
if (keepId) {
const c = this.configurations.find(x => x.id === keepId);
if (c) this.editConfiguration(c);
else this.cancelConfigEdit();
} else {
this.cancelConfigEdit();
}
} 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),
});
const name = this.configEditingId
? 'configuration-' + this.configEditingId.slice(0, 8) + '.pdf'
: 'layout-preview.pdf';
const path = this.configEditingId
? '/configurations/' + this.configEditingId + '/pdf'
: '/layout/pdf?' + q;
this.downloadBlob(path, name);
},
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);
}
},
async loadOrders() {
this.ordersLoading = true;
try {
this.orders = await this.apiJSON('GET', '/orders') || [];
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.ordersLoading = false;
}
},
async editOrder(o) {
this.orderEditingId = o.id;
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 = this.orderFormFrom(fresh);
this.orderImages = fresh.images ? [...fresh.images] : [];
this.orderImageVersion++;
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.orderLoading = false;
}
},
cancelOrderEdit() {
this.orderEditingId = null;
this.orderForm = { name: '', printed: false, shipped: false, ref: '' };
this.orderImages = [];
this.orderLightbox = null;
this.orderError = '';
this.orderSuccess = '';
},
async saveOrder() {
this.orderSaving = true;
this.orderError = '';
this.orderSuccess = '';
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 updated.';
} else {
saved = await this.apiJSON('POST', '/orders', body);
this.orderSuccess = 'Order created.';
}
const keepId = saved?.id || this.orderEditingId;
await this.loadOrders();
if (keepId) {
const o = this.orders.find(x => x.id === keepId);
if (o) await this.editOrder(o);
else this.cancelOrderEdit();
} else {
this.cancelOrderEdit();
}
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.orderSaving = false;
}
},
async uploadOrderImages(event) {
const files = event.target?.files;
if (!files?.length || !this.orderEditingId) return;
this.orderImageUploading = true;
this.orderError = '';
this.orderSuccess = '';
const fd = new FormData();
for (const f of files) {
fd.append('images', f);
}
try {
const res = await fetch(this.apiUrl('/orders/' + this.orderEditingId + '/images'), {
method: 'POST',
body: fd,
});
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);
}
const result = text ? JSON.parse(text) : null;
this.orderImageVersion++;
if (result?.order) {
await this.editOrder(result.order);
} else {
await this.loadOrders();
const o = this.orders.find(x => x.id === this.orderEditingId);
if (o) await this.editOrder(o);
}
const n = (result?.added || []).length;
this.orderSuccess = n === 1 ? '1 image uploaded.' : n + ' images uploaded.';
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.orderImageUploading = false;
if (event.target) event.target.value = '';
}
},
async deleteOrderImage(imageId) {
if (!this.orderEditingId) return;
if (!confirm('Remove this image?')) return;
this.orderError = '';
try {
const res = await fetch(
this.apiUrl('/orders/' + this.orderEditingId + '/images/' + imageId),
{ method: 'DELETE' },
);
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
this.orderImageVersion++;
await this.loadOrders();
const o = this.orders.find(x => x.id === this.orderEditingId);
if (o) await this.editOrder(o);
this.orderSuccess = 'Image removed.';
} catch (e) {
this.orderError = String(e.message || e);
}
},
async deleteOrder(id) {
if (!confirm('Delete this order and all images?')) return;
this.orderError = '';
try {
const res = await fetch(this.apiUrl('/orders/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
await this.loadOrders();
if (this.orderEditingId === id) this.cancelOrderEdit();
if (this.section === 'printJobs') this.refreshPrintJobSummary();
} catch (e) {
this.orderError = String(e.message || e);
}
},
async loadPrintJobs() {
this.printJobsLoading = true;
try {
this.printJobs = await this.apiJSON('GET', '/print-jobs') || [];
} catch (e) {
this.printJobError = String(e.message || e);
} finally {
this.printJobsLoading = false;
}
},
defaultPrintJobForm() {
return { name: '', configuration_id: '', order_ids: [] };
},
async editPrintJob(j) {
this.printJobEditingId = j.id;
this.printJobForm = {
name: j.name || '',
configuration_id: j.configuration_id || '',
order_ids: [...(j.order_ids || [])],
};
this.printJobError = '';
this.printJobSuccess = '';
try {
const fresh = await this.apiJSON('GET', '/print-jobs/' + j.id);
this.printJobForm = {
name: fresh.name || '',
configuration_id: fresh.configuration_id || '',
order_ids: [...(fresh.order_ids || [])],
};
if (fresh.summary) this.printJobSummary = fresh.summary;
} catch (e) {
this.printJobError = String(e.message || e);
}
await this.refreshPrintJobSummary();
},
cancelPrintJobEdit() {
this.printJobEditingId = null;
this.printJobForm = this.defaultPrintJobForm();
this.printJobSummary = null;
this.printJobError = '';
this.printJobSuccess = '';
},
async savePrintJob() {
this.printJobSaving = true;
this.printJobError = '';
this.printJobSuccess = '';
const body = {
name: this.printJobForm.name,
configuration_id: this.printJobForm.configuration_id,
order_ids: [...(this.printJobForm.order_ids || [])],
};
try {
let saved;
if (this.printJobEditingId) {
saved = await this.apiJSON('PUT', '/print-jobs/' + this.printJobEditingId, body);
this.printJobSuccess = 'Print-Job gespeichert.';
} else {
saved = await this.apiJSON('POST', '/print-jobs', body);
this.printJobSuccess = 'Print-Job angelegt.';
}
const keepId = saved?.id || this.printJobEditingId;
await this.loadPrintJobs();
if (keepId) {
const j = this.printJobs.find(x => x.id === keepId);
if (j) await this.editPrintJob(j);
else this.cancelPrintJobEdit();
} else {
this.cancelPrintJobEdit();
}
} catch (e) {
this.printJobError = String(e.message || e);
} finally {
this.printJobSaving = false;
}
},
async deletePrintJob(id) {
if (!confirm('Print-Job wirklich löschen?')) return;
this.printJobError = '';
try {
const res = await fetch(this.apiUrl('/print-jobs/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
await this.loadPrintJobs();
if (this.printJobEditingId === id) this.cancelPrintJobEdit();
} catch (e) {
this.printJobError = String(e.message || e);
}
},
refreshPrintJobSummary() {
clearTimeout(this._printJobSummaryTimer);
this._printJobSummaryTimer = setTimeout(() => this.fetchPrintJobSummary(), 200);
},
printJobFormMatchesSaved() {
if (!this.printJobEditingId) return false;
const saved = this.printJobs.find(j => j.id === this.printJobEditingId);
if (!saved) return false;
const a = [...(this.printJobForm.order_ids || [])].sort().join(',');
const b = [...(saved.order_ids || [])].sort().join(',');
return saved.configuration_id === this.printJobForm.configuration_id && a === b;
},
async fetchPrintJobSummary() {
if (!this.printJobForm.configuration_id) {
this.printJobSummary = null;
return;
}
this.printJobSummaryLoading = true;
let serverSummary = null;
try {
if (this.printJobEditingId && this.printJobForm.order_ids?.length && this.printJobFormMatchesSaved()) {
serverSummary = await this.apiJSON('GET', '/print-jobs/' + this.printJobEditingId + '/summary');
}
} catch (_) {}
this.printJobSummary = this.computePrintJobSummaryLocal(serverSummary);
this.printJobSummaryLoading = false;
},
computePrintJobSummaryLocal(serverSummary) {
const cfg = this.configurations.find(c => c.id === this.printJobForm.configuration_id);
const preview = cfg?.preview;
if (!preview) return serverSummary || null;
const selected = this.orders.filter(o => this.printJobForm.order_ids.includes(o.id));
let imageCount = 0;
for (const o of selected) {
imageCount += (o.images || []).length;
}
const slots = preview.count || 0;
const overflow = imageCount > slots;
let warning = '';
if (overflow) {
warning = `${imageCount} Bilder für ${slots} Item-Positionen: ${imageCount - slots} Bilder werden nicht platziert`;
}
const base = serverSummary || {};
return {
...base,
image_count: imageCount,
slot_count: slots,
image_overflow: overflow,
warning: warning || base.warning,
preview,
assignments: base.assignments || [],
};
},
async downloadPrintJobPDF() {
if (!this.printJobEditingId) return;
this.printJobPdfGenerating = true;
this.printJobError = '';
try {
const res = await fetch(this.apiUrl('/print-jobs/' + this.printJobEditingId + '/pdf'));
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 warn = res.headers.get('X-Print-Job-Warning');
if (warn) {
this.printJobSummary = {
...(this.printJobSummary || {}),
image_overflow: true,
warning: warn,
};
}
const blob = await res.blob();
const name = (this.printJobForm.name || 'print-job-' + this.printJobEditingId.slice(0, 8))
.replace(/[^\w\-]+/g, '_') + '.pdf';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
this.printJobSuccess = warn ? 'PDF erzeugt (mit Warnung).' : 'PDF erzeugt.';
} catch (e) {
this.printJobError = String(e.message || e);
} finally {
this.printJobPdfGenerating = false;
}
},
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 trim = p.trim_offset_mm || 0;
const canvasW = p.canvas_width_mm || p.footprint_width_mm;
const canvasH = p.canvas_height_mm || p.footprint_height_mm;
const fpW = p.footprint_width_mm;
const fpH = p.footprint_height_mm;
for (const pos of p.positions || []) {
const x = pos.x_mm * scale;
const y = pos.y_mm * scale;
items += `<rect x="${x}" y="${y}" width="${canvasW * scale}" height="${canvasH * scale}" fill="none" stroke="#8b9cb3" stroke-width="0.8" stroke-dasharray="3,2"/>`;
items += `<rect x="${(pos.x_mm + trim) * scale}" y="${(pos.y_mm + trim) * 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>