Visueller Visitenkarten-Editor (SPA)

- CardTemplateEditorController: GET|PUT /api/companies/{id}/card-template
  (gespeicherte oder Standardvorlage, Mandantenprüfung, Upsert)
- CardPdfRenderer: freie Hex-Farben unterstützt
- CardEditorView: Canvas im mm-Maßstab mit Beschnitt/Endformat/Sicherheit,
  Drag&Drop-Elemente (Feld/Text/QR/Logo/Fläche/Linie), Eigenschaften-Panel
  (Datenbindung, Position/Größe, Schrift, Ausrichtung, Farbe), Vorder-/Rück-
  seite, Live-Vorschau mit echten Daten, Speichern + PDF-Vorschau
- Nav-Eintrag "Visitenkarten" + Route
- Panel-Layout-Fix (min-width:0 gegen Grid-Überlauf)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-05-31 15:45:30 +02:00
parent 408b37a5ea
commit 1a035d6c61
6 changed files with 448 additions and 2 deletions

View File

@ -103,5 +103,11 @@ reseller@demo.de / reseller).
`GET /api/employees/{id}/card.pdf`. Layout via `CardTemplate` (Standardvorlage `GET /api/employees/{id}/card.pdf`. Layout via `CardTemplate` (Standardvorlage
greift Firmen-Branding + QR ab). Siehe `docs/KONZEPT.md` §13. greift Firmen-Branding + QR ab). Siehe `docs/KONZEPT.md` §13.
Nächster Schritt: visueller Karten-Editor, PDF/X-1a-Finishing (Ghostscript), **Visueller Karten-Editor** (SPA, Menü „Visitenkarten"): Canvas im mm-Maßstab
dann Wallet-Pässe (§12). Siehe `docs/KONZEPT.md` §9. mit Beschnitt/Endformat/Sicherheits-Markierung, Elemente per Drag&Drop
(Feld/Text/QR/Logo/Fläche/Linie), Eigenschaften-Panel (Position/Größe/Schrift/
Farbe/Datenbindung), Vorder-/Rückseite, Live-Vorschau mit echten Daten,
Speichern + PDF-Vorschau. Backend: `GET|PUT /api/companies/{id}/card-template`.
Nächster Schritt: PDF/X-1a-Finishing (Ghostscript) + Font-Embedding,
Sammel-PDF/Druckbogen, dann Wallet-Pässe (§12). Siehe `docs/KONZEPT.md` §9.

View File

@ -0,0 +1,100 @@
<?php
namespace App\Controller;
use App\Entity\CardTemplate;
use App\Entity\Company;
use App\Repository\CardTemplateRepository;
use App\Security\TenantContext;
use App\Service\CardTemplateFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Lädt/speichert die Visitenkarten-Vorlage einer Firma für den visuellen Editor.
* Gibt falls noch keine Vorlage existiert die Standardvorlage zurück.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class CardTemplateEditorController
{
public function __construct(
private readonly CardTemplateRepository $templates,
private readonly CardTemplateFactory $factory,
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/companies/{id}/card-template', name: 'company_card_template_get', methods: ['GET'])]
public function get(string $id): JsonResponse
{
$company = $this->company($id);
$template = $this->templates->findCardForCompany($company);
return new JsonResponse($this->serialize($template ?? $this->factory->default(), null === $template));
}
#[Route('/api/companies/{id}/card-template', name: 'company_card_template_put', methods: ['PUT'])]
public function put(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$data = json_decode($request->getContent(), true) ?? [];
$template = $this->templates->findCardForCompany($company) ?? (new CardTemplate())->setCompany($company);
$template
->setName((string) ($data['name'] ?? 'Standard'))
->setWidthMm((float) ($data['widthMm'] ?? 85))
->setHeightMm((float) ($data['heightMm'] ?? 55))
->setBleedMm((float) ($data['bleedMm'] ?? 2))
->setSafeMm((float) ($data['safeMm'] ?? 4))
->setFront(is_array($data['front'] ?? null) ? $data['front'] : [])
->setBack(is_array($data['back'] ?? null) ? $data['back'] : []);
$this->em->persist($template);
$this->em->flush();
return new JsonResponse($this->serialize($template, false));
}
private function company(string $id): Company
{
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($id));
if (!$company instanceof Company) {
throw new NotFoundHttpException('Firma nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin()) {
return $company;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
return $company;
}
private function serialize(CardTemplate $t, bool $isDefault): array
{
return [
'id' => $isDefault ? null : (string) $t->getId(),
'isDefault' => $isDefault,
'name' => $t->getName(),
'widthMm' => $t->getWidthMm(),
'heightMm' => $t->getHeightMm(),
'bleedMm' => $t->getBleedMm(),
'safeMm' => $t->getSafeMm(),
'front' => $t->getFront(),
'back' => $t->getBack(),
];
}
}

View File

@ -137,6 +137,9 @@ final class CardPdfRenderer
return $this->hexToCmyk($hex); return $this->hexToCmyk($hex);
} }
if (is_array($color) && isset($color['hex'])) {
return $this->hexToCmyk((string) $color['hex']);
}
if (is_array($color) && isset($color['c'])) { if (is_array($color) && isset($color['c'])) {
return [(float) $color['c'], (float) $color['m'], (float) $color['y'], (float) $color['k']]; return [(float) $color['c'], (float) $color['m'], (float) $color['y'], (float) $color['k']];
} }

View File

@ -13,6 +13,7 @@ const nav = computed<NavItem[]>(() => [
{ label: 'Reseller', to: '/app/resellers', icon: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3', show: auth.isPlatformAdmin }, { label: 'Reseller', to: '/app/resellers', icon: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3', show: auth.isPlatformAdmin },
{ label: 'Firmen', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin || auth.isPlatformAdmin }, { label: 'Firmen', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin || auth.isPlatformAdmin },
{ label: 'Mitarbeiter', to: '/app/employees', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', show: true }, { label: 'Mitarbeiter', to: '/app/employees', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', show: true },
{ label: 'Visitenkarten', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: true },
{ label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: auth.isCompanyAdmin || auth.isResellerAdmin }, { label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: auth.isCompanyAdmin || auth.isResellerAdmin },
{ label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isCompanyAdmin || auth.isResellerAdmin }, { label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isCompanyAdmin || auth.isResellerAdmin },
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: true }, { label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: true },

View File

@ -19,6 +19,7 @@ const router = createRouter({
{ path: 'resellers', name: 'resellers', component: () => import('@/views/ResellersView.vue') }, { path: 'resellers', name: 'resellers', component: () => import('@/views/ResellersView.vue') },
{ path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') }, { path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') },
{ path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') }, { path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') },
{ path: 'card-editor', name: 'card-editor', component: () => import('@/views/CardEditorView.vue') },
{ path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') }, { path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') }, { path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },
{ path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') }, { path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') },

