382 lines
18 KiB
HTML
382 lines
18 KiB
HTML
{% 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 & 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 & 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 & 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 & 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 & 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 %}
|