Files
foodlinkk-command-center/cockpit/templates/packaging.html
T
2026-06-09 10:41:13 +00:00

382 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Packaging Studio · Foodlinkk{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/packaging-studio.css?v=1" />
{% endblock %}
{% block content %}
<div class="pack-studio" x-data="packagingStudio()" x-init="init()">
<header class="pack-hero">
<div>
<h1>📦 Packaging Studio</h1>
<p>Ontwerp verpakkingen met Python SVG-engine · koppel aan project · export naar NAS · email &amp; kopieer naar ander project.</p>
</div>
<div class="pack-toolbar">
<button class="btn btn-sm" @click="preview()" :disabled="loading">👁 Preview</button>
<button class="btn btn-primary btn-sm" @click="save()" :disabled="loading">
<span x-text="packagingId ? '💾 Bijwerken' : '💾 Opslaan'"></span>
</button>
<button class="btn btn-sm" @click="duplicate()" x-show="packagingId" :disabled="loading">📋 Dupliceer</button>
<button class="btn btn-sm" @click="showCopyModal=true" x-show="packagingId">📁 Kopieer naar project</button>
<button class="btn btn-sm" @click="showEmailModal=true" x-show="packagingId">✉ Email</button>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('svg')" x-show="packagingId">SVG</a>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('png')" x-show="packagingId">PNG</a>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('pdf')" x-show="packagingId">PDF</a>
<button class="btn btn-sm btn-ghost" @click="newDesign()"> Nieuw</button>
</div>
</header>
<div class="pack-layout">
<aside class="pack-panel">
<div class="pack-tabs">
<button class="pack-tab" :class="{active: sideTab==='shape'}" @click="sideTab='shape'">Vorm</button>
<button class="pack-tab" :class="{active: sideTab==='brand'}" @click="sideTab='brand'">Brand</button>
<button class="pack-tab" :class="{active: sideTab==='text'}" @click="sideTab='text'">Tekst</button>
<button class="pack-tab" :class="{active: sideTab==='elements'}" @click="sideTab='elements'">Elementen</button>
<button class="pack-tab" :class="{active: sideTab==='project'}" @click="sideTab='project'">Project</button>
</div>
<div x-show="sideTab==='shape'">
<h3>Afmetingen &amp; type</h3>
<div class="pack-field-grid">
<label class="full">Design naam
<input class="form-input" x-model="form.design_name" placeholder="AH Halal Box v1" /></label>
<label class="full">Type
<select class="form-input" x-model="form.type">
<template x-for="t in types" :key="t.id">
<option :value="t.id" x-text="t.label"></option>
</template>
</select></label>
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" @input="debouncedPreview()" /></label>
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" @input="debouncedPreview()" /></label>
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" @input="debouncedPreview()" /></label>
<label>Bleed (mm)<input type="number" class="form-input" x-model.number="form.bleed_mm" min="0" max="20" @input="debouncedPreview()" /></label>
<label class="full">Barcode<input class="form-input" x-model="form.barcode_value" @input="debouncedPreview()" /></label>
<label class="full">QR URL<input class="form-input" x-model="form.qr_value" placeholder="https://foodlinkk.nl/product/..." @input="debouncedPreview()" /></label>
</div>
</div>
<div x-show="sideTab==='brand'" x-cloak>
<h3>Kleuren &amp; stijl</h3>
<div class="pack-field-grid">
<template x-for="c in colorFields" :key="c.key">
<label class="full" x-text="c.label">
<div class="pack-color-row">
<input class="form-input" x-model="form.brand[c.key]" @input="debouncedPreview()" />
<input type="color" :value="form.brand[c.key]" @input="form.brand[c.key]=$event.target.value; debouncedPreview()" />
</div>
</label>
</template>
</div>
</div>
<div x-show="sideTab==='text'" x-cloak>
<h3>Producttekst</h3>
<div class="pack-field-grid">
<label class="full">Productnaam<input class="form-input" x-model="form.text.product_name" @input="debouncedPreview()" /></label>
<label class="full">Tagline<input class="form-input" x-model="form.text.tagline" @input="debouncedPreview()" /></label>
<label>Netto gewicht<input class="form-input" x-model="form.text.subtitle" placeholder="400g" @input="debouncedPreview()" /></label>
<label>Herkomst<input class="form-input" x-model="form.text.origin" @input="debouncedPreview()" /></label>
<label class="full">Ingrediënten<textarea class="form-input" rows="3" x-model="form.text.ingredients" @input="debouncedPreview()"></textarea></label>
<label class="full">THT / best before<input class="form-input" x-model="form.text.best_before" @input="debouncedPreview()" /></label>
</div>
</div>
<div x-show="sideTab==='elements'" x-cloak>
<h3>Print &amp; productie-elementen</h3>
<div class="pack-elements">
<template x-for="el in elementList" :key="el.key">
<label><input type="checkbox" x-model="form.elements[el.key]" @change="debouncedPreview()"> <span x-text="el.label"></span></label>
</template>
</div>
</div>
<div x-show="sideTab==='project'" x-cloak>
<h3>Project &amp; NAS</h3>
<div class="pack-field-grid">
<label class="full">Bestaand project
<select class="form-input" x-model="save.project_id">
<option value="">— Nieuw project —</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
</template>
</select></label>
<label class="full" x-show="!save.project_id">Nieuw projectnaam
<input class="form-input" x-model="save.project_name" placeholder="Jumbo pouch Q3" /></label>
<label class="full" x-show="!save.project_id">Klant
<select class="form-input" x-model="save.client_id">
<option value=""></option>
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
</select></label>
</div>
<p class="page-tip" style="margin-top:0.75rem;font-size:0.72rem">Bij opslaan: SVG, PNG, PDF + manifest → <code>…/packaging/</code> op NAS.</p>
<div x-show="nasInfo.ok" class="pack-nas-badge" style="margin-top:0.5rem">✓ NAS export OK</div>
<div class="muted" style="font-size:0.68rem;margin-top:0.35rem;word-break:break-all" x-show="nasInfo.files?.svg" x-text="nasInfo.files?.svg"></div>
</div>
</aside>
<section class="pack-panel pack-preview-wrap">
<h3>Live preview</h3>
<div class="pack-preview-stage">
<div x-show="svg" x-html="svg"></div>
<p class="page-tip" x-show="!svg && !loading">Klik Preview of pas instellingen aan.</p>
<p class="page-tip" x-show="loading">Genereren…</p>
</div>
<div class="pack-meta-bar">
<span x-text="packagingId ? 'ID: '+packagingId.substring(0,8)+'…' : 'Nog niet opgeslagen'"></span>
<span x-text="form.type + ' · ' + form.width_mm + '×' + form.height_mm + '×' + form.depth_mm + ' mm'"></span>
</div>
</section>
<aside class="pack-panel">
<h3>Bibliotheek</h3>
<div class="pack-library">
<template x-for="p in library" :key="p.id">
<article class="pack-design-card" :class="{active: packagingId===p.id}" @click="loadDesign(p.id)">
<strong x-text="p.spec?.design_name || p.spec?.text?.product_name || p.spec?.type || 'Design'"></strong>
<small x-text="(p.project_name||'Los') + (p.client_name ? ' · '+p.client_name : '')"></small>
<small x-text="(p.created_at||'').replace('T',' ').slice(0,16)"></small>
</article>
</template>
<p class="page-tip" x-show="!library.length">Nog geen designs opgeslagen.</p>
</div>
</aside>
</div>
<div class="pack-modal-backdrop" x-show="showEmailModal" x-cloak @click="showEmailModal=false"></div>
<div class="pack-modal" x-show="showEmailModal" x-cloak>
<h3>✉ Design emailen</h3>
<label>Aan (komma-gescheiden)<input class="form-input" x-model="emailForm.to" placeholder="klant@example.com" /></label>
<label style="margin-top:0.5rem;display:block">Onderwerp<input class="form-input" x-model="emailForm.subject" /></label>
<label style="margin-top:0.5rem;display:block">Bericht<textarea class="form-input" rows="4" x-model="emailForm.body"></textarea></label>
<p class="page-tip" style="font-size:0.7rem;margin-top:0.5rem">Downloadlinks voor SVG, PNG en PDF worden automatisch toegevoegd.</p>
<div class="pack-modal-actions">
<button class="btn btn-sm" @click="showEmailModal=false">Annuleer</button>
<button class="btn btn-primary btn-sm" @click="sendEmail()">Verstuur</button>
</div>
</div>
<div class="pack-modal-backdrop" x-show="showCopyModal" x-cloak @click="showCopyModal=false"></div>
<div class="pack-modal" x-show="showCopyModal" x-cloak>
<h3>📁 Kopieer naar ander project</h3>
<label>Doelproject
<select class="form-input" x-model="copyTargetId">
<option value="">— Selecteer —</option>
<template x-for="p in cockpitProjects" :key="'cp'+p.id">
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
</template>
</select></label>
<div class="pack-modal-actions">
<button class="btn btn-sm" @click="showCopyModal=false">Annuleer</button>
<button class="btn btn-primary btn-sm" @click="copyToProject()">Kopieer</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function packagingStudio() {
const DEFAULT_BRAND = {
bg: '#0b1220', panel: '#101a2d', primary: '#00e5ff', secondary: '#ffd700',
accent: '#b8ff3c', text: '#e2e8f0', muted: '#94a3b8', cut_line: '#ef4444',
fold_line: '#60a5fa', bleed: '#f97316', halal: '#22c55e'
};
const DEFAULT_ELEMENTS = {
barcode: true, logo_area: true, fold_lines: true, cut_lines: true,
nutrition_panel: false, ingredients: true, halal_badge: false, window: false,
qr_code: false, glue_tabs: false, bleed: true, dimensions: true
};
return {
loading: false,
sideTab: 'shape',
svg: '',
packagingId: '',
library: [],
cockpitProjects: [],
clients: [],
types: [],
nasInfo: {},
showEmailModal: false,
showCopyModal: false,
copyTargetId: '',
previewTimer: null,
save: { project_name: '', client_id: '', project_id: '{{ initial_project_id or "" }}' },
emailForm: { to: '', subject: 'Foodlinkk packaging design', body: 'Bijgevoegd vind je ons packaging design.' },
colorFields: [
{ key: 'primary', label: 'Primair' }, { key: 'secondary', label: 'Secundair' },
{ key: 'accent', label: 'Accent' }, { key: 'panel', label: 'Paneel' },
{ key: 'text', label: 'Tekst' }, { key: 'bg', label: 'Achtergrond' }
],
elementList: [
{ key: 'logo_area', label: 'Logo zone' }, { key: 'barcode', label: 'Barcode' },
{ key: 'qr_code', label: 'QR code' }, { key: 'nutrition_panel', label: 'Voedingswaarden' },
{ key: 'ingredients', label: 'Ingrediënten' }, { key: 'halal_badge', label: 'Halal badge' },
{ key: 'window', label: 'Venster' }, { key: 'fold_lines', label: 'Vouwlijnen' },
{ key: 'cut_lines', label: 'Snijlijnen' }, { key: 'glue_tabs', label: 'Lijmtabs' },
{ key: 'bleed', label: 'Bleed marge' }, { key: 'dimensions', label: 'Afmetingen' }
],
form: {
type: 'folding_box',
width_mm: 120, height_mm: 80, depth_mm: 40, bleed_mm: 3,
barcode_value: '8710000000012', qr_value: '',
design_name: 'Nieuw design',
brand: { ...DEFAULT_BRAND },
text: {
product_name: 'FOODLINKK Premium',
tagline: 'Authentiek · Vers · Halal',
subtitle: '400g', origin: 'Geproduceerd in NL',
ingredients: 'Kip 45%, water, specerijen, zout, ui, knoflook.',
best_before: 'Ten minste houdbaar tot: zie verpakking'
},
elements: { ...DEFAULT_ELEMENTS }
},
async init() {
await Promise.all([this.refreshLibrary(), this.loadCockpitProjects(), this.loadTypes()]);
try {
const r = await Cockpit.api('/clients');
this.clients = r.clients || r.items || [];
} catch (e) { this.clients = []; }
const pid = new URLSearchParams(location.search).get('project_id') || this.save.project_id;
if (pid) this.save.project_id = String(pid);
await this.preview();
},
async loadTypes() {
try {
const r = await fetch('/api/packaging/types').then(x => x.json());
this.types = r.items || [];
} catch (e) {
this.types = [
{ id: 'folding_box', label: 'Folding box' }, { id: 'wrap', label: 'Wrap' },
{ id: 'round_label', label: 'Rond label' }, { id: 'sleeve', label: 'Sleeve' },
{ id: 'pouch', label: 'Pouch' }, { id: 'tray', label: 'Tray' }
];
}
},
payload() {
const body = JSON.parse(JSON.stringify(this.form));
if (this.save.project_id) body.project_id = parseInt(this.save.project_id, 10);
else if (this.save.project_name) {
body.project_name = this.save.project_name;
if (this.save.client_id) body.client_id = parseInt(this.save.client_id, 10);
}
return body;
},
debouncedPreview() {
clearTimeout(this.previewTimer);
this.previewTimer = setTimeout(() => this.preview(), 450);
},
async preview() {
if (this.loading) return;
this.loading = true;
try {
const r = await Cockpit.api('/api/packaging/preview', { method: 'POST', body: JSON.stringify(this.payload()) });
this.svg = r.svg || '';
} catch (e) { Cockpit.toast(e.message || 'Preview mislukt', 'error'); }
this.loading = false;
},
async save() {
if (this.loading) return;
this.loading = true;
try {
const url = this.packagingId ? '/api/packaging/' + this.packagingId : '/api/packaging/generate';
const method = this.packagingId ? 'PATCH' : 'POST';
const r = await Cockpit.api(url, { method, body: JSON.stringify(this.payload()) });
this.svg = r.svg || this.svg;
this.packagingId = r.id || this.packagingId;
this.nasInfo = r.nas || {};
await this.refreshLibrary();
await this.loadCockpitProjects();
Cockpit.toast(this.packagingId ? 'Design bijgewerkt' : 'Design opgeslagen', 'success');
} catch (e) { Cockpit.toast(e.message || 'Opslaan mislukt', 'error'); }
this.loading = false;
},
async duplicate() {
if (!this.packagingId) return;
try {
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/duplicate', { method: 'POST', body: '{}' });
await this.loadDesign(r.id);
Cockpit.toast('Design gedupliceerd', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async copyToProject() {
if (!this.packagingId || !this.copyTargetId) return Cockpit.toast('Selecteer doelproject', 'warn');
try {
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/copy-to-project', {
method: 'POST',
body: JSON.stringify({ target_project_id: parseInt(this.copyTargetId, 10) })
});
this.showCopyModal = false;
await this.loadDesign(r.id);
Cockpit.toast('Gekopieerd naar project', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async sendEmail() {
if (!this.packagingId) return;
const to = (this.emailForm.to || '').split(',').map(s => s.trim()).filter(Boolean);
if (!to.length) return Cockpit.toast('Vul ontvanger in', 'warn');
try {
await Cockpit.api('/api/packaging/' + this.packagingId + '/email', {
method: 'POST',
body: JSON.stringify({ ...this.emailForm, to })
});
this.showEmailModal = false;
Cockpit.toast('Email verstuurd', 'success');
} catch (e) { Cockpit.toast(e.message || 'Email mislukt — check Settings → Email', 'error'); }
},
newDesign() {
this.packagingId = '';
this.nasInfo = {};
this.form.design_name = 'Nieuw design';
this.preview();
},
downloadUrl(format) {
return this.packagingId ? '/api/packaging/download/' + this.packagingId + '?format=' + format : '#';
},
async refreshLibrary() {
try {
const r = await Cockpit.api('/api/packaging/projects?limit=40');
this.library = r.items || [];
} catch (e) { this.library = []; }
},
async loadCockpitProjects() {
try {
const r = await fetch('/api/projects?limit=80').then(x => x.json());
this.cockpitProjects = r.items || [];
} catch (e) { this.cockpitProjects = []; }
},
async loadDesign(id) {
try {
const r = await Cockpit.api('/api/packaging/' + id);
const item = r.item || {};
const spec = item.spec || {};
this.packagingId = item.id;
this.svg = item.svg || '';
if (spec.type) this.form.type = spec.type;
['width_mm','height_mm','depth_mm','bleed_mm','barcode_value','qr_value','design_name'].forEach(k => {
if (spec[k] !== undefined) this.form[k] = spec[k];
});
this.form.brand = { ...DEFAULT_BRAND, ...(spec.brand || {}) };
this.form.text = { ...this.form.text, ...(spec.text || {}) };
this.form.elements = { ...DEFAULT_ELEMENTS, ...(spec.elements || {}) };
if (item.cockpit_project_id) this.save.project_id = String(item.cockpit_project_id);
Cockpit.toast('Design geladen', 'info');
} catch (e) { Cockpit.toast('Laden mislukt', 'error'); }
}
};
}
</script>
{% endblock %}