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:
parent
408b37a5ea
commit
1a035d6c61
10
README.md
10
README.md
@ -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.
|
||||||
|
|||||||
100
backend/src/Controller/CardTemplateEditorController.php
Normal file
100
backend/src/Controller/CardTemplateEditorController.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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']];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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') },
|
||||||
|
|||||||
335
frontend/src/views/CardEditorView.vue
Normal file
335
frontend/src/views/CardEditorView.vue
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user