1566 lines
54 KiB
HTML
1566 lines
54 KiB
HTML
<!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-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;
|
||
}
|
||
@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">Keine 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()">+ 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>
|
||
|
||
<!-- Orders -->
|
||
<div x-show="section === 'orders'">
|
||
<div class="main-header">
|
||
<h2 x-text="orderEditingId ? 'Order bearbeiten' : 'Neue Order'"></h2>
|
||
<p class="muted" x-show="!orderEditingId">Order anlegen, danach Bilder hochladen.</p>
|
||
<p class="muted" x-show="orderEditingId">Beliebig viele Bilder pro Order (PNG, JPEG, WebP, GIF).</p>
|
||
</div>
|
||
<div class="panel">
|
||
<form @submit.prevent="saveOrder()">
|
||
<div class="form-grid">
|
||
<div>
|
||
<label>Name (optional)</label>
|
||
<input type="text" x-model="orderForm.name" placeholder="z.B. Kunde Müller">
|
||
</div>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button type="submit" :disabled="orderSaving">
|
||
<span x-text="orderSaving ? 'Speichere…' : (orderEditingId ? 'Speichern' : 'Anlegen')"></span>
|
||
</button>
|
||
<button type="button" class="danger" x-show="orderEditingId" @click="deleteOrder(orderEditingId)">Löschen</button>
|
||
</div>
|
||
<p class="msg-err" x-show="orderError" x-text="orderError"></p>
|
||
<p class="msg-ok" x-show="orderSuccess" x-text="orderSuccess"></p>
|
||
</form>
|
||
|
||
<div x-show="orderEditingId" style="margin-top: 1.5rem;">
|
||
<label>Bilder hochladen</label>
|
||
<input type="file" class="file-input" accept="image/*" multiple
|
||
:disabled="orderImageUploading || orderLoading"
|
||
@change="uploadOrderImages($event)">
|
||
<p class="muted" x-show="orderImageUploading">Lade hoch…</p>
|
||
<p class="muted" x-show="orderLoading">Lade Bilder…</p>
|
||
|
||
<div class="order-images" x-show="orderImages.length > 0">
|
||
<template x-for="img in orderImages" :key="img.id">
|
||
<div class="order-image-card">
|
||
<button type="button" class="order-image-thumb"
|
||
@click="openOrderImage(img)"
|
||
:title="(img.original_name || img.filename) + ' — Klicken für Vollbild'">
|
||
<img :src="orderImageUrl(orderEditingId, img.id)"
|
||
:alt="img.original_name || 'Bild'"
|
||
loading="lazy"
|
||
@error="onOrderImageError($event, img)">
|
||
</button>
|
||
<p class="order-image-meta" x-text="img.original_name || img.filename"></p>
|
||
<div class="order-image-actions">
|
||
<button type="button" class="secondary" @click="openOrderImage(img)">Vergrößern</button>
|
||
<button type="button" class="danger" @click="deleteOrderImage(img.id)">Entfernen</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<p class="muted" x-show="orderImages.length === 0 && !orderImageUploading && !orderLoading" style="margin-top: 1rem;">
|
||
Noch keine Bilder — Dateien oben auswählen.
|
||
</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">Klicken oder Esc zum Schließen</p>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function dashboard() {
|
||
return {
|
||
apiBase: '',
|
||
apiOk: null,
|
||
|
||
section: 'plates',
|
||
expanded: { plates: true, items: false, configurations: false, orders: 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: '' },
|
||
orderLoading: false,
|
||
orderImageVersion: 0,
|
||
orderLightbox: 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();
|
||
},
|
||
|
||
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 || ('Order ' + o.id.slice(0, 8));
|
||
const count = (o.images || []).length;
|
||
return n + (count ? ' · ' + count + ' Bild' + (count === 1 ? '' : 'er') : '');
|
||
},
|
||
|
||
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 || 'Bild',
|
||
};
|
||
},
|
||
|
||
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(),
|
||
]);
|
||
}
|
||
},
|
||
|
||
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 = { name: o.name || '' };
|
||
this.orderImages = o.images ? [...o.images] : [];
|
||
this.orderError = '';
|
||
this.orderSuccess = '';
|
||
this.orderLoading = true;
|
||
try {
|
||
const fresh = await this.apiJSON('GET', '/orders/' + o.id);
|
||
this.orderForm = { name: fresh.name || '' };
|
||
this.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: '' };
|
||
this.orderImages = [];
|
||
this.orderLightbox = null;
|
||
this.orderError = '';
|
||
this.orderSuccess = '';
|
||
},
|
||
|
||
async saveOrder() {
|
||
this.orderSaving = true;
|
||
this.orderError = '';
|
||
this.orderSuccess = '';
|
||
const body = { name: this.orderForm.name };
|
||
try {
|
||
let saved;
|
||
if (this.orderEditingId) {
|
||
saved = await this.apiJSON('PUT', '/orders/' + this.orderEditingId, body);
|
||
this.orderSuccess = 'Order aktualisiert.';
|
||
} else {
|
||
saved = await this.apiJSON('POST', '/orders', body);
|
||
this.orderSuccess = 'Order angelegt.';
|
||
}
|
||
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 Bild hochgeladen.' : n + ' Bilder hochgeladen.';
|
||
} 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('Bild wirklich entfernen?')) 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 = 'Bild entfernt.';
|
||
} catch (e) {
|
||
this.orderError = String(e.message || e);
|
||
}
|
||
},
|
||
|
||
async deleteOrder(id) {
|
||
if (!confirm('Order inkl. aller Bilder wirklich löschen?')) 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();
|
||
} catch (e) {
|
||
this.orderError = 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 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>
|