View File

@ -0,0 +1,335 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { list } from '@/api/resources'
import client from '@/api/client'
interface El { type: string; [k: string]: any }
interface Template {
id: string | null; isDefault: boolean; name: string
widthMm: number; heightMm: number; bleedMm: number; safeMm: number
front: El[]; back: El[]
}
interface Company { '@id': string; id: string; name: string; slug: string; brandingConfig: Record<string, string> | unknown[] }
interface Employee { id: string; firstName: string; lastName: string; position: string | null; email: string | null; phone: string | null; mobile: string | null; company: string; department: string | null }
const SCALE = 6 // px pro mm
const companies = ref<Company[]>([])
const selectedCompanyId = ref('')
const tpl = ref<Template | null>(null)
const side = ref<'front' | 'back'>('front')
const selectedIndex = ref<number | null>(null)
const sample = ref<Employee | null>(null)
const employees = ref<Employee[]>([])
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const selectedCompany = computed(() => companies.value.find((c) => c['@id'] === selectedCompanyId.value))
const branding = computed(() => {
const b = selectedCompany.value?.brandingConfig
return (b && !Array.isArray(b) ? b : {}) as Record<string, string>
})
const els = computed<El[]>(() => (tpl.value ? tpl.value[side.value] : []))
const selected = computed(() => (selectedIndex.value !== null ? els.value[selectedIndex.value] : null))
const BINDINGS = [
['fullName', 'Name'], ['firstName', 'Vorname'], ['lastName', 'Nachname'],
['position', 'Position'], ['department', 'Abteilung'], ['email', 'E-Mail'],
['phone', 'Telefon'], ['mobile', 'Mobil'], ['company.name', 'Firma'],
['profileUrl', 'Profil-URL'], ['shortUrl', 'Kurz-URL'],
]
function colorCss(c: any): string {
if (!c) return '#343434'
if (c.hex) return c.hex
switch (c.ref) {
case 'primary': return branding.value.primaryColor || '#f58220'
case 'dark': return branding.value.primaryDark || '#222222'
case 'white': return '#ffffff'
default: return '#343434'
}
}
function bindingValue(b: string): string {
const e = sample.value
if (!e) return `{${b}}`
const map: Record<string, string> = {
fullName: `${e.firstName} ${e.lastName}`.trim(), firstName: e.firstName, lastName: e.lastName,
position: e.position || '', department: e.department || '', email: e.email || '',
phone: e.phone || '', mobile: e.mobile || '', 'company.name': selectedCompany.value?.name || '',
profileUrl: 'profil-url', shortUrl: 'kurz-url',
}
// Leere Werte als Platzhalter zeigen, damit das Element editierbar sichtbar bleibt
return map[b] || `{${b}}`
}
function elText(el: El): string {
const v = el.type === 'field' ? bindingValue(el.binding) : (el.text ?? '')
return (el.prefix ?? '') + v
}
function elStyle(el: El): any {
const base: Record<string, string> = {
left: el.x * SCALE + 'px', top: el.y * SCALE + 'px',
}
if (el.type === 'rect') {
return { ...base, width: el.w * SCALE + 'px', height: el.h * SCALE + 'px', background: colorCss(el.fill) }
}
if (el.type === 'line') {
return { ...base, width: el.w * SCALE + 'px', height: Math.max(1, (el.lineWidth || 0.3) * SCALE) + 'px', background: colorCss(el.color) }
}
if (el.type === 'qr') {
return { ...base, width: el.w * SCALE + 'px', height: el.h * SCALE + 'px' }
}
if (el.type === 'image') {
return { ...base, width: el.w * SCALE + 'px', height: el.h * SCALE + 'px' }
}
// field / text
return {
...base, width: (el.w || 40) * SCALE + 'px',
fontSize: (el.fontSize || 9) * 0.3528 * SCALE + 'px',
fontWeight: el.bold ? '700' : '400',
textAlign: el.align === 'C' ? 'center' : el.align === 'R' ? 'right' : 'left',
color: colorCss(el.color), lineHeight: '1.1', whiteSpace: 'pre-wrap',
}
}
// --- Laden ---
async function loadTemplate() {
if (!selectedCompanyId.value) return
const cid = selectedCompany.value!.id
const { data } = await client.get<Template>(`/companies/${cid}/card-template`)
tpl.value = data
selectedIndex.value = null
sample.value = employees.value.find((e) => e.company === selectedCompanyId.value) ?? null
}
async function load() {
loading.value = true
;[companies.value, employees.value] = await Promise.all([
list<Company>('companies').then((r) => r.member),
list<Employee>('employees').then((r) => r.member).catch(() => []),
])
if (companies.value[0]) selectedCompanyId.value = companies.value[0]['@id']
await loadTemplate()
loading.value = false
}
// --- Drag ---
function startDrag(e: MouseEvent, idx: number) {
selectedIndex.value = idx
const el = els.value[idx]
const sx = e.clientX, sy = e.clientY, ox = el.x, oy = el.y
const w = tpl.value!.widthMm, h = tpl.value!.heightMm, bl = tpl.value!.bleedMm
const move = (ev: MouseEvent) => {
el.x = Math.min(w, Math.max(-bl, +(ox + (ev.clientX - sx) / SCALE).toFixed(1)))
el.y = Math.min(h, Math.max(-bl, +(oy + (ev.clientY - sy) / SCALE).toFixed(1)))
}
const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up) }
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)
e.preventDefault()
}
// --- Elemente hinzufügen / löschen ---
function add(type: string) {
const defs: Record<string, El> = {
field: { type: 'field', binding: 'fullName', x: 8, y: 8, w: 50, fontSize: 11, bold: false, align: 'L', color: { ref: 'text' } },
text: { type: 'text', text: 'Text', x: 8, y: 8, w: 50, fontSize: 9, bold: false, align: 'L', color: { ref: 'text' } },
qr: { type: 'qr', x: 8, y: 8, w: 18, h: 18 },
image: { type: 'image', binding: 'logo', x: 8, y: 8, w: 30, h: 10, align: 'L' },
rect: { type: 'rect', x: 8, y: 8, w: 30, h: 15, fill: { ref: 'primary' } },
line: { type: 'line', x: 8, y: 8, w: 25, h: 0, lineWidth: 0.5, color: { ref: 'primary' } },
}
els.value.push({ ...defs[type] })
selectedIndex.value = els.value.length - 1
}
function removeSelected() {
if (selectedIndex.value === null) return
els.value.splice(selectedIndex.value, 1)
selectedIndex.value = null
}
// --- Farben im Panel ---
const COLOR_KEY = (el: El) => (el.type === 'rect' ? 'fill' : 'color')
function colorMode(el: El): string {
const c = el[COLOR_KEY(el)]
if (c?.hex) return 'custom'
return c?.ref ?? 'text'
}
function setColorMode(el: El, val: string) {
el[COLOR_KEY(el)] = val === 'custom' ? { hex: '#000000' } : { ref: val }
}
function setColorHex(el: El, hex: string) { el[COLOR_KEY(el)] = { hex } }
// --- Speichern / Vorschau ---
async function save() {
if (!tpl.value || !selectedCompany.value) return
saving.value = true; saved.value = false
try {
const { data } = await client.put<Template>(`/companies/${selectedCompany.value.id}/card-template`, tpl.value)
tpl.value = data
saved.value = true
} finally { saving.value = false }
}
async function previewPdf() {
if (!sample.value) { alert('Kein Mitarbeiter in dieser Firma für die Vorschau.'); return }
const res = await client.get(`/employees/${sample.value.id}/card.pdf`, { responseType: 'blob' })
window.open(URL.createObjectURL(res.data as Blob), '_blank')
}
onMounted(load)
</script>
<template>
<section>
<div class="page-head">
<div><h1>Visitenkarten-Editor</h1><p class="muted">Layout pro Firma · druckfertiges PDF</p></div>
<div class="head-actions">
<select v-if="companies.length > 1" class="input" v-model="selectedCompanyId" @change="loadTemplate">
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
</select>
<button class="btn btn-ghost" @click="previewPdf">PDF-Vorschau</button>
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? 'Speichern' : 'Speichern' }}</button>
</div>
</div>
<p v-if="saved" class="ok"> Vorlage gespeichert</p>
<div v-if="loading" class="muted">Lädt</div>
<div v-else-if="tpl" class="editor">
<!-- Werkzeugleiste + Canvas -->
<div>
<div class="toolbar">
<div class="tabs">
<button :class="['tab', { active: side === 'front' }]" @click="side = 'front'; selectedIndex = null">Vorderseite</button>
<button :class="['tab', { active: side === 'back' }]" @click="side = 'back'; selectedIndex = null">Rückseite</button>
</div>
<div class="adds">
<button class="btn btn-soft btn-sm" @click="add('field')">+ Feld</button>
<button class="btn btn-soft btn-sm" @click="add('text')">+ Text</button>
<button class="btn btn-soft btn-sm" @click="add('qr')">+ QR</button>
<button class="btn btn-soft btn-sm" @click="add('image')">+ Logo</button>
<button class="btn btn-soft btn-sm" @click="add('rect')">+ Fläche</button>
<button class="btn btn-soft btn-sm" @click="add('line')">+ Linie</button>
</div>
</div>
<div class="stage">
<!-- Beschnitt-Fläche -->
<div class="bleed" :style="{ width: (tpl.widthMm + 2 * tpl.bleedMm) * SCALE + 'px', height: (tpl.heightMm + 2 * tpl.bleedMm) * SCALE + 'px' }">
<!-- Endformat (Trim) -->
<div class="trim" :style="{ left: tpl.bleedMm * SCALE + 'px', top: tpl.bleedMm * SCALE + 'px', width: tpl.widthMm * SCALE + 'px', height: tpl.heightMm * SCALE + 'px' }" @mousedown.self="selectedIndex = null">
<!-- Sicherheitsabstand -->
<div class="safe" :style="{ inset: tpl.safeMm * SCALE + 'px' }"></div>
<!-- Elemente -->
<div
v-for="(el, i) in els" :key="i"
class="el" :class="{ sel: i === selectedIndex, ['t-' + el.type]: true }"
:style="elStyle(el)"
@mousedown="startDrag($event, i)"
>
<template v-if="el.type === 'field' || el.type === 'text'">{{ elText(el) }}</template>
<div v-else-if="el.type === 'qr'" class="ph-qr">QR</div>
<img v-else-if="el.type === 'image' && branding.logoUrl" :src="branding.logoUrl" class="logo-img" />
<div v-else-if="el.type === 'image'" class="ph-logo">Logo</div>
</div>
</div>
</div>
<div class="legend muted">
<span class="lg-bleed"></span> Beschnitt
<span class="lg-trim"></span> Endformat
<span class="lg-safe"></span> Sicherheit
</div>
</div>
</div>
<!-- Eigenschaften -->
<div class="card props">
<h3>Eigenschaften</h3>
<p v-if="!selected" class="muted small">Element auswählen oder hinzufügen.</p>
<template v-if="selected">
<div class="field"><label>Typ</label><input class="input" :value="selected.type" disabled /></div>
<div class="field" v-if="selected.type === 'field'">
<label>Datenfeld</label>
<select class="input" v-model="selected.binding">
<option v-for="[v, l] in BINDINGS" :key="v" :value="v">{{ l }}</option>
</select>
</div>
<div class="field" v-if="selected.type === 'text'"><label>Text</label><input class="input" v-model="selected.text" /></div>
<div class="grid2">
<div class="field"><label>X (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.x" /></div>
<div class="field"><label>Y (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.y" /></div>
</div>
<div class="grid2" v-if="['rect','qr','image','line'].includes(selected.type)">
<div class="field"><label>Breite (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.w" /></div>
<div class="field" v-if="selected.type !== 'line'"><label>Höhe (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.h" /></div>
</div>
<template v-if="selected.type === 'field' || selected.type === 'text'">
<div class="grid2">
<div class="field"><label>Breite (mm)</label><input class="input" type="number" step="0.5" v-model.number="selected.w" /></div>
<div class="field"><label>Schrift (pt)</label><input class="input" type="number" step="0.5" v-model.number="selected.fontSize" /></div>
</div>
<div class="grid2">
<div class="field"><label>Ausrichtung</label>
<select class="input" v-model="selected.align"><option value="L">Links</option><option value="C">Mitte</option><option value="R">Rechts</option></select>
</div>
<div class="field"><label>Fett</label><select class="input" v-model="selected.bold"><option :value="false">Nein</option><option :value="true">Ja</option></select></div>
</div>
</template>
<div class="field" v-if="selected.type !== 'qr' && selected.type !== 'image'">
<label>Farbe</label>
<select class="input" :value="colorMode(selected)" @change="setColorMode(selected, ($event.target as HTMLSelectElement).value)">
<option value="primary">Primärfarbe</option>
<option value="dark">Dunkel</option>
<option value="text">Text</option>
<option value="white">Weiß</option>
<option value="custom">Eigene</option>
</select>
<input v-if="colorMode(selected) === 'custom'" type="color" class="color-pick"
:value="(selected[COLOR_KEY(selected)] && selected[COLOR_KEY(selected)].hex) || '#000000'"
@input="setColorHex(selected, ($event.target as HTMLInputElement).value)" />
</div>
<button class="btn btn-ghost btn-sm del" @click="removeSelected">Element löschen</button>
</template>
</div>
</div>
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.page-head .muted { margin: .2rem 0 0; }
.head-actions { display: flex; gap: .6rem; align-items: center; }
.ok { color: var(--success); font-weight: 600; font-size: .88rem; margin: 0 0 .8rem; }
.editor { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 1.4rem; align-items: start; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
.tabs { display: flex; gap: .3rem; }
.tab { padding: .5rem .9rem; border: 1px solid var(--line); background: #fff; border-radius: var(--radius-sm); font-weight: 600; cursor: pointer; font-size: .85rem; }
.tab.active { background: var(--dark); color: #fff; border-color: var(--dark); }
.adds { display: flex; gap: .35rem; flex-wrap: wrap; }
.stage { background: #ececec; border-radius: var(--radius); padding: 1.5rem; display: flex; flex-direction: column; align-items: center; overflow-x: auto; }
.bleed { position: relative; background: #fbe9da; box-shadow: var(--shadow-sm); }
.trim { position: absolute; background: #fff; overflow: hidden; }
.safe { position: absolute; border: 1px dashed #c9c9c9; pointer-events: none; }
.el { position: absolute; cursor: move; user-select: none; }
.el.sel { outline: 2px solid var(--psc-orange); outline-offset: 1px; }
.t-field, .t-text { overflow: hidden; }
.ph-qr { width: 100%; height: 100%; background: repeating-conic-gradient(#222 0% 25%, #fff 0% 50%) 50% / 6px 6px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 9px; font-weight: 700; }
.ph-logo { width: 100%; height: 100%; border: 1px dashed #bbb; display: flex; align-items: center; justify-content: center; color: #999; font-size: 10px; }
.logo-img { width: 100%; height: 100%; object-fit: contain; }
.legend { display: flex; gap: .8rem; align-items: center; margin-top: 1rem; font-size: .75rem; }
.legend span:first-child, .legend span { display: inline-block; }
.lg-bleed, .lg-trim, .lg-safe { width: 14px; height: 10px; border-radius: 2px; vertical-align: middle; margin-right: -.4rem; }
.lg-bleed { background: #fbe9da; } .lg-trim { background: #fff; border: 1px solid #ccc; } .lg-safe { border: 1px dashed #c9c9c9; }
.props { padding: 1.2rem; }
.props h3 { margin-bottom: .8rem; }
.props .field { min-width: 0; }
.props :deep(.input) { width: 100%; min-width: 0; }
.small { font-size: .82rem; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
.grid2 > .field { min-width: 0; }
.color-pick { margin-top: .4rem; width: 100%; height: 34px; border: 1px solid #e0e0e0; border-radius: 8px; cursor: pointer; }
.del { margin-top: .8rem; color: var(--danger); }
</style>