Import aus Monorepo (frischer Start)
This commit is contained in:
57
.env.example
Normal file
57
.env.example
Normal file
@@ -0,0 +1,57 @@
|
||||
# Directus – kopiere zu .env und fülle die Werte aus. .env NICHT committen.
|
||||
|
||||
# ── Postgres ────────────────────────────────────────────────
|
||||
DB_DATABASE=skrift
|
||||
DB_USER=directus
|
||||
DB_PASSWORD=CHANGE-ME-strong-db-password
|
||||
|
||||
# ── Directus ────────────────────────────────────────────────
|
||||
# SECRET: langer Zufallswert, z. B. openssl rand -hex 32
|
||||
DIRECTUS_SECRET=CHANGE-ME-64-hex-random
|
||||
PUBLIC_URL=https://admin.skrift.de
|
||||
|
||||
# Erster Admin (nur beim ersten Start relevant)
|
||||
ADMIN_EMAIL=admin@skrift.de
|
||||
ADMIN_PASSWORD=CHANGE-ME-strong-admin-password
|
||||
|
||||
# CORS: URL des Frontends (Portal/Konfigurator). Kommagetrennt möglich.
|
||||
CORS_ORIGIN=https://app.skrift.de
|
||||
|
||||
# ── E-Mail (SMTP) — für passwortlosen Login-Code + Benachrichtigungen ──
|
||||
# WICHTIG: PORT und SECURE müssen zusammenpassen, sonst schlägt die Verbindung
|
||||
# mit "SSL routines: wrong version number" fehl:
|
||||
# Port 587 → STARTTLS → EMAIL_SMTP_SECURE=false
|
||||
# Port 465 → implizites TLS → EMAIL_SMTP_SECURE=true
|
||||
EMAIL_FROM=noreply@skrift.de
|
||||
EMAIL_SMTP_HOST=
|
||||
EMAIL_SMTP_PORT=587
|
||||
EMAIL_SMTP_USER=
|
||||
EMAIL_SMTP_PASSWORD=
|
||||
EMAIL_SMTP_SECURE=false
|
||||
|
||||
# Nur falls der Mailserver ein selbstsigniertes Zertifikat nutzt:
|
||||
# EMAIL_SMTP_TLS_REJECT_UNAUTHORIZED=false
|
||||
|
||||
# ── Skrift-Backend (Vorschau + finaler Auftrag) ─────────────
|
||||
# Intern über das gemeinsame npm-Netz erreichbar (container_name).
|
||||
SKRIFT_BACKEND_URL=http://skrift-backend:4000
|
||||
# MUSS exakt dem API_TOKEN aus Docker/skrift-backend/.env entsprechen,
|
||||
# sonst antwortet das Backend mit 401 Unauthorized.
|
||||
SKRIFT_BACKEND_TOKEN=CHANGE-ME-gleich-wie-backend-API_TOKEN
|
||||
# Vorschauen pro Kennung und Tag
|
||||
SKRIFT_PREVIEW_LIMIT=10
|
||||
# Zieladresse für Angebots-/Musteranfragen
|
||||
SKRIFT_TEAM_EMAIL=hello@skrift.de
|
||||
# Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*).
|
||||
# Frei wählbarer langer Zufallswert; muss identisch als ?token=... in der
|
||||
# Webhook-URL stehen. Ohne diesen Wert lehnt der Webhook alle Anfragen ab.
|
||||
SKRIFT_WEBHOOK_TOKEN=CHANGE-ME-langer-zufallswert
|
||||
# Signatur-Plotter-Service (intern über das npm-Netz). Default passt zum container_name.
|
||||
SKRIFT_SIGN_URL=http://skrift-signature-service:8000
|
||||
|
||||
# ── PayPal (server-seitige Zahlungsprüfung) ─────────────────
|
||||
# Client-ID ist öffentlich (auch im Frontend), das Secret bleibt NUR hier.
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_CLIENT_SECRET=
|
||||
# sandbox oder live
|
||||
PAYPAL_ENV=sandbox
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
52
bootstrap/README.md
Normal file
52
bootstrap/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Directus Bootstrap
|
||||
|
||||
Legt das Skrift-Datenmodell (Collections, Beziehungen, Rollen/Rechte, Seed) idempotent in einer laufenden Directus-Instanz an. Mehrfach ausführbar – Vorhandenes wird übersprungen.
|
||||
|
||||
## Voraussetzungen
|
||||
- Directus läuft (siehe `../docker-compose.yml`) und ist erreichbar.
|
||||
- Node ≥ 18 (kein `npm install` nötig – nutzt nur Bordmittel).
|
||||
- Ein Admin-Zugang: entweder ein **statischer Admin-Token** oder Admin-E-Mail/Passwort.
|
||||
|
||||
## Ausführen
|
||||
|
||||
Mit Token (empfohlen – Token im Directus-Admin unter dem Admin-Nutzer erzeugen):
|
||||
|
||||
```bash
|
||||
DIRECTUS_URL=https://admin.skrift.de \
|
||||
DIRECTUS_TOKEN=<admin-static-token> \
|
||||
node bootstrap.mjs
|
||||
```
|
||||
|
||||
Alternativ mit Login:
|
||||
|
||||
```bash
|
||||
DIRECTUS_URL=https://admin.skrift.de \
|
||||
ADMIN_EMAIL=admin@skrift.de \
|
||||
ADMIN_PASSWORD=... \
|
||||
node bootstrap.mjs
|
||||
```
|
||||
|
||||
Nur Schema + Seed, ohne Rollen/Rechte (zum stufenweisen Testen):
|
||||
|
||||
```bash
|
||||
SKIP_PERMISSIONS=1 DIRECTUS_URL=... DIRECTUS_TOKEN=... node bootstrap.mjs
|
||||
```
|
||||
|
||||
## Was es anlegt
|
||||
- **Collections:** customers, products, formats, motifs, price_items, pricing_settings,
|
||||
vouchers, voucher_redemptions, orders, order_entries, order_addons, jobs, status_history.
|
||||
- **Rollen/Policies:** Staff (volle Verwaltung), Customer (nur Eigenes + Katalog lesen),
|
||||
Public (aktive Kataloge lesen), Service (Backend/Produktion: lesen + Status schreiben).
|
||||
- **Seed:** Produkte (inkl. Kontakt-Produkte), Formate, `price_items` mit den
|
||||
Konfigurator-Preisen, `pricing_settings` inkl. Zeichen-Whitelist (1:1 aus job-manager).
|
||||
|
||||
## Nach dem Lauf – manuell
|
||||
- **Service-Token:** Nutzer in Rolle *Service* anlegen, statischen Token erzeugen und in
|
||||
die `.env` von skrift-backend und skrift-produktion eintragen.
|
||||
- **Preise prüfen:** `price_items` mit Preis `0` (Schreib-/Gestaltungsservice, Motiv-Upload)
|
||||
und die Produkt-Basispreise im Admin auf die echten Werte setzen.
|
||||
- **Preisformel:** `pricing_settings.formula` ist ein Platzhalter – hier die reale Formel hinterlegen.
|
||||
|
||||
> Hinweis: Das Skript zielt auf Directus 11 (Policy-Rechtemodell) und wurde nicht gegen
|
||||
> eine Live-Instanz getestet. Falls beim Lauf ein Schritt fehlschlägt, die Konsolenausgabe
|
||||
> schicken – dann wird die betroffene Stelle gezielt korrigiert.
|
||||
1290
bootstrap/bootstrap.mjs
Normal file
1290
bootstrap/bootstrap.mjs
Normal file
File diff suppressed because it is too large
Load Diff
13
bootstrap/package.json
Normal file
13
bootstrap/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "skrift-directus-bootstrap",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Idempotentes Anlegen von Collections, Rollen, Rechten und Seed-Daten in Directus.",
|
||||
"scripts": {
|
||||
"bootstrap": "node bootstrap.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
108
docker-compose.yml
Normal file
108
docker-compose.yml
Normal file
@@ -0,0 +1,108 @@
|
||||
# Directus + Postgres — Daten-/Auth-/Admin-Schicht für Skrift.
|
||||
#
|
||||
# Hinter dem Nginx Proxy Manager (kein Host-Port; NPM proxyt intern per container_name).
|
||||
# Im NPM als Proxy Host anlegen:
|
||||
# Domain Names : admin.skrift.de (PUBLIC_URL in .env muss dazu passen)
|
||||
# Scheme : http
|
||||
# Forward Hostname : skrift-directus
|
||||
# Forward Port : 8055
|
||||
# SSL : Let's-Encrypt-Zertifikat aktivieren
|
||||
#
|
||||
# Start: cp .env.example .env → Werte setzen → docker compose up -d
|
||||
|
||||
services:
|
||||
database:
|
||||
image: postgres:16-alpine
|
||||
container_name: skrift-directus-db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- directus-db:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_DATABASE}
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "${DB_USER}", "-d", "${DB_DATABASE}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
directus:
|
||||
image: directus/directus:11
|
||||
container_name: skrift-directus
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- directus-uploads:/directus/uploads
|
||||
# Bind-Mount: Extensions kommen aus dem Repo-Ordner ./extensions
|
||||
- ./extensions:/directus/extensions
|
||||
# Generierte Auftragsdateien read-only einblenden (gleicher Ordner wie
|
||||
# das Backend beschreibt / SFTP-Ziel) – für Direktzugriff statt Kopie.
|
||||
- /var/skrift-output:/var/skrift-output:ro
|
||||
environment:
|
||||
SECRET: ${DIRECTUS_SECRET}
|
||||
|
||||
DB_CLIENT: pg
|
||||
DB_HOST: database
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: ${DB_DATABASE}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
|
||||
# Erster Admin – wird nur beim allerersten Start angelegt
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
|
||||
PUBLIC_URL: ${PUBLIC_URL}
|
||||
|
||||
# CORS für das Frontend (Portal/Konfigurator)
|
||||
CORS_ENABLED: "true"
|
||||
CORS_ORIGIN: ${CORS_ORIGIN}
|
||||
|
||||
# E-Mail: für passwortlosen Login-Code + Benachrichtigungen (Flows)
|
||||
EMAIL_FROM: ${EMAIL_FROM}
|
||||
EMAIL_TRANSPORT: smtp
|
||||
EMAIL_SMTP_HOST: ${EMAIL_SMTP_HOST}
|
||||
EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT}
|
||||
EMAIL_SMTP_USER: ${EMAIL_SMTP_USER}
|
||||
EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD}
|
||||
EMAIL_SMTP_SECURE: ${EMAIL_SMTP_SECURE}
|
||||
|
||||
# Skrift-Backend (SVG-Generierung: Vorschau + finaler Auftrag).
|
||||
# SKRIFT_BACKEND_TOKEN MUSS dem API_TOKEN des Backends entsprechen,
|
||||
# sonst antwortet das Backend mit 401.
|
||||
SKRIFT_BACKEND_URL: ${SKRIFT_BACKEND_URL:-http://skrift-backend:4000}
|
||||
SKRIFT_BACKEND_TOKEN: ${SKRIFT_BACKEND_TOKEN}
|
||||
SKRIFT_PREVIEW_LIMIT: ${SKRIFT_PREVIEW_LIMIT:-10}
|
||||
SKRIFT_TEAM_EMAIL: ${SKRIFT_TEAM_EMAIL:-hello@skrift.de}
|
||||
|
||||
# Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*).
|
||||
# Muss identisch als ?token=... in der Webhook-URL stehen.
|
||||
SKRIFT_WEBHOOK_TOKEN: ${SKRIFT_WEBHOOK_TOKEN}
|
||||
|
||||
# Signatur-Plotter-Service (Vorschau/Render im Unterschriftenservice-Modul).
|
||||
SKRIFT_SIGN_URL: ${SKRIFT_SIGN_URL:-http://skrift-signature-service:8000}
|
||||
|
||||
# PayPal – server-seitige Zahlungsprüfung (Secret liegt NUR hier, nie im Frontend)
|
||||
PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID}
|
||||
PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET}
|
||||
PAYPAL_ENV: ${PAYPAL_ENV:-sandbox}
|
||||
expose:
|
||||
- "8055"
|
||||
networks:
|
||||
- internal
|
||||
- npm
|
||||
|
||||
volumes:
|
||||
directus-db:
|
||||
directus-uploads:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
npm:
|
||||
name: nginx-proxy-manager_default
|
||||
external: true
|
||||
572
extensions/directus-extension-skrift-api/dist/index.js
vendored
Normal file
572
extensions/directus-extension-skrift-api/dist/index.js
vendored
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Skrift – Public-API (v1) für Kunden
|
||||
* ===========================================================================
|
||||
* Eigener, versionierter API-Namespace, gedacht hinter einer eigenen Domain
|
||||
* (z. B. api.skrift.de → NPM proxyt auf /skrift-api/*). Der Kunde sieht NIE das
|
||||
* Directus-Dashboard.
|
||||
*
|
||||
* POST /v1/orders – Auftrag anlegen (Brief/Postkarte: Text ODER Datei)
|
||||
* GET /v1/orders/:nummer – Status abfragen (wartend | in_bearbeitung | versendet)
|
||||
* POST /v1/files – Druckdatei hochladen (Base64) → file_id
|
||||
* GET /v1/openapi.json – OpenAPI-Spezifikation
|
||||
* GET /docs – interaktive Doku (Scalar)
|
||||
* POST /v1/clients – (Admin) neuen API-Zugang + Key erzeugen
|
||||
*
|
||||
* Auth: Header `X-Api-Key: <key>` (NICHT Authorization – den reserviert Directus).
|
||||
* Gespeichert wird nur der SHA-256-Hash (api_clients). Jeder Zugang ist an GENAU
|
||||
* EIN Produkt gebunden.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Verzeichnis dieser Extension (für die selbst gehosteten Swagger-UI-Assets).
|
||||
const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, '');
|
||||
const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || '';
|
||||
const STORAGE = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
|
||||
|
||||
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
||||
const pad3 = (n) => String(n).padStart(3, '0');
|
||||
const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'];
|
||||
|
||||
/**
|
||||
* Internal order → public status (pending | processing | shipped | cancelled).
|
||||
* Der Betreiber steuert im Produktions-Board `production_status`; der kaufmännische
|
||||
* `status` dient als Rückfall. Beide werden berücksichtigt.
|
||||
*/
|
||||
function kundenStatus(order) {
|
||||
const ps = String(order?.production_status || '');
|
||||
const s = String(order?.status || '');
|
||||
if (ps === 'storniert' || s === 'storniert') return 'cancelled';
|
||||
if (ps === 'versendet' || s === 'versendet' || s === 'abgeschlossen') return 'shipped';
|
||||
if (['im_druck', 'kuvertieren', 'versandfertig'].includes(ps) || ['in_produktion', 'gedruckt'].includes(s)) return 'processing';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
// Selbst ausgelieferte HTML-Doku (kein externes Script → CSP-sicher). Inline-CSS.
|
||||
const DOCS_HTML = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Skrift API Reference</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font:16px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;background:#f6f8fb}
|
||||
.wrap{max-width:900px;margin:0 auto;padding:32px 20px 80px}
|
||||
h1{font-size:30px;margin:0 0 4px}
|
||||
h2{font-size:22px;margin:40px 0 12px;padding-top:14px;border-top:1px solid #e2e8f0}
|
||||
h3{font-size:15px;margin:18px 0 6px}
|
||||
code{background:#eef2f7;padding:2px 6px;border-radius:5px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:14px}
|
||||
pre{background:#0f172a;color:#e6edf3;padding:16px;border-radius:10px;overflow:auto;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:1.5}
|
||||
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:18px 20px;margin:14px 0}
|
||||
.ep{display:flex;align-items:center;gap:10px;font-family:ui-monospace,monospace;font-size:15px;margin-bottom:6px}
|
||||
.m{font-weight:700;color:#fff;border-radius:6px;padding:2px 8px;font-size:12px}
|
||||
.post{background:#16a34a}.get{background:#2563eb}
|
||||
table{border-collapse:collapse;width:100%;margin:8px 0;font-size:14px}
|
||||
th,td{text-align:left;border-bottom:1px solid #e2e8f0;padding:7px 8px;vertical-align:top}
|
||||
th{color:#64748b;font-weight:600}
|
||||
.muted{color:#64748b}
|
||||
.tag{display:inline-block;background:#eef2f7;border-radius:6px;padding:1px 8px;font-size:13px;margin:0 6px 4px 0}
|
||||
a{color:#1E50A5}
|
||||
</style></head>
|
||||
<body><div class="wrap">
|
||||
<h1>Skrift API Reference</h1>
|
||||
<p class="muted">Handwritten mail as a service · API v1 · Base URL <code>https://dev.skrift.de</code></p>
|
||||
|
||||
<h2>Overview</h2>
|
||||
<div class="card">
|
||||
<p>REST API over HTTPS. Request and response bodies are JSON (<code>Content-Type: application/json</code>, UTF-8). An order yields one document and/or one envelope per recipient; the <b>quantity is derived from the number of recipients</b>. One or more products are bound to your API key; each order selects one via the <code>product</code> field.</p>
|
||||
</div>
|
||||
|
||||
<h2>Authentication</h2>
|
||||
<div class="card">
|
||||
<p>Every request must include your API key in the <code>X-Api-Key</code> header:</p>
|
||||
<pre>X-Api-Key: <API_KEY></pre>
|
||||
<p class="muted">Each key is scoped to exactly one product. Keys are secrets — never expose them in client-side code.</p>
|
||||
</div>
|
||||
|
||||
<h2>Errors</h2>
|
||||
<div class="card">
|
||||
<p>Errors use standard HTTP status codes and a uniform body:</p>
|
||||
<pre>{ "error": "human-readable message" }</pre>
|
||||
<table>
|
||||
<tr><th>Status</th><th>Meaning</th></tr>
|
||||
<tr><td>200 / 201</td><td>Success / order created</td></tr>
|
||||
<tr><td>400</td><td>Invalid request (missing or invalid fields)</td></tr>
|
||||
<tr><td>401</td><td>Missing or invalid API key</td></tr>
|
||||
<tr><td>404</td><td>Resource not found</td></tr>
|
||||
<tr><td>413</td><td>Payload too large (file > 80 MB)</td></tr>
|
||||
<tr><td>415</td><td>Unsupported media type (only PDF is accepted)</td></tr>
|
||||
<tr><td>500</td><td>Internal error</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Order status</h2>
|
||||
<div class="card">
|
||||
<p>The <code>status</code> field returns one of:</p>
|
||||
<span class="tag">pending</span> received, not yet in production
|
||||
<span class="tag">processing</span> being written / printed
|
||||
<span class="tag">shipped</span> dispatched
|
||||
<span class="tag">cancelled</span> cancelled
|
||||
</div>
|
||||
|
||||
<h2>Create an order</h2>
|
||||
<div class="card">
|
||||
<div class="ep"><span class="m post">POST</span> /v1/orders</div>
|
||||
<p>Creates an order and returns its order number.</p>
|
||||
<h3>Request body</h3>
|
||||
<table>
|
||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
||||
<tr><td>product</td><td>string</td><td>Product key to use. Required if your key allows multiple products; optional (auto) if exactly one.</td></tr>
|
||||
<tr><td>recipients</td><td>array</td><td><b>Required.</b> One entry per recipient; count = quantity. Address as <code>free_text</code> (up to 5 lines, separated by <code>\\n</code>).</td></tr>
|
||||
<tr><td>document_format</td><td>string</td><td>Document format: <code>a4</code>, <code>a6h</code> or <code>a6l</code>.</td></tr>
|
||||
<tr><td>font</td><td>string</td><td><code>tilda</code>, <code>alva</code> or <code>ellie</code>.</td></tr>
|
||||
<tr><td>realistic</td><td>boolean</td><td>Natural handwriting variation. Default <code>true</code>.</td></tr>
|
||||
<tr><td>shipping_type</td><td>string</td><td><code>bulk</code> (consolidated shipment to you; default) or <code>single</code> (direct to each recipient).</td></tr>
|
||||
<tr><td>shipping_day</td><td>string</td><td>Preferred dispatch weekday, e.g. <code>montag</code> (optional).</td></tr>
|
||||
<tr><td>letter.text</td><td>string</td><td>Letter body, <b>fully written out — no placeholders</b>. Long text flows across multiple pages automatically; <code>[[seitenumbruch]]</code> forces a page break.</td></tr>
|
||||
<tr><td>letter.files</td><td>array</td><td><code>file_id</code>(s) from <code>POST /v1/files</code> (file / PDF products).</td></tr>
|
||||
<tr><td>envelope.mode</td><td>string</td><td><code>none</code> (no envelope) or <code>recipient</code> (recipient address on the envelope). Envelope size is selected automatically: A4 → DIN Lang, otherwise C6.</td></tr>
|
||||
</table>
|
||||
<h3>Example — text letter</h3>
|
||||
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
|
||||
-H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"product":"briefe","document_format":"a4","font":"tilda","realistic":true,
|
||||
"shipping_type":"bulk","shipping_day":"montag",
|
||||
"letter":{"text":"Dear Anna,\\n\\nthank you for ..."},
|
||||
"envelope":{"mode":"recipient"},
|
||||
"recipients":[
|
||||
{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}
|
||||
]
|
||||
}'</pre>
|
||||
<h3>Example — print uploaded PDFs (file product)</h3>
|
||||
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
|
||||
-H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
|
||||
-d '{"document_format":"a4","letter":{"files":["<file_id>"]},
|
||||
"envelope":{"mode":"recipient"},
|
||||
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
|
||||
<h3>Example — address envelopes only</h3>
|
||||
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
|
||||
-H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
|
||||
-d '{"envelope":{"mode":"recipient"},
|
||||
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
|
||||
<h3>Response <span class="muted">· 201</span></h3>
|
||||
<pre>{ "order_number": "18-08-26-001", "status": "pending" }</pre>
|
||||
</div>
|
||||
|
||||
<h2>Retrieve order status</h2>
|
||||
<div class="card">
|
||||
<div class="ep"><span class="m get">GET</span> /v1/orders/{order_number}</div>
|
||||
<p>Returns the current status of one of your orders. <code>status_since</code> is the ISO 8601 timestamp (UTC) since which the order has been in its current status.</p>
|
||||
<pre>curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: <KEY>"
|
||||
|
||||
{ "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }</pre>
|
||||
</div>
|
||||
|
||||
<h2>Upload a PDF</h2>
|
||||
<div class="card">
|
||||
<div class="ep"><span class="m post">POST</span> /v1/files</div>
|
||||
<p>Uploads a base64-encoded print file. <b>PDF only</b>, max. 80 MB. Returns a <code>file_id</code> to reference in <code>letter.files</code>.</p>
|
||||
<pre>curl -X POST https://dev.skrift.de/v1/files \\
|
||||
-H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
|
||||
-d '{"filename":"letter.pdf","content_base64":"<BASE64>"}'
|
||||
|
||||
{ "file_id": "..." }</pre>
|
||||
</div>
|
||||
|
||||
<p class="muted" style="margin-top:30px">Machine-readable specification: <a href="v1/openapi.json">OpenAPI (JSON)</a> — import into Postman, Insomnia, or any OpenAPI client.</p>
|
||||
</div></body></html>`;
|
||||
|
||||
// Swagger UI (selbst gehostet). Assets liegen als swagger-ui.css + swagger-ui-bundle.js
|
||||
// im Extension-Ordner; init.js wird generiert. Kein CDN → CSP-/Brave-sicher.
|
||||
const SWAGGER_HTML = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Skrift API Reference</title>
|
||||
<link rel="stylesheet" href="docs-assets/swagger-ui.css">
|
||||
<style>body{margin:0;background:#fafafa}.swagger-ui .topbar{display:none}</style>
|
||||
</head><body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="docs-assets/swagger-ui-bundle.js"></script>
|
||||
<script src="docs-assets/init.js"></script>
|
||||
</body></html>`;
|
||||
|
||||
const INIT_JS = "window.addEventListener('DOMContentLoaded',function(){window.ui=SwaggerUIBundle({url:'v1/openapi.json',dom_id:'#swagger-ui',deepLinking:true,presets:[SwaggerUIBundle.presets.apis],layout:'BaseLayout'});});";
|
||||
|
||||
const handler = (router, { services, getSchema, logger }) => {
|
||||
const { ItemsService, FilesService } = services;
|
||||
const svcFactory = (schema) => (c) => new ItemsService(c, { schema, accountability: null });
|
||||
|
||||
/** Zeichen-Whitelist (wie im übrigen System) als Sanitizer laden. */
|
||||
async function ladeClean(schema) {
|
||||
try {
|
||||
const ps = await new ItemsService('pricing_settings', { schema, accountability: null })
|
||||
.readSingleton({ fields: ['char_whitelist'] });
|
||||
const wl = ps?.char_whitelist;
|
||||
const re = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null;
|
||||
return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v);
|
||||
} catch { return (v) => v; }
|
||||
}
|
||||
|
||||
/** API-Key aus dem X-Api-Key-Header lesen und den aktiven Zugang finden.
|
||||
* (NICHT Authorization: Bearer – den reserviert Directus für eigene Tokens
|
||||
* und lehnt fremde Werte global mit INVALID_CREDENTIALS ab.) */
|
||||
async function authClient(req) {
|
||||
const raw = String(req.headers['x-api-key'] || '').trim();
|
||||
if (!raw) return null;
|
||||
const schema = await getSchema();
|
||||
const rows = await new ItemsService('api_clients', { schema, accountability: null }).readByQuery({
|
||||
filter: { key_hash: { _eq: sha256(raw) }, active: { _eq: true } },
|
||||
limit: 1,
|
||||
fields: ['id', 'name', 'customer', 'webhook_url', 'config',
|
||||
'product.id', 'product.key', 'product.type', 'product.input_mode',
|
||||
'products.product.id', 'products.product.key', 'products.product.type', 'products.product.input_mode'],
|
||||
});
|
||||
const client = rows?.[0] || null;
|
||||
if (client) {
|
||||
// Letzte Nutzung vermerken (best effort, nicht blockierend).
|
||||
new ItemsService('api_clients', { schema, accountability: null })
|
||||
.updateOne(client.id, { last_used: new Date().toISOString() }).catch(() => {});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
const fehler = (res, code, msg) => res.status(code).json({ error: msg });
|
||||
|
||||
// ── POST /v1/files ─────────────────────────────────────────────────────────
|
||||
// Body: { filename, content_base64 } → { file_id }. NUR PDF (Sicherheit).
|
||||
router.post('/v1/files', async (req, res) => {
|
||||
const client = await authClient(req);
|
||||
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
|
||||
const { filename, content_base64 } = req.body || {};
|
||||
if (!filename || !content_base64) return fehler(res, 400, 'filename and content_base64 are required.');
|
||||
if (!/\.pdf$/i.test(String(filename))) return fehler(res, 415, 'Only PDF files are accepted (filename must end in .pdf).');
|
||||
let buf;
|
||||
try { buf = Buffer.from(String(content_base64), 'base64'); } catch { return fehler(res, 400, 'content_base64 is not valid base64.'); }
|
||||
if (!buf.length) return fehler(res, 400, 'File is empty.');
|
||||
if (buf.length > 80 * 1024 * 1024) return fehler(res, 413, 'File too large (max. 80 MB).');
|
||||
// Inhalts-Prüfung: echte PDF-Signatur, nicht nur die Endung.
|
||||
if (buf.slice(0, 5).toString('latin1') !== '%PDF-') return fehler(res, 415, 'File is not a valid PDF.');
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const filesSvc = new FilesService({ schema, accountability: null });
|
||||
const id = await filesSvc.uploadOne(Readable.from(buf), {
|
||||
storage: STORAGE,
|
||||
filename_download: String(filename),
|
||||
title: String(filename),
|
||||
type: 'application/pdf',
|
||||
});
|
||||
return res.json({ file_id: id });
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-api] files: ${err.stack || err.message}`);
|
||||
return fehler(res, 500, 'File upload failed.');
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /v1/orders ─────────────────────────────────────────────────────────
|
||||
router.post('/v1/orders', async (req, res) => {
|
||||
const client = await authClient(req);
|
||||
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
|
||||
|
||||
const body = req.body || {};
|
||||
|
||||
// Erlaubte Produkte des Keys: m2m-Liste + (Fallback) Einzelprodukt, dedupliziert.
|
||||
const erlaubt = {};
|
||||
if (Array.isArray(client.products)) for (const j of client.products) if (j && j.product && j.product.id != null) erlaubt[j.product.id] = j.product;
|
||||
if (client.product && client.product.id != null) erlaubt[client.product.id] = client.product;
|
||||
const erlaubteListe = Object.values(erlaubt);
|
||||
if (!erlaubteListe.length) return fehler(res, 409, 'No product is assigned to this API key.');
|
||||
|
||||
// Produktwahl: body.product (Key) muss erlaubt sein; bei genau einem Produkt optional.
|
||||
const gewuenscht = String(body.product || '').trim();
|
||||
let product;
|
||||
if (gewuenscht) {
|
||||
product = erlaubteListe.find((p) => String(p.key) === gewuenscht);
|
||||
if (!product) return fehler(res, 400, `product "${gewuenscht}" is not allowed for this key. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
|
||||
} else if (erlaubteListe.length === 1) {
|
||||
product = erlaubteListe[0];
|
||||
} else {
|
||||
return fehler(res, 400, `Field "product" is required. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
|
||||
}
|
||||
|
||||
const recipients = Array.isArray(body.recipients) ? body.recipients : [];
|
||||
if (!recipients.length) return fehler(res, 400, 'At least one recipient is required.');
|
||||
if (recipients.length > 5000) return fehler(res, 400, 'Too many recipients (max. 5000).');
|
||||
|
||||
const istKuvertOnly = product.type === 'envelope';
|
||||
const istDatei = product.input_mode === 'datei';
|
||||
const letter = body.letter || {};
|
||||
const files = Array.isArray(letter.files) ? letter.files.filter(Boolean) : [];
|
||||
// Kuvert-only: kein Brief nötig (nur adressierte Kuverts); PDF-Dateien optional.
|
||||
if (!istKuvertOnly) {
|
||||
if (istDatei && !files.length) return fehler(res, 400, 'This product requires uploaded print files (letter.files).');
|
||||
if (!istDatei && !String(letter.text || '').trim()) return fehler(res, 400, 'letter.text is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const svc = svcFactory(schema);
|
||||
const clean = await ladeClean(schema);
|
||||
|
||||
// Schriftstückformat: nur A4 / A6H / A6L (freundliche Werte → interne Keys).
|
||||
const FORMAT_MAP = { a4: 'a4', a6h: 'a6_hoch', a6l: 'a6_quer', a6_hoch: 'a6_hoch', a6_quer: 'a6_quer' };
|
||||
const fmtKey = FORMAT_MAP[String(body.document_format || '').trim().toLowerCase()] || null;
|
||||
if (!fmtKey && !istKuvertOnly) return fehler(res, 400, 'document_format must be a4, a6h or a6l.');
|
||||
let formatId = null;
|
||||
if (fmtKey) {
|
||||
const fr = await svc('formats').readByQuery({ filter: { key: { _eq: fmtKey } }, limit: 1, fields: ['id'] });
|
||||
formatId = fr?.[0]?.id ?? null;
|
||||
if (!formatId) return fehler(res, 400, `Format ${fmtKey} is not active.`);
|
||||
}
|
||||
|
||||
// Kuvert: NUR "recipient" (Empfängeradresse) oder "none" (gar kein Kuvert).
|
||||
const envMode = String((body.envelope || {}).mode || '').toLowerCase() === 'recipient'
|
||||
? 'recipient' : (istKuvertOnly ? 'recipient' : 'none');
|
||||
// Kuvertformat automatisch aus dem Dokumentformat: A4 → DIN Lang, sonst C6.
|
||||
const envFormat = envMode === 'none' ? null : (fmtKey === 'a4' ? 'dinlang' : 'c6');
|
||||
const hatDateien = files.length > 0;
|
||||
|
||||
const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda';
|
||||
// Versandart nur Englisch: "single" (direkt an Empfänger) / "bulk" (Sammelversand an dich).
|
||||
const shippingType = String(body.shipping_type || '').toLowerCase() === 'single' ? 'einzeln' : 'sammel';
|
||||
// Gewünschter Versandtag (optional), z. B. "montag".
|
||||
const shippingDay = WOCHENTAGE.includes(String(body.shipping_day || '').toLowerCase())
|
||||
? String(body.shipping_day).toLowerCase() : null;
|
||||
|
||||
const orderData = {
|
||||
api_client: client.id,
|
||||
customer: client.customer || null,
|
||||
customer_email: (client.config && client.config.email) || null,
|
||||
product: product.id,
|
||||
format: formatId,
|
||||
person_type: 'unternehmen',
|
||||
font,
|
||||
realistic: body.realistic !== false,
|
||||
source: 'api',
|
||||
status: 'in_queue',
|
||||
production_status: 'eingegangen',
|
||||
payment_status: 'offen',
|
||||
payment_method: 'rechnung',
|
||||
shipping_type: shippingType,
|
||||
needs_envelope: envMode === 'recipient',
|
||||
envelope_labeling: envMode === 'recipient' ? 'empfaenger' : 'keine',
|
||||
envelope_text: null,
|
||||
envelope_format: envFormat,
|
||||
// Kein Brieftext bei Datei- oder Kuvert-only-Produkten.
|
||||
text_template: (istDatei || istKuvertOnly) ? null : clean(String(letter.text || '')),
|
||||
// Hochgeladene PDF-Briefe (Datei-Produkt ODER optional bei Kuvert-only).
|
||||
source_files: (istDatei || (istKuvertOnly && hatDateien)) ? files : null,
|
||||
source_file: (istDatei || (istKuvertOnly && hatDateien)) ? (files[0] || null) : null,
|
||||
entries_count: recipients.length,
|
||||
shipping_day: shippingDay,
|
||||
// Mehrseitigkeit läuft selbstständig (Auto-Fluss); nicht vom Kunden wählbar.
|
||||
multi_page: true,
|
||||
// Schriftgröße nicht vom Kunden wählbar.
|
||||
font_scale_letter: 1,
|
||||
font_scale_envelope: 1,
|
||||
};
|
||||
|
||||
// Auftragsnummer (Tages-Präfix) mit Kollisions-Wiederholung.
|
||||
const jt = new Date();
|
||||
const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
|
||||
const naechsteNummer = async () => {
|
||||
const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
|
||||
return `${praefix}-${pad3((heutige?.length ?? 0) + 1)}`;
|
||||
};
|
||||
|
||||
let orderId, orderNumber;
|
||||
for (let v = 0; v < 6; v += 1) {
|
||||
orderNumber = await naechsteNummer();
|
||||
try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
|
||||
catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
|
||||
}
|
||||
|
||||
// Empfängerzeilen – Briefnummer wird vom System vergeben.
|
||||
const entriesSvc = svc('order_entries');
|
||||
let n = 0;
|
||||
for (const e of recipients) {
|
||||
n += 1;
|
||||
await entriesSvc.createOne({
|
||||
order: orderId, letter_number: n,
|
||||
salutation: clean(e.salutation ?? null), first_name: clean(e.first_name ?? null), last_name: clean(e.last_name ?? null),
|
||||
street: clean(e.street ?? null), house_no: clean(e.house_no ?? null), zip: clean(e.zip ?? null),
|
||||
city: clean(e.city ?? null), country: clean(e.country ?? null), free_text: clean(e.free_text ?? null),
|
||||
// Keine Platzhalter über die API – der Brieftext wird 1:1 verwendet.
|
||||
placeholders: null,
|
||||
});
|
||||
}
|
||||
|
||||
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Über die Public-API angelegt.' }).catch(() => {});
|
||||
|
||||
// Generierung IMMER anstoßen (fire-and-forget) – für jedes Produkt. Das
|
||||
// Backend entscheidet, was zu erzeugen ist: Schriftstücke bei Handschrift-
|
||||
// Produkten, Kuverts immer wenn gewünscht; bei Datei-Produkten (z. B.
|
||||
// Unterschriftenservice) NUR die Kuverts. Ohne zu Erzeugendes = No-Op.
|
||||
fetch(`${BACKEND_URL}/api/order/from-directus`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||
body: JSON.stringify({ orderId }),
|
||||
}).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`));
|
||||
|
||||
return res.status(201).json({ order_number: orderNumber, status: 'pending' });
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-api] orders: ${err.stack || err.message}`);
|
||||
return fehler(res, 500, 'Order could not be created.');
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /v1/orders/:nummer ──────────────────────────────────────────────────
|
||||
router.get('/v1/orders/:nummer', async (req, res) => {
|
||||
const client = await authClient(req);
|
||||
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
|
||||
// Nur eigene Aufträge des Zugangs.
|
||||
filter: { order_number: { _eq: String(req.params.nummer) }, api_client: { _eq: client.id } },
|
||||
limit: 1, fields: ['order_number', 'status', 'production_status', 'production_status_changed_at',
|
||||
'versandt_am', 'entries_count', 'date_created'],
|
||||
});
|
||||
const order = rows?.[0];
|
||||
if (!order) return fehler(res, 404, 'Order not found.');
|
||||
const status = kundenStatus(order);
|
||||
// Seit wann der Status so ist: Zeitpunkt der letzten Produktionsstatus-
|
||||
// Änderung; bei „shipped" ersatzweise der Versandzeitpunkt, sonst Anlage.
|
||||
const statusSince = order.production_status_changed_at
|
||||
|| (status === 'shipped' ? order.versandt_am : null)
|
||||
|| order.date_created || null;
|
||||
return res.json({
|
||||
order_number: order.order_number,
|
||||
status,
|
||||
status_since: statusSince,
|
||||
recipients: order.entries_count ?? null,
|
||||
created_at: order.date_created ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-api] status: ${err.stack || err.message}`);
|
||||
return fehler(res, 500, 'Failed to retrieve order status.');
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /v1/clients (Admin) – neuen Zugang + Key erzeugen ───────────────────
|
||||
// Nur mit Directus-Admin-Token/Session. Der Klartext-Key wird EINMALIG geliefert.
|
||||
router.post('/v1/clients', async (req, res) => {
|
||||
if (!req.accountability || req.accountability.admin !== true) return fehler(res, 403, 'Administrators only.');
|
||||
const { name, product, products, customer, webhook_url } = req.body || {};
|
||||
// Mehrere Produkte (Array von IDs) bevorzugt; „product" (einzeln) als Kurzform.
|
||||
const prodIds = Array.isArray(products) ? products.filter((x) => x != null) : (product != null ? [product] : []);
|
||||
if (!name || !prodIds.length) return fehler(res, 400, 'name and products (array of product IDs) are required.');
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
|
||||
const id = await new ItemsService('api_clients', { schema, accountability: null }).createOne({
|
||||
name: String(name), products: prodIds.map((pid) => ({ product: pid })),
|
||||
customer: customer || null, webhook_url: webhook_url || null,
|
||||
key_hash: sha256(key), key_prefix: key.slice(0, 14), active: true,
|
||||
});
|
||||
// Klartext-Key nur JETZT – wird nirgends gespeichert.
|
||||
return res.status(201).json({ id, name, api_key: key, note: 'Store this key securely — it will not be shown again.' });
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-api] clients: ${err.stack || err.message}`);
|
||||
return fehler(res, 500, 'API client could not be created.');
|
||||
}
|
||||
});
|
||||
|
||||
// ── OpenAPI + Doku ──────────────────────────────────────────────────────────
|
||||
const OPENAPI = {
|
||||
openapi: '3.0.3',
|
||||
info: { title: 'Skrift API', version: '1.0.0', description: 'Handwritten mail as a service. Authentication: API key in the X-Api-Key header. Bodies are JSON (UTF-8).' },
|
||||
servers: [{ url: 'https://dev.skrift.de' }],
|
||||
components: {
|
||||
securitySchemes: { ApiKey: { type: 'apiKey', in: 'header', name: 'X-Api-Key' } },
|
||||
schemas: {
|
||||
Recipient: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
free_text: { type: 'string', description: 'Recipient address as free text, up to 5 lines separated by \\n.' },
|
||||
salutation: { type: 'string' }, first_name: { type: 'string' }, last_name: { type: 'string' },
|
||||
street: { type: 'string' }, house_no: { type: 'string' }, zip: { type: 'string' }, city: { type: 'string' }, country: { type: 'string' },
|
||||
},
|
||||
},
|
||||
OrderRequest: {
|
||||
type: 'object', required: ['recipients'],
|
||||
properties: {
|
||||
product: { type: 'string', description: 'Product key to use. Required if the API key is bound to more than one product; optional (auto) if exactly one.' },
|
||||
document_format: { type: 'string', enum: ['a4', 'a6h', 'a6l'], description: 'Document format.' },
|
||||
font: { type: 'string', enum: ['tilda', 'alva', 'ellie'] },
|
||||
realistic: { type: 'boolean', default: true, description: 'Natural handwriting variation.' },
|
||||
shipping_type: { type: 'string', enum: ['single', 'bulk'], default: 'bulk', description: 'single = direct to each recipient; bulk = consolidated shipment to you.' },
|
||||
shipping_day: { type: 'string', enum: ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], description: 'Preferred dispatch weekday (optional).' },
|
||||
letter: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Letter body, fully written out (no placeholders). Long text flows across multiple pages automatically; [[seitenumbruch]] forces a page break.' },
|
||||
files: { type: 'array', items: { type: 'string' }, description: 'file_id(s) from POST /v1/files (file / PDF products).' },
|
||||
},
|
||||
},
|
||||
envelope: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: { type: 'string', enum: ['none', 'recipient'], default: 'none', description: 'none = no envelope; recipient = recipient address on the envelope. Envelope size is selected automatically (A4 → DIN Lang, otherwise C6).' },
|
||||
},
|
||||
},
|
||||
recipients: { type: 'array', items: { $ref: '#/components/schemas/Recipient' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ ApiKey: [] }],
|
||||
paths: {
|
||||
'/v1/orders': {
|
||||
post: {
|
||||
summary: 'Create an order', description: 'Quantity equals the number of recipients. Returns the order number.',
|
||||
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/OrderRequest' } } } },
|
||||
responses: { 201: { description: 'Order created', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string' } } } } } } },
|
||||
},
|
||||
},
|
||||
'/v1/orders/{order_number}': {
|
||||
get: {
|
||||
summary: 'Retrieve order status', parameters: [{ name: 'order_number', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] }, status_since: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp since which the order has held its current status.' }, recipients: { type: 'integer' } } } } } } },
|
||||
},
|
||||
},
|
||||
'/v1/files': {
|
||||
post: {
|
||||
summary: 'Upload a PDF (PDF only, max. 80 MB)',
|
||||
requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['filename', 'content_base64'], properties: { filename: { type: 'string', description: 'Must end in .pdf.' }, content_base64: { type: 'string', description: 'PDF as base64.' } } } } } },
|
||||
responses: { 200: { description: 'Uploaded', content: { 'application/json': { schema: { type: 'object', properties: { file_id: { type: 'string' } } } } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
router.get('/v1/openapi.json', (_req, res) => res.json(OPENAPI));
|
||||
|
||||
router.get('/docs', (_req, res) => {
|
||||
// Swagger UI, wenn die Assets im Extension-Ordner liegen; sonst die statische
|
||||
// HTML-Doku als Fallback (nie leer). Beides CSP-/Brave-sicher (kein CDN).
|
||||
let hasSwagger = false;
|
||||
try { hasSwagger = fs.existsSync(path.join(EXT_DIR, 'swagger-ui-bundle.js')); } catch { /* ignore */ }
|
||||
res.type('html').send(hasSwagger ? SWAGGER_HTML : DOCS_HTML);
|
||||
});
|
||||
|
||||
// Statische Doku-Assets (Swagger UI) – von der eigenen Domain, whitelisted.
|
||||
router.get('/docs-assets/:file', (req, res) => {
|
||||
const name = String(req.params.file || '');
|
||||
if (name === 'init.js') { res.type('application/javascript').send(INIT_JS); return; }
|
||||
const TYPES = { 'swagger-ui.css': 'text/css', 'swagger-ui-bundle.js': 'application/javascript' };
|
||||
if (!TYPES[name]) return res.status(404).json({ error: 'Not found.' });
|
||||
try {
|
||||
const buf = fs.readFileSync(path.join(EXT_DIR, name));
|
||||
res.setHeader('Content-Type', TYPES[name]);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
return res.send(buf);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Docs asset missing – place swagger-ui.css and swagger-ui-bundle.js in the extension folder.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Objekt-Form mit fester id → Mount unter /skrift-api (wie skrift-orders).
|
||||
export default { id: 'skrift-api', handler };
|
||||
2
extensions/directus-extension-skrift-api/dist/swagger-ui-bundle.js
vendored
Normal file
2
extensions/directus-extension-skrift-api/dist/swagger-ui-bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
extensions/directus-extension-skrift-api/dist/swagger-ui.css
vendored
Normal file
3
extensions/directus-extension-skrift-api/dist/swagger-ui.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
extensions/directus-extension-skrift-api/package.json
Normal file
13
extensions/directus-extension-skrift-api/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Public-API für Kunden (v1): Aufträge anlegen, Status abfragen, Datei-Upload. Key-Auth über api_clients (SHA-256).",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
37
extensions/directus-extension-skrift-apikey/dist/index.js
vendored
Normal file
37
extensions/directus-extension-skrift-apikey/dist/index.js
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Skrift – API-Key-Hook
|
||||
* ---------------------------------------------------------------------------
|
||||
* Beim Anlegen eines api_clients-Eintrags wird automatisch ein API-Key erzeugt:
|
||||
* - key_hash = SHA-256 des Keys (dauerhaft; nur damit authentifiziert die API)
|
||||
* - key_prefix = Erkennungspräfix (Anzeige)
|
||||
* - key_plain = Klartext-Key, EINMALIG sichtbar (jetzt kopieren)
|
||||
*
|
||||
* Bei der nächsten Änderung des Eintrags wird key_plain wieder geleert
|
||||
* („reveal once"). Der Klartext wird also nie dauerhaft gespeichert.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
||||
|
||||
export default (register) => {
|
||||
// Anlegen: Key erzeugen, sofern noch keiner gesetzt ist.
|
||||
register.filter('api_clients.items.create', (payload) => {
|
||||
if (payload && typeof payload === 'object' && !payload.key_hash) {
|
||||
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
|
||||
payload.key_hash = sha256(key);
|
||||
payload.key_prefix = key.slice(0, 14);
|
||||
payload.key_plain = key; // einmalige Anzeige
|
||||
if (payload.active === undefined) payload.active = true;
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
|
||||
// Jede spätere Änderung entfernt den Klartext (nur direkt nach Anlage sichtbar),
|
||||
// außer er wird gerade explizit gesetzt.
|
||||
register.filter('api_clients.items.update', (payload) => {
|
||||
if (payload && typeof payload === 'object' && payload.key_plain === undefined) {
|
||||
payload.key_plain = null;
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
};
|
||||
13
extensions/directus-extension-skrift-apikey/package.json
Normal file
13
extensions/directus-extension-skrift-apikey/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-apikey",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Erzeugt beim Anlegen eines api_clients automatisch einen API-Key (SHA-256 gespeichert, Klartext einmalig in key_plain).",
|
||||
"directus:extension": {
|
||||
"type": "hook",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
276
extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal file
276
extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Skrift – Passwortloser Login per E-Mail-Code
|
||||
* ---------------------------------------------------------------------------
|
||||
* Directus kennt ab Werk nur Passwort/SSO. Diese Endpoint-Extension ergänzt den
|
||||
* Kunden-Login per Einmalcode:
|
||||
*
|
||||
* POST /skrift-auth/request-code { email } → Code per Mail
|
||||
* POST /skrift-auth/verify-code { email, code } → Tokens
|
||||
* POST /skrift-auth/register { email, ... } → Konto anlegen + Code
|
||||
*
|
||||
* Sicherheitsprinzipien:
|
||||
* - `request-code` antwortet IMMER generisch (verrät nicht, ob es das Konto gibt).
|
||||
* - Codes werden nur als Hash gespeichert (SHA-256 + SECRET als Pepper).
|
||||
* - Ablauf nach 10 Minuten, max. 5 Fehlversuche, 60 s Wiederhol-Sperre.
|
||||
*
|
||||
* Token-Ausstellung: Der Code-Login setzt für den Nutzer ein frisches Zufalls-
|
||||
* passwort und meldet ihn damit über Directus' offiziellen AuthenticationService
|
||||
* an. So sind Session/Refresh-Handling garantiert korrekt (statt selbstgebauter
|
||||
* JWTs) – das Passwort kennt niemand und rotiert bei jedem Login.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const CODE_TTL_MIN = 10;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const RESEND_COOLDOWN_SEC = 60;
|
||||
const CUSTOMER_ROLE_NAME = 'Kunde';
|
||||
|
||||
const handler = (router, { services, getSchema, env, logger }) => {
|
||||
const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services;
|
||||
|
||||
const norm = (e) => String(e || '').trim().toLowerCase();
|
||||
const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||
const hashCode = (code, email) =>
|
||||
crypto.createHash('sha256').update(`${code}:${email}:${env.SECRET}`).digest('hex');
|
||||
const genCode = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
|
||||
|
||||
// Temporäres Passwort, das jede übliche Passwort-Richtlinie erfüllt
|
||||
// (Groß-, Kleinbuchstabe, Ziffer, Sonderzeichen). Wird nie angezeigt und
|
||||
// rotiert bei jedem Login.
|
||||
const tempPasswort = () =>
|
||||
`Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`;
|
||||
|
||||
const ctx = async () => ({ schema: await getSchema(), accountability: null });
|
||||
|
||||
// Findet das Konto per primärer E-Mail ODER per zusätzlicher Login-Mail
|
||||
// (alt_emails). So melden sich Zweit-Mails im SELBEN Konto an.
|
||||
async function findUserByEmail(email, schema) {
|
||||
const users = new UsersService({ schema, accountability: null });
|
||||
const felder = ['id', 'email', 'status', 'alt_emails'];
|
||||
const primaer = await users.readByQuery({ filter: { email: { _eq: email } }, fields: felder, limit: 1 });
|
||||
if (primaer?.[0]) return primaer[0];
|
||||
// Zusätzliche Login-Mails: JSON-Feld ist DB-abhängig schwer filterbar, daher
|
||||
// die (wenigen) Konten mit alt_emails laden und exakt in JS prüfen.
|
||||
try {
|
||||
const cand = await users.readByQuery({ filter: { alt_emails: { _nnull: true } }, fields: felder, limit: -1 });
|
||||
const treffer = (cand || []).find((u) => {
|
||||
const liste = Array.isArray(u.alt_emails)
|
||||
? u.alt_emails
|
||||
: (typeof u.alt_emails === 'string' ? (() => { try { return JSON.parse(u.alt_emails); } catch { return []; } })() : []);
|
||||
return Array.isArray(liste) && liste.map((x) => norm(x)).includes(email);
|
||||
});
|
||||
if (treffer) return treffer;
|
||||
} catch { /* alt_emails evtl. noch nicht vorhanden → ignorieren */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendCodeMail(email, code) {
|
||||
const schema = await getSchema();
|
||||
|
||||
// Standardinhalt (Fallback, falls keine Vorlage hinterlegt ist).
|
||||
let subject = `Ihr Anmeldecode: ${code}`;
|
||||
let body =
|
||||
`<p>Guten Tag,</p><p>Ihr Anmeldecode für Skrift lautet:</p>` +
|
||||
`<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0;">${code}</p>` +
|
||||
`<p>Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung ` +
|
||||
`angefordert haben, können Sie diese E-Mail ignorieren.</p>`;
|
||||
let signature = '';
|
||||
let absender = '';
|
||||
|
||||
// Editierbare Vorlage „login_code" + gemeinsame Signatur laden (falls vorhanden).
|
||||
try {
|
||||
const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
|
||||
filter: { key: { _eq: 'login_code' }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'],
|
||||
});
|
||||
const tpl = rows?.[0];
|
||||
const fuelle = (s) => String(s || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (k === 'code' ? code : ''));
|
||||
if (tpl) {
|
||||
if (tpl.subject) subject = fuelle(tpl.subject);
|
||||
if (tpl.body) body = fuelle(tpl.body);
|
||||
}
|
||||
const es = await new ItemsService('email_settings', { schema, accountability: null })
|
||||
.readSingleton({ fields: ['signature', 'sender_name', 'sender_email'] });
|
||||
if (es?.signature) signature = es.signature;
|
||||
if (es?.sender_email) {
|
||||
absender = es.sender_name ? `${es.sender_name} <${es.sender_email}>` : es.sender_email;
|
||||
}
|
||||
} catch { /* Vorlage optional – Fallback oben greift */ }
|
||||
|
||||
const mail = new MailService({ schema, accountability: null });
|
||||
const nachricht = { to: email, subject, html: body + signature };
|
||||
if (absender) nachricht.from = absender;
|
||||
await mail.send(nachricht);
|
||||
}
|
||||
|
||||
// ── Code anfordern ────────────────────────────────────────────────────────
|
||||
router.post('/request-code', async (req, res) => {
|
||||
const email = norm(req.body?.email);
|
||||
// Antwort ist bewusst immer gleich – keine Auskunft über Kontoexistenz.
|
||||
const generic = () => res.json({ ok: true });
|
||||
|
||||
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
|
||||
|
||||
try {
|
||||
const { schema } = await ctx();
|
||||
const codes = new ItemsService('login_codes', { schema, accountability: null });
|
||||
|
||||
// Wiederhol-Sperre
|
||||
const recent = await codes.readByQuery({
|
||||
filter: { email: { _eq: email } },
|
||||
sort: ['-date_created'], limit: 1, fields: ['date_created'],
|
||||
});
|
||||
if (recent?.[0]?.date_created) {
|
||||
const age = (Date.now() - new Date(recent[0].date_created).getTime()) / 1000;
|
||||
if (age < RESEND_COOLDOWN_SEC) return generic();
|
||||
}
|
||||
|
||||
const user = await findUserByEmail(email, schema);
|
||||
if (!user || user.status !== 'active') return generic();
|
||||
|
||||
const code = genCode();
|
||||
await codes.createOne({
|
||||
email,
|
||||
code_hash: hashCode(code, email),
|
||||
expires_at: new Date(Date.now() + CODE_TTL_MIN * 60_000).toISOString(),
|
||||
attempts: 0,
|
||||
used: false,
|
||||
});
|
||||
await sendCodeMail(email, code);
|
||||
return generic();
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-auth] request-code: ${err.message}`);
|
||||
return generic();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Code prüfen + anmelden ────────────────────────────────────────────────
|
||||
router.post('/verify-code', async (req, res) => {
|
||||
const email = norm(req.body?.email);
|
||||
const code = String(req.body?.code || '').trim();
|
||||
if (!isEmail(email) || !/^\d{6}$/.test(code))
|
||||
return res.status(400).json({ error: 'E-Mail oder Code ungültig.' });
|
||||
|
||||
try {
|
||||
const { schema } = await ctx();
|
||||
const codes = new ItemsService('login_codes', { schema, accountability: null });
|
||||
|
||||
const rows = await codes.readByQuery({
|
||||
filter: { email: { _eq: email }, used: { _eq: false } },
|
||||
sort: ['-date_created'], limit: 1,
|
||||
fields: ['id', 'code_hash', 'expires_at', 'attempts'],
|
||||
});
|
||||
const row = rows?.[0];
|
||||
if (!row) return res.status(401).json({ error: 'Code ungültig oder abgelaufen.' });
|
||||
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||
await codes.updateOne(row.id, { used: true });
|
||||
return res.status(401).json({ error: 'Code abgelaufen. Bitte neu anfordern.' });
|
||||
}
|
||||
if ((row.attempts ?? 0) >= MAX_ATTEMPTS) {
|
||||
await codes.updateOne(row.id, { used: true });
|
||||
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte neuen Code anfordern.' });
|
||||
}
|
||||
|
||||
const expected = Buffer.from(row.code_hash, 'utf8');
|
||||
const actual = Buffer.from(hashCode(code, email), 'utf8');
|
||||
const ok = expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||
if (!ok) {
|
||||
await codes.updateOne(row.id, { attempts: (row.attempts ?? 0) + 1 });
|
||||
return res.status(401).json({ error: 'Code ungültig.' });
|
||||
}
|
||||
await codes.updateOne(row.id, { used: true });
|
||||
|
||||
const user = await findUserByEmail(email, schema);
|
||||
if (!user || user.status !== 'active')
|
||||
return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' });
|
||||
|
||||
// WICHTIG: Directus kennt nur die PRIMÄRE E-Mail als Login. Wurde der Code
|
||||
// an eine Zweit-Mail geschickt, melden wir dennoch mit der primären an –
|
||||
// dadurch landet die Zweit-Mail im selben Konto (voller Zugriff).
|
||||
const loginEmail = norm(user.email);
|
||||
|
||||
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
|
||||
const users = new UsersService({ schema, accountability: null });
|
||||
const tempPassword = tempPasswort();
|
||||
await users.updateOne(user.id, { password: tempPassword });
|
||||
|
||||
// Die Signatur von AuthenticationService.login variiert zwischen
|
||||
// Directus-Versionen. Der Aufruf mit ('default', payload) hat bei dir per
|
||||
// curl funktioniert – daher zuerst diese Variante, dann Fallbacks.
|
||||
const auth = new AuthenticationService({ schema, accountability: null });
|
||||
const versuche = [
|
||||
() => auth.login('default', { email: loginEmail, password: tempPassword }),
|
||||
() => auth.login({ email: loginEmail, password: tempPassword }),
|
||||
() => auth.login({ email: loginEmail, password: tempPassword }, {}),
|
||||
];
|
||||
let result, letzterFehler;
|
||||
for (const versuch of versuche) {
|
||||
try { result = await versuch(); break; }
|
||||
catch (e) { letzterFehler = e; }
|
||||
}
|
||||
if (!result) {
|
||||
logger.error(`[skrift-auth] login fehlgeschlagen: ${letzterFehler?.message}`);
|
||||
const e = new Error(`Login-Service: ${letzterFehler?.message || 'unbekannt'}`);
|
||||
e.detail = letzterFehler?.message;
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Kunde = Login-Konto selbst – keine separate Verknüpfung mehr nötig.
|
||||
return res.json({
|
||||
access_token: result.accessToken ?? result.access_token,
|
||||
refresh_token: result.refreshToken ?? result.refresh_token,
|
||||
expires: result.expires,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-auth] verify-code: ${err.stack || err.message}`);
|
||||
return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Konto anlegen (aus dem Checkout) ──────────────────────────────────────
|
||||
router.post('/register', async (req, res) => {
|
||||
const email = norm(req.body?.email);
|
||||
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
|
||||
|
||||
try {
|
||||
const { schema } = await ctx();
|
||||
|
||||
// Bereits vorhanden? Dann einfach Login-Code schicken (kein Fehler).
|
||||
const existing = await findUserByEmail(email, schema);
|
||||
if (existing) return res.json({ ok: true, existed: true });
|
||||
|
||||
const roles = new RolesService({ schema, accountability: null });
|
||||
const role = (await roles.readByQuery({
|
||||
filter: { name: { _eq: CUSTOMER_ROLE_NAME } }, limit: 1, fields: ['id'],
|
||||
}))?.[0];
|
||||
if (!role) {
|
||||
logger.error(`[skrift-auth] Rolle "${CUSTOMER_ROLE_NAME}" nicht gefunden.`);
|
||||
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
|
||||
}
|
||||
|
||||
// Kunde = Login-Konto: Profil hängt direkt am Nutzer.
|
||||
const users = new UsersService({ schema, accountability: null });
|
||||
await users.createOne({
|
||||
email,
|
||||
password: crypto.randomBytes(32).toString('base64url'),
|
||||
role: role.id,
|
||||
status: 'active',
|
||||
first_name: req.body?.first_name ?? null,
|
||||
last_name: req.body?.last_name ?? null,
|
||||
company: req.body?.company ?? null,
|
||||
phone: req.body?.phone ?? null,
|
||||
person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||
});
|
||||
|
||||
return res.json({ ok: true, existed: false });
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-auth] register: ${err.message}`);
|
||||
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Explizite ID → Routen liegen garantiert unter /skrift-auth/*,
|
||||
// unabhängig von Ordner- oder Paketnamen.
|
||||
export default { id: 'skrift-auth', handler };
|
||||
13
extensions/directus-extension-skrift-auth/package.json
Normal file
13
extensions/directus-extension-skrift-auth/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-auth",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Passwortloser Login per E-Mail-Code für Skrift-Kunden.",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
205
extensions/directus-extension-skrift-contacts/dist/index.js
vendored
Normal file
205
extensions/directus-extension-skrift-contacts/dist/index.js
vendored
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Skrift – Kontakte-Hook (Anlage auf Anforderung, Aktionen, DSGVO-Purge)
|
||||
* ---------------------------------------------------------------------------
|
||||
* Rund um die Collections `contacts` und `contact_actions`:
|
||||
*
|
||||
* 1) ANLAGE NUR AUF ANFORDERUNG — Kontakte werden NICHT automatisch bei jedem
|
||||
* Versand gespeichert. Erst wenn am Auftrag „In Kontakte speichern"
|
||||
* (orders.keep_contacts) gesetzt wird, entsteht je Empfängerzeile ein
|
||||
* Kontakt (Guard: orders.contacts_created). Diese Kontakte bleiben dauerhaft.
|
||||
*
|
||||
* 2) STANDARDAKTIONEN (contact_actions) —
|
||||
* • type „angebot" → sofort eine Mail mit den Eckdaten (Name, Adresse,
|
||||
* E-Mail, Telefon, Notizen) an die interne Adresse (fürs Rechnungsprogramm).
|
||||
* • type „rueckruf" → am fälligen Tag (Zeitplan) eine Erinnerungs-Mail.
|
||||
*
|
||||
* 3) DSGVO-STRIP (optional, per Env) — Empfängerdaten (order_entries) alter,
|
||||
* versendeter Aufträge OHNE „In Kontakte speichern" werden anonymisiert.
|
||||
* Standard: nur DRY-RUN (Log). Scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1.
|
||||
* Kontakte selbst werden NICHT automatisch gelöscht (nur manuell).
|
||||
*
|
||||
* Env:
|
||||
* SKRIFT_CONTACTS_REMINDER_CRON Cron der Rückruf-Erinnerung (Default „0 7 * * *").
|
||||
* SKRIFT_CONTACTS_PURGE_CRON Cron des DSGVO-Strip (Default „0 3 * * *").
|
||||
* SKRIFT_CONTACTS_STRIP_ENTRIES „1"/„true" = Empfängerdaten wirklich löschen.
|
||||
* SKRIFT_TEAM_EMAIL / EMAIL_FROM Fallback-Empfänger, falls email_settings.team_email leer.
|
||||
*
|
||||
* Von Hand als ESM (kein Build-Schritt), analog zu skrift-mailer/skrift-apikey.
|
||||
*/
|
||||
|
||||
const RETENTION_DAYS = 30;
|
||||
// Alle 15 Minuten prüfen, damit die Erinnerung nahe der gewählten Uhrzeit kommt.
|
||||
const REMINDER_CRON = process.env.SKRIFT_CONTACTS_REMINDER_CRON || '*/15 * * * *';
|
||||
const PURGE_CRON = process.env.SKRIFT_CONTACTS_PURGE_CRON || '0 3 * * *';
|
||||
const STRIP_ENTRIES = /^(1|true|yes|on)$/i.test(String(process.env.SKRIFT_CONTACTS_STRIP_ENTRIES || ''));
|
||||
|
||||
const ENTRY_PII = ['salutation', 'first_name', 'last_name', 'street', 'house_no',
|
||||
'zip', 'city', 'country', 'free_text', 'placeholders', 'text'];
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||||
const htmlZuText = (html) => String(html || '')
|
||||
.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '').replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
||||
.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
const contactName = (c) => [c?.salutation, c?.first_name, c?.last_name].filter(Boolean).join(' ')
|
||||
|| c?.company || c?.email || c?.phone || '(ohne Namen)';
|
||||
|
||||
// Eckdaten eines Kontakts als HTML-Block (für Angebots-/Erinnerungs-Mails).
|
||||
function contactHtml(c, extraNote) {
|
||||
const anschrift = [
|
||||
c.company ? `<strong>${esc(c.company)}</strong>` : '',
|
||||
esc([c.salutation, c.first_name, c.last_name].filter(Boolean).join(' ')),
|
||||
esc([c.street, c.house_no].filter(Boolean).join(' ')),
|
||||
esc([c.zip, c.city].filter(Boolean).join(' ')),
|
||||
c.country ? esc(c.country) : '',
|
||||
].filter(Boolean).join('<br>');
|
||||
const zeilen = [];
|
||||
zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Anschrift</p><p style="margin:0 0 12px;line-height:1.6;">${anschrift || '—'}</p>`);
|
||||
zeilen.push('<p style="margin:0 0 12px;">'
|
||||
+ `E-Mail: ${c.email ? esc(c.email) : '—'}<br>Telefon: ${c.phone ? esc(c.phone) : '—'}</p>`);
|
||||
const notes = [c.notes, extraNote].filter((n) => n && String(n).trim()).join('\n\n');
|
||||
if (notes) zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Notizen</p><p style="margin:0;line-height:1.6;white-space:pre-wrap;">${esc(notes)}</p>`);
|
||||
return zeilen.join('');
|
||||
}
|
||||
|
||||
export default (register, { services, getSchema, logger }) => {
|
||||
const { ItemsService, MailService } = services;
|
||||
const svc = (coll, schema) => new ItemsService(coll, { schema, accountability: null });
|
||||
|
||||
async function loadSettings(schema) {
|
||||
try { const rows = await svc('email_settings', schema).readByQuery({ limit: 1 }); return rows?.[0] || {}; }
|
||||
catch { return {}; }
|
||||
}
|
||||
async function sendTeamMail(schema, subject, html) {
|
||||
const settings = await loadSettings(schema);
|
||||
const to = settings.team_email || process.env.SKRIFT_TEAM_EMAIL || process.env.EMAIL_FROM;
|
||||
if (!to) { logger.warn('[skrift-contacts] keine Team-Adresse (email_settings.team_email) – Mail übersprungen'); return; }
|
||||
const msg = { to, subject, html, text: htmlZuText(html) };
|
||||
if (settings.sender_email) msg.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||||
await new MailService({ schema, accountability: null }).send(msg);
|
||||
logger.info(`[skrift-contacts] Mail „${subject}" an ${to}`);
|
||||
}
|
||||
|
||||
// ── 1) Kontakt-Anlage NUR wenn „In Kontakte speichern" gesetzt wird ─────────
|
||||
function contactFromEntry(order, e) {
|
||||
const c = {
|
||||
salutation: e.salutation || null, first_name: e.first_name || null, last_name: e.last_name || null,
|
||||
street: e.street || null, house_no: e.house_no || null, zip: e.zip || null, city: e.city || null,
|
||||
country: e.country || null, lead_status: 'neu',
|
||||
source_order: order.id, source_entry: e.id, sent_at: order.versandt_am || null,
|
||||
};
|
||||
if (!c.first_name && !c.last_name && e.free_text) {
|
||||
const lines = String(e.free_text).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
||||
if (lines.length) c.last_name = lines[0];
|
||||
if (lines.length) c.notes = 'Adresse:\n' + lines.join('\n');
|
||||
}
|
||||
return c;
|
||||
}
|
||||
async function saveContactsForOrder(orderId) {
|
||||
const schema = await getSchema();
|
||||
const orders = svc('orders', schema);
|
||||
const order = await orders.readOne(orderId, {
|
||||
fields: ['id', 'order_number', 'versandt_am', 'contacts_created'],
|
||||
});
|
||||
if (!order) return;
|
||||
if (order.contacts_created) return; // schon angelegt → nichts weiter zu tun
|
||||
const entries = await svc('order_entries', schema).readByQuery({
|
||||
filter: { order: { _eq: orderId } }, limit: -1,
|
||||
fields: ['id', 'salutation', 'first_name', 'last_name', 'street', 'house_no', 'zip', 'city', 'country', 'free_text'],
|
||||
});
|
||||
if (entries?.length) await svc('contacts', schema).createMany(entries.map((e) => contactFromEntry(order, e)));
|
||||
await orders.updateOne(orderId, { contacts_created: true });
|
||||
logger.info(`[skrift-contacts] ${entries?.length || 0} Kontakt(e) aus Auftrag ${order.order_number} gespeichert`);
|
||||
}
|
||||
|
||||
register.action('orders.items.update', async ({ keys, payload }) => {
|
||||
if (payload?.keep_contacts !== true) return; // nur auf ausdrückliche Anforderung
|
||||
for (const id of keys || []) {
|
||||
try { await saveContactsForOrder(id); }
|
||||
catch (e) { logger.warn(`[skrift-contacts] speichern ${id}: ${e.message}`); }
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2) Standardaktionen ─────────────────────────────────────────────────────
|
||||
register.action('contact_actions.items.create', async ({ key, payload }) => {
|
||||
const typ = payload?.type;
|
||||
if (typ !== 'angebot' && typ !== 'mail') return; // Rückruf läuft über den Zeitplan
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const a = await svc('contact_actions', schema).readOne(key, {
|
||||
fields: ['id', 'note', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
|
||||
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country',
|
||||
'contact.email', 'contact.phone', 'contact.notes'],
|
||||
});
|
||||
if (!a?.contact) return;
|
||||
const name = contactName(a.contact);
|
||||
const betreff = typ === 'mail' ? `Notiz: ${name}` : `Angebot vorbereiten: ${name}`;
|
||||
const einleitung = typ === 'mail' ? `Notiz zu <strong>${esc(name)}</strong>:` : `Angebot vorbereiten für <strong>${esc(name)}</strong>:`;
|
||||
const html = `<p style="margin:0 0 12px;">${einleitung}</p>${contactHtml(a.contact, a.note)}`;
|
||||
await sendTeamMail(schema, betreff, html);
|
||||
} catch (e) { logger.warn(`[skrift-contacts] Aktions-Mail (${typ}): ${e.message}`); }
|
||||
});
|
||||
|
||||
register.schedule(REMINDER_CRON, async () => {
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const nowIso = new Date().toISOString();
|
||||
const actions = svc('contact_actions', schema);
|
||||
const due = await actions.readByQuery({
|
||||
filter: { _and: [{ type: { _eq: 'rueckruf' } }, { status: { _eq: 'offen' } }, { notified: { _eq: false } }, { due_date: { _lte: nowIso } }] },
|
||||
fields: ['id', 'note', 'due_date', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
|
||||
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country', 'contact.email', 'contact.phone', 'contact.notes'],
|
||||
limit: -1,
|
||||
});
|
||||
for (const a of due || []) {
|
||||
if (!a.contact) { await actions.updateOne(a.id, { notified: true }); continue; }
|
||||
const name = contactName(a.contact);
|
||||
const wann = a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'short', timeStyle: 'short' }) : '';
|
||||
const html = `<p style="margin:0 0 12px;">Fälliger Rückruf${wann ? ' (' + esc(wann) + ' Uhr)' : ''} bei <strong>${esc(name)}</strong>`
|
||||
+ `${a.contact.phone ? ' · <strong>' + esc(a.contact.phone) + '</strong>' : ''}:</p>${contactHtml(a.contact, a.note)}`;
|
||||
try { await sendTeamMail(schema, `Rückruf fällig: ${name}`, html); await actions.updateOne(a.id, { notified: true }); }
|
||||
catch (e) { logger.warn(`[skrift-contacts] Rückruf-Mail ${a.id}: ${e.message}`); }
|
||||
}
|
||||
} catch (e) { logger.warn(`[skrift-contacts] Erinnerungslauf: ${e.message}`); }
|
||||
});
|
||||
|
||||
// ── 3) Täglicher Aufräumlauf ────────────────────────────────────────────────
|
||||
register.schedule(PURGE_CRON, async () => {
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000).toISOString();
|
||||
|
||||
// 3a) Kontakte mit „kein Interesse", seit 30 Tagen unverändert → löschen.
|
||||
// Alle anderen Lead-Status bleiben dauerhaft erhalten.
|
||||
const contacts = svc('contacts', schema);
|
||||
const raus = await contacts.readByQuery({
|
||||
filter: { _and: [{ lead_status: { _eq: 'kein_interesse' } }, { date_updated: { _lt: cutoff } }] },
|
||||
fields: ['id'], limit: -1,
|
||||
});
|
||||
if (raus?.length) { await contacts.deleteMany(raus.map((r) => r.id)); logger.info(`[skrift-contacts] ${raus.length} Kontakt(e) „kein Interesse" (>30 Tage) gelöscht`); }
|
||||
|
||||
// 3b) DSGVO-Strip der Empfängerdaten im Auftrag (optional, per Env).
|
||||
const orders = svc('orders', schema);
|
||||
const alte = await orders.readByQuery({
|
||||
filter: { _and: [
|
||||
{ keep_contacts: { _eq: false } }, { entries_stripped: { _eq: false } },
|
||||
{ production_status: { _eq: 'versendet' } }, { versandt_am: { _lt: cutoff } },
|
||||
] },
|
||||
fields: ['id', 'order_number'], limit: -1,
|
||||
});
|
||||
for (const o of alte || []) {
|
||||
const entries = await svc('order_entries', schema).readByQuery({ filter: { order: { _eq: o.id } }, fields: ['id'], limit: -1 });
|
||||
if (!STRIP_ENTRIES) {
|
||||
logger.info(`[skrift-contacts] DSGVO DRY-RUN: würde Empfängerdaten von Auftrag ${o.order_number} (${entries?.length || 0} Zeilen) anonymisieren – scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1`);
|
||||
continue;
|
||||
}
|
||||
const wipe = Object.fromEntries(ENTRY_PII.map((k) => [k, null]));
|
||||
if (entries?.length) await svc('order_entries', schema).updateMany(entries.map((r) => r.id), wipe);
|
||||
await orders.updateOne(o.id, { entries_stripped: true });
|
||||
logger.info(`[skrift-contacts] DSGVO: Empfängerdaten von Auftrag ${o.order_number} anonymisiert`);
|
||||
}
|
||||
} catch (e) { logger.warn(`[skrift-contacts] DSGVO-Lauf: ${e.message}`); }
|
||||
});
|
||||
};
|
||||
13
extensions/directus-extension-skrift-contacts/package.json
Normal file
13
extensions/directus-extension-skrift-contacts/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-contacts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Legt bei Versand automatisch Kontakte je Empfänger an und löscht sie (samt Empfängerdaten im Auftrag) nach 30 Tagen – außer der Auftrag ist als „Kontakte behalten“ markiert.",
|
||||
"directus:extension": {
|
||||
"type": "hook",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
397
extensions/directus-extension-skrift-kontakte/dist/index.js
vendored
Normal file
397
extensions/directus-extension-skrift-kontakte/dist/index.js
vendored
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Skrift – Modul „Kontakte" (Mini-CRM)
|
||||
* ===========================================================================
|
||||
* Handytaugliches Lead-Board über die Collection `contacts`: alle Empfänger,
|
||||
* denen ein Schriftstück gesendet wurde. Fokus auf Nachtelefonieren:
|
||||
*
|
||||
* • Liste mit Suche + Status-Filter, ein Tipp öffnet den Kontakt.
|
||||
* • Detail: Name/Adresse, Telefon als GroundWire-Anruf-Button, E-Mail,
|
||||
* Lead-Status, „Behalten" und ein Notizfeld – alles mit Auto-Speichern.
|
||||
* • ◀/▶ blättert durch die gefilterte Liste, ohne zurückzuspringen.
|
||||
* • CSV-Export der (gefilterten) Kontakte. Import über die normale
|
||||
* Directus-Collection-Seite (Content → Kontakte → Import).
|
||||
*
|
||||
* Anruf-Schema fest GroundWire (groundwire://call?number=…). Die Konstante DIAL
|
||||
* unten ist die einzige Stelle, falls ein anderes Format nötig wird.
|
||||
*
|
||||
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||
*/
|
||||
import { defineComponent, ref, reactive, computed, watch, onMounted, h, resolveComponent } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const ACCENT = '#1E50A5';
|
||||
// GroundWire-Anruf-Link (Acrobits „Call URI"): scheme:NUMMER?dialAction=…
|
||||
// Die Nummer steht direkt hinter dem Doppelpunkt (kein „//"), ohne Leerzeichen.
|
||||
// dialAction=voiceCall → sofortiger VoIP-Anruf über GroundWire. Für nur
|
||||
// Vorbefüllen (Nummer eintippen, selbst tippen) den ?dialAction-Teil weglassen.
|
||||
const cleanNr = (nr) => String(nr || '').replace(/[^\d+]/g, '');
|
||||
const DIAL = (nr) => 'groundwire:' + cleanNr(nr) + '?dialAction=voiceCall';
|
||||
|
||||
const LEAD = [
|
||||
{ key: 'neu', label: 'Neu', color: '#6b7280' },
|
||||
{ key: 'kontaktiert', label: 'Kontaktiert', color: ACCENT },
|
||||
{ key: 'interessiert', label: 'Interessiert', color: '#e07b00' },
|
||||
{ key: 'kein_interesse', label: 'Kein Interesse', color: '#c62828' },
|
||||
{ key: 'kunde', label: 'Kunde', color: '#2e7d32' },
|
||||
];
|
||||
const LEAD_LABEL = Object.fromEntries(LEAD.map((l) => [l.key, l.label]));
|
||||
const LEAD_COLOR = Object.fromEntries(LEAD.map((l) => [l.key, l.color]));
|
||||
|
||||
const FIELDS = ['id', 'salutation', 'first_name', 'last_name', 'company', 'street', 'house_no',
|
||||
'zip', 'city', 'country', 'email', 'phone', 'notes', 'lead_status', 'sent_at',
|
||||
'source_order.order_number'];
|
||||
|
||||
// ── Stil-Helfer ──────────────────────────────────────────────────────────────
|
||||
const CARD = 'border:1px solid var(--border-subdued);border-radius:10px;padding:16px 18px;background:var(--background-page,#fff);box-shadow:0 1px 3px rgba(0,0,0,.08);';
|
||||
// Klar erkennbares Eingabefeld: kräftigere Umrandung + leicht getönter Grund,
|
||||
// damit es sich deutlich von der weißen Karte abhebt.
|
||||
const S_INPUT = 'width:100%;padding:11px 12px;border:1.5px solid var(--border-normal,#c4cdde);border-radius:8px;background:var(--background-subdued,#eef1f7);color:var(--foreground-normal);font:inherit;font-size:15px;box-sizing:border-box;';
|
||||
const LBL = 'font-size:12px;font-weight:600;color:var(--foreground-subdued);margin:0 0 4px;text-transform:uppercase;letter-spacing:.03em;';
|
||||
|
||||
function btn(label, onClick, kind) {
|
||||
const base = 'cursor:pointer;border-radius:8px;padding:10px 16px;font:inherit;font-size:14px;border:1px solid;';
|
||||
const style = kind === 'primary'
|
||||
? base + `border-color:${ACCENT};background:${ACCENT};color:#fff;`
|
||||
: kind === 'ghost'
|
||||
? base + 'border-color:transparent;background:transparent;color:var(--foreground-normal);'
|
||||
: base + 'border-color:var(--border-normal);background:var(--background-subdued);color:var(--foreground-normal);';
|
||||
return h('button', { type: 'button', onClick, style }, label);
|
||||
}
|
||||
function chip(text, farbe) {
|
||||
return h('span', {
|
||||
style: 'display:inline-flex;align-items:center;gap:4px;font-size:12px;padding:3px 10px;border-radius:20px;'
|
||||
+ `background:${farbe || 'var(--background-subdued)'};color:#fff;white-space:nowrap;`,
|
||||
}, text);
|
||||
}
|
||||
function subdued(text, extra) {
|
||||
return h('div', { style: `color:var(--foreground-subdued);font-size:14px;${extra || ''}` }, text);
|
||||
}
|
||||
function feld(label, node) {
|
||||
return h('div', { style: 'margin-bottom:14px;' }, [h('div', { style: LBL }, label), node]);
|
||||
}
|
||||
|
||||
const anzeigeName = (k) => [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ')
|
||||
|| k.company || k.email || k.phone || '(ohne Namen)';
|
||||
const adresse = (k) => [[k.street, k.house_no].filter(Boolean).join(' '),
|
||||
[k.zip, k.city].filter(Boolean).join(' '), k.country].filter(Boolean).join(', ');
|
||||
|
||||
const Board = defineComponent({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const kontakte = ref([]);
|
||||
const loading = ref(false);
|
||||
const fehler = ref('');
|
||||
const meldung = ref('');
|
||||
const suche = ref('');
|
||||
const statusFilter = ref('');
|
||||
const aktivId = ref(null);
|
||||
const savedFlash = ref(null); // Feld-Key, der zuletzt gespeichert wurde
|
||||
const aktionen = ref([]); // offene/erledigte Aktionen des aktiven Kontakts
|
||||
const rueckruf = reactive({ open: false, date: '', time: '09:00' });
|
||||
const mailMich = reactive({ open: false, note: '' });
|
||||
|
||||
async function laden() {
|
||||
loading.value = true; fehler.value = '';
|
||||
try {
|
||||
const r = await api.get('/items/contacts', {
|
||||
params: { limit: -1, sort: ['-sent_at', '-date_created'], fields: FIELDS },
|
||||
});
|
||||
kontakte.value = r.data.data || [];
|
||||
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Fehler beim Laden.'; }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
onMounted(laden);
|
||||
|
||||
const gefiltert = computed(() => {
|
||||
const q = suche.value.trim().toLowerCase();
|
||||
return kontakte.value.filter((k) => {
|
||||
if (statusFilter.value && (k.lead_status || 'neu') !== statusFilter.value) return false;
|
||||
if (!q) return true;
|
||||
const hay = [k.salutation, k.first_name, k.last_name, k.company, k.zip, k.city,
|
||||
k.email, k.phone, k.source_order?.order_number].filter(Boolean).join(' ').toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
});
|
||||
const aktiv = computed(() => kontakte.value.find((k) => k.id === aktivId.value) || null);
|
||||
const aktivIndex = computed(() => gefiltert.value.findIndex((k) => k.id === aktivId.value));
|
||||
|
||||
async function save(id, patch, feldKey) {
|
||||
try {
|
||||
await api.patch(`/items/contacts/${id}`, patch);
|
||||
const k = kontakte.value.find((x) => x.id === id);
|
||||
if (k) Object.assign(k, patch);
|
||||
if (feldKey) { savedFlash.value = feldKey; setTimeout(() => { if (savedFlash.value === feldKey) savedFlash.value = null; }, 1500); }
|
||||
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen.'; }
|
||||
}
|
||||
// Speichert nur, wenn sich der Wert wirklich geändert hat (Blur-Handler).
|
||||
const saveIfChanged = (k, key) => (ev) => {
|
||||
const val = ev.target.value;
|
||||
if ((k[key] || '') !== val) save(k.id, { [key]: val || null }, key);
|
||||
};
|
||||
|
||||
// ── Standardaktionen (Rückruf / Angebot) ─────────────────────────────────
|
||||
async function ladeAktionen(id) {
|
||||
if (!id) { aktionen.value = []; return; }
|
||||
try {
|
||||
const r = await api.get('/items/contact_actions', {
|
||||
params: { filter: { contact: { _eq: id } }, sort: ['-date_created'], limit: -1,
|
||||
fields: ['id', 'type', 'due_date', 'note', 'status', 'notified'] },
|
||||
});
|
||||
aktionen.value = r.data.data || [];
|
||||
} catch { aktionen.value = []; }
|
||||
}
|
||||
watch(aktivId, (id) => { rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00'; mailMich.open = false; mailMich.note = ''; ladeAktionen(id); });
|
||||
|
||||
const isoDatum = (d) => { const x = new Date(); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + d); return x; };
|
||||
const ymd = (dt) => `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
|
||||
|
||||
async function planeRueckruf(k) {
|
||||
if (!rueckruf.date) { fehler.value = 'Bitte ein Datum wählen.'; return; }
|
||||
try {
|
||||
await api.post('/items/contact_actions', {
|
||||
contact: k.id, type: 'rueckruf', status: 'offen',
|
||||
due_date: new Date(rueckruf.date + 'T' + (rueckruf.time || '09:00') + ':00').toISOString(),
|
||||
});
|
||||
rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00';
|
||||
await ladeAktionen(k.id);
|
||||
meldung.value = 'Rückruf-Erinnerung gesetzt.'; setTimeout(() => { meldung.value = ''; }, 2500);
|
||||
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Rückruf nicht anlegen.'; }
|
||||
}
|
||||
async function sendeAngebot(k) {
|
||||
try {
|
||||
await api.post('/items/contact_actions', { contact: k.id, type: 'angebot', status: 'erledigt' });
|
||||
await ladeAktionen(k.id);
|
||||
meldung.value = 'Angebot-Eckdaten wurden dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
|
||||
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Angebot nicht senden.'; }
|
||||
}
|
||||
// Legt eine Musterbestellung an: erscheint als Musteranfrage in
|
||||
// Produktion → Neu und wird über den bestehenden „Muster drucken"-Workflow
|
||||
// gedruckt. Keine Mail an den Kontakt.
|
||||
async function sendeMuster(k) {
|
||||
const name = [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ') || k.company || 'Kontakt';
|
||||
try {
|
||||
await api.post('/items/offer_requests', {
|
||||
type: 'muster', status: 'neu', name, product: 'Muster',
|
||||
email: k.email || 'muster@skrift.de', phone: k.phone || null,
|
||||
address: {
|
||||
company: k.company || '', salutation: k.salutation || '',
|
||||
first_name: k.first_name || '', last_name: k.last_name || '',
|
||||
street: [k.street, k.house_no].filter(Boolean).join(' '),
|
||||
zip: k.zip || '', city: k.city || '', country: k.country || '',
|
||||
},
|
||||
});
|
||||
meldung.value = 'Musterbestellung angelegt – erscheint in Produktion → Neu.'; setTimeout(() => { meldung.value = ''; }, 3500);
|
||||
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Muster nicht anlegen.'; }
|
||||
}
|
||||
// Schickt dir (interne Adresse) die Eckdaten des Kontakts + deine Notiz per Mail.
|
||||
async function sendeMail(k) {
|
||||
if (!mailMich.note.trim()) { fehler.value = 'Bitte eine Notiz eingeben.'; return; }
|
||||
try {
|
||||
await api.post('/items/contact_actions', { contact: k.id, type: 'mail', status: 'erledigt', note: mailMich.note });
|
||||
mailMich.open = false; mailMich.note = '';
|
||||
meldung.value = 'Deine Notiz wurde dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
|
||||
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Mail nicht senden.'; }
|
||||
}
|
||||
async function erledige(a, k) {
|
||||
try { await api.patch(`/items/contact_actions/${a.id}`, { status: 'erledigt' }); await ladeAktionen(k.id); }
|
||||
catch (e) { fehler.value = e?.message || 'Konnte nicht als erledigt markieren.'; }
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const rows = gefiltert.value;
|
||||
const cols = [['salutation', 'Anrede'], ['first_name', 'Vorname'], ['last_name', 'Nachname'],
|
||||
['company', 'Firma'], ['street', 'Straße'], ['house_no', 'Hausnr'], ['zip', 'PLZ'], ['city', 'Ort'],
|
||||
['country', 'Land'], ['email', 'E-Mail'], ['phone', 'Telefon'], ['lead_status', 'Status'],
|
||||
['notes', 'Notizen'], ['sent_at', 'Versendet am']];
|
||||
const esc = (v) => { const s = v == null ? '' : String(v); return /[",\n;]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
|
||||
const csv = '' + cols.map((c) => c[1]).join(',') + '\n'
|
||||
+ rows.map((r) => cols.map((c) => esc(r[c[0]])).join(',')).join('\n');
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `kontakte-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Liste ──
|
||||
function karte(k) {
|
||||
return h('div', {
|
||||
onClick: () => { aktivId.value = k.id; fehler.value = ''; },
|
||||
style: CARD + 'cursor:pointer;margin-bottom:10px;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;',
|
||||
}, [
|
||||
h('div', { style: 'min-width:0;' }, [
|
||||
h('div', { style: 'font-size:16px;font-weight:600;margin-bottom:2px;' }, anzeigeName(k)),
|
||||
subdued(adresse(k) || '—', 'font-size:13px;'),
|
||||
k.phone ? subdued('📞 ' + k.phone, 'font-size:13px;margin-top:2px;') : null,
|
||||
]),
|
||||
h('div', { style: 'display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex-shrink:0;' }, [
|
||||
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
function listenAnsicht() {
|
||||
const kopf = h('div', { style: 'display:flex;flex-direction:column;gap:10px;margin-bottom:16px;' }, [
|
||||
h('input', {
|
||||
type: 'search', value: suche.value, placeholder: '🔎 Name, Ort, Telefon, E-Mail …',
|
||||
style: S_INPUT, onInput: (e) => { suche.value = e.target.value; },
|
||||
}),
|
||||
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;' }, [
|
||||
h('select', {
|
||||
style: S_INPUT + 'width:auto;flex:1;min-width:150px;cursor:pointer;',
|
||||
onChange: (e) => { statusFilter.value = e.target.value; },
|
||||
}, [
|
||||
h('option', { value: '', selected: statusFilter.value === '' }, 'Alle Status'),
|
||||
...LEAD.map((l) => h('option', { value: l.key, selected: statusFilter.value === l.key }, l.label)),
|
||||
]),
|
||||
]),
|
||||
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between;' }, [
|
||||
subdued(`${gefiltert.value.length} Kontakt(e)`),
|
||||
h('div', { style: 'display:flex;gap:8px;' }, [
|
||||
btn('↻ Aktualisieren', laden),
|
||||
btn('⭳ CSV-Export', exportCsv),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
const liste = loading.value ? subdued('Lädt …')
|
||||
: gefiltert.value.length ? h('div', {}, gefiltert.value.map(karte))
|
||||
: subdued('Keine Kontakte. Sobald ein Auftrag versendet wird, erscheinen die Empfänger hier automatisch.');
|
||||
const hinweis = subdued('CSV-Import: Content → Kontakte → Import (oben rechts).', 'margin-top:16px;font-size:12px;');
|
||||
return [kopf, liste, hinweis];
|
||||
}
|
||||
|
||||
// ── Detail ──
|
||||
function detailAnsicht() {
|
||||
const k = aktiv.value;
|
||||
if (!k) { aktivId.value = null; return listenAnsicht(); }
|
||||
const idx = aktivIndex.value;
|
||||
const gesamt = gefiltert.value.length;
|
||||
const geh = (d) => {
|
||||
const l = gefiltert.value;
|
||||
let i = aktivIndex.value; if (i < 0) return;
|
||||
i += d; if (i < 0 || i >= l.length) return;
|
||||
aktivId.value = l[i].id; fehler.value = '';
|
||||
};
|
||||
const flash = (key) => savedFlash.value === key ? h('span', { style: 'color:#2e7d32;font-size:12px;margin-left:8px;' }, '✓ gespeichert') : null;
|
||||
|
||||
const navRow = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:14px;' }, [
|
||||
btn('◀ Liste', () => { aktivId.value = null; }, 'ghost'),
|
||||
h('div', { style: 'display:flex;align-items:center;gap:8px;' }, [
|
||||
h('button', { type: 'button', disabled: idx <= 0, onClick: () => geh(-1),
|
||||
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx <= 0 ? 'opacity:.4;cursor:not-allowed;' : '') }, '◀'),
|
||||
subdued(gesamt ? `${idx + 1} / ${gesamt}` : '–'),
|
||||
h('button', { type: 'button', disabled: idx >= gesamt - 1, onClick: () => geh(1),
|
||||
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx >= gesamt - 1 ? 'opacity:.4;cursor:not-allowed;' : '') }, '▶'),
|
||||
]),
|
||||
]);
|
||||
|
||||
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:14px;' }, [
|
||||
h('div', {}, [
|
||||
h('div', { style: 'font-size:22px;font-weight:700;' }, anzeigeName(k)),
|
||||
adresse(k) ? subdued(adresse(k), 'margin-top:2px;') : null,
|
||||
k.source_order?.order_number ? subdued('aus Auftrag ' + k.source_order.order_number, 'font-size:12px;margin-top:2px;') : null,
|
||||
]),
|
||||
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
|
||||
]);
|
||||
|
||||
// Anruf-Button (GroundWire) – nur wenn eine Nummer da ist.
|
||||
const anruf = k.phone
|
||||
? h('a', { href: DIAL(k.phone),
|
||||
style: `display:flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;background:#2e7d32;color:#fff;border-radius:10px;padding:14px;font-size:17px;font-weight:600;margin-bottom:14px;` },
|
||||
[h('span', {}, '📞'), h('span', {}, 'Anrufen · ' + k.phone)])
|
||||
: subdued('Keine Telefonnummer – unten eintragen, dann erscheint der Anruf-Button.', 'margin-bottom:14px;');
|
||||
|
||||
const telFeld = feld(h('span', {}, ['Telefon', flash('phone')]),
|
||||
h('input', { type: 'tel', value: k.phone || '', placeholder: '+49 …', style: S_INPUT, onBlur: saveIfChanged(k, 'phone') }));
|
||||
const mailFeld = feld(h('span', {}, ['E-Mail', flash('email')]),
|
||||
h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
|
||||
h('input', { type: 'email', value: k.email || '', placeholder: 'name@…', style: S_INPUT, onBlur: saveIfChanged(k, 'email') }),
|
||||
k.email ? h('a', { href: 'mailto:' + k.email, style: 'text-decoration:none;font-size:20px;' }, '✉') : null,
|
||||
]));
|
||||
|
||||
const statusFeld = feld('Lead-Status',
|
||||
h('select', { style: S_INPUT + 'cursor:pointer;', onChange: (e) => save(k.id, { lead_status: e.target.value }, 'lead_status') },
|
||||
LEAD.map((l) => h('option', { value: l.key, selected: (k.lead_status || 'neu') === l.key }, l.label))));
|
||||
|
||||
const notizFeld = feld(h('span', {}, ['Notizen', flash('notes')]),
|
||||
h('textarea', { value: k.notes || '', rows: 5, placeholder: 'Gesprächsnotizen …',
|
||||
style: S_INPUT + 'resize:vertical;line-height:1.5;', onBlur: saveIfChanged(k, 'notes') }));
|
||||
|
||||
const namensFelder = h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [
|
||||
feld('Vorname', h('input', { value: k.first_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'first_name') })),
|
||||
feld('Nachname', h('input', { value: k.last_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'last_name') })),
|
||||
feld('Firma', h('input', { value: k.company || '', style: S_INPUT, onBlur: saveIfChanged(k, 'company') })),
|
||||
feld('Ort', h('input', { value: k.city || '', style: S_INPUT, onBlur: saveIfChanged(k, 'city') })),
|
||||
]);
|
||||
|
||||
// ── Standardaktionen (deutlich als farbige Buttons) ──
|
||||
const aktBase = 'cursor:pointer;border-radius:8px;padding:11px 15px;font:inherit;font-size:14px;font-weight:600;'
|
||||
+ 'border:1.5px solid;text-decoration:none;display:inline-flex;align-items:center;gap:6px;';
|
||||
const aktStil = (rand, grund, text) => aktBase + `border-color:${rand};background:${grund};color:${text};`;
|
||||
const S_RUECK = aktStil(ACCENT, 'rgba(30,80,165,.10)', ACCENT);
|
||||
const S_ANGEBOT = aktStil('#e07b00', 'rgba(224,123,0,.12)', '#b35f00');
|
||||
const S_MUSTER = aktStil('#6b3fa0', 'rgba(107,63,160,.12)', '#5a2f90');
|
||||
const S_MAIL = aktStil('#0b7285', 'rgba(11,114,133,.12)', '#0b6273');
|
||||
const aktionsLeiste = h('div', { class: 'sk-akt' }, [
|
||||
h('button', { type: 'button', style: rueckruf.open ? aktStil(ACCENT, ACCENT, '#fff') : S_RUECK, onClick: () => { rueckruf.open = !rueckruf.open; if (rueckruf.open && !rueckruf.date) rueckruf.date = ymd(isoDatum(1)); } }, '📞 Rückruf planen'),
|
||||
h('button', { type: 'button', style: S_ANGEBOT, onClick: () => sendeAngebot(k) }, '📧 Angebot senden'),
|
||||
h('button', { type: 'button', style: S_MUSTER, onClick: () => sendeMuster(k) }, '🧪 Muster senden'),
|
||||
h('button', { type: 'button', style: mailMich.open ? aktStil('#0b7285', '#0b7285', '#fff') : S_MAIL, onClick: () => { mailMich.open = !mailMich.open; } }, '✉️ Mail an mich'),
|
||||
]);
|
||||
|
||||
const preset = (label, d) => h('button', { type: 'button',
|
||||
style: 'cursor:pointer;border-radius:20px;padding:6px 12px;font:inherit;font-size:13px;border:1px solid var(--border-normal);'
|
||||
+ (rueckruf.date === ymd(isoDatum(d)) ? `background:${ACCENT};color:#fff;` : 'background:var(--background-page);color:var(--foreground-normal);'),
|
||||
onClick: () => { rueckruf.date = ymd(isoDatum(d)); } }, label);
|
||||
const rueckrufForm = rueckruf.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
|
||||
h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;' }, [preset('Morgen', 1), preset('In 3 Tagen', 3), preset('Nächste Woche', 7)]),
|
||||
h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;' }, [
|
||||
h('div', { style: 'flex:2;min-width:150px;' }, feld('Datum', h('input', { type: 'date', value: rueckruf.date, style: S_INPUT, onInput: (e) => { rueckruf.date = e.target.value; } }))),
|
||||
h('div', { style: 'flex:1;min-width:110px;' }, feld('Uhrzeit', h('input', { type: 'time', value: rueckruf.time, style: S_INPUT, onInput: (e) => { rueckruf.time = e.target.value; } }))),
|
||||
]),
|
||||
h('div', { style: 'display:flex;gap:8px;' }, [btn('Erinnerung setzen', () => planeRueckruf(k), 'primary'), btn('Abbrechen', () => { rueckruf.open = false; }, 'ghost')]),
|
||||
]) : null;
|
||||
|
||||
const mailForm = mailMich.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
|
||||
feld('Notiz an mich', h('textarea', { value: mailMich.note, rows: 3, placeholder: 'Was soll dir per Mail zugehen? (Eckdaten kommen automatisch mit)', style: S_INPUT + 'resize:vertical;', onInput: (e) => { mailMich.note = e.target.value; } })),
|
||||
h('div', { style: 'display:flex;gap:8px;' }, [btn('Mail an mich senden', () => sendeMail(k), 'primary'), btn('Abbrechen', () => { mailMich.open = false; }, 'ghost')]),
|
||||
]) : null;
|
||||
|
||||
const offene = aktionen.value.filter((a) => a.type === 'rueckruf' && a.status === 'offen');
|
||||
const offeneListe = offene.length ? h('div', { style: 'margin-bottom:12px;display:flex;flex-direction:column;gap:6px;' }, offene.map((a) => h('div', {
|
||||
style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;border:1px solid var(--border-normal);border-radius:8px;padding:8px 12px;background:var(--background-page);',
|
||||
}, [
|
||||
h('div', { style: 'font-size:13px;' }, [
|
||||
h('span', { style: 'font-weight:600;' }, '📞 Rückruf ' + (a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '')),
|
||||
a.note ? h('div', { style: 'color:var(--foreground-subdued);margin-top:2px;white-space:pre-wrap;' }, a.note) : null,
|
||||
]),
|
||||
btn('✓ erledigt', () => erledige(a, k), 'default'),
|
||||
]))) : null;
|
||||
|
||||
const aktionenBlock = h('div', { style: 'margin-bottom:14px;' }, [
|
||||
h('div', { style: LBL }, 'Aktionen'), aktionsLeiste, rueckrufForm, mailForm, offeneListe,
|
||||
]);
|
||||
|
||||
return [navRow, h('div', { style: CARD }, [kopf, anruf, notizFeld, statusFeld, aktionenBlock, telFeld, mailFeld, namensFelder])];
|
||||
}
|
||||
|
||||
return () => {
|
||||
const inhalt = h('div', { style: 'max-width:760px;margin:0 auto;padding:20px 16px 60px;' }, [
|
||||
// Aktions-Buttons: am Desktop nebeneinander (gleich breit), am Handy gestapelt in voller Breite.
|
||||
h('style', {}, '.sk-akt{display:flex;gap:8px;flex-wrap:wrap;}.sk-akt>*{flex:1 1 auto;justify-content:center;}@media(max-width:600px){.sk-akt{flex-direction:column;}.sk-akt>*{width:100%;flex:none;}}'),
|
||||
fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, fehler.value) : null,
|
||||
meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, meldung.value) : null,
|
||||
aktivId.value ? detailAnsicht() : listenAnsicht(),
|
||||
]);
|
||||
return h(resolveComponent('private-view'), { title: 'Kontakte' }, {
|
||||
default: () => h('div', { style: 'min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [inhalt]),
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
id: 'skrift-kontakte',
|
||||
name: 'Kontakte',
|
||||
icon: 'contacts',
|
||||
routes: [{ path: '', component: Board }],
|
||||
};
|
||||
13
extensions/directus-extension-skrift-kontakte/package.json
Normal file
13
extensions/directus-extension-skrift-kontakte/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-kontakte",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Modul: Kontakte/Leads – handytaugliches Mini-CRM (Anrufen via GroundWire, Notizen, Durchblättern, CSV-Export).",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
291
extensions/directus-extension-skrift-mailer/dist/index.js
vendored
Normal file
291
extensions/directus-extension-skrift-mailer/dist/index.js
vendored
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Skrift – Mail-Hook
|
||||
* ---------------------------------------------------------------------------
|
||||
* Versendet Kunden-E-Mails auf Basis editierbarer Vorlagen (`email_templates`)
|
||||
* mit gemeinsamer Signatur/Footer (`email_settings`) und optionalem AGB-Anhang.
|
||||
*
|
||||
* orders.items.create → Vorlage „order_created" (reiche Bestellbestätigung)
|
||||
* orders.items.update → Vorlage mit dem Schlüssel des neuen Status
|
||||
* (z. B. „bezahlt", „versendet"), falls vorhanden+aktiv
|
||||
*
|
||||
* Der Login-Code wird bewusst in skrift-auth versendet (dort entsteht er).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const STATUS_LABEL = {
|
||||
wartet_auf_zahlung: 'Wartet auf Zahlung', bezahlt: 'Bezahlt',
|
||||
material_erwartet: 'Material erwartet', material_eingegangen: 'Material eingegangen',
|
||||
in_queue: 'In Warteschlange', in_produktion: 'In Produktion', gedruckt: 'Gedruckt',
|
||||
versendet: 'Versendet', abgeschlossen: 'Abgeschlossen', storniert: 'Storniert',
|
||||
};
|
||||
const ENVELOPE_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
|
||||
const LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' };
|
||||
const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
|
||||
|
||||
// Eingebaute Fallback-Vorlagen: greifen NUR, wenn in `email_templates` keine
|
||||
// aktive Vorlage mit passendem Schlüssel existiert. So gehen Bestätigung und
|
||||
// die kundenrelevanten Status-Mails auch ohne konfigurierte Vorlage sicher raus
|
||||
// (analog zum Login-Fallback in skrift-auth). Interne Status (in_queue, gedruckt …)
|
||||
// haben bewusst KEINEN Fallback → für die braucht es eine aktive Vorlage.
|
||||
const FALLBACK_TEMPLATES = {
|
||||
order_created: {
|
||||
subject: 'Bestellbestätigung {{order_id}}',
|
||||
body: `<p>Hallo {{name}},</p>
|
||||
<p>vielen Dank für Ihre Bestellung <strong>{{order_id}}</strong>. Wir haben Ihren Auftrag erhalten und melden uns mit den nächsten Schritten.</p>`,
|
||||
},
|
||||
bezahlt: {
|
||||
subject: 'Zahlung erhalten – Bestellung {{order_id}}',
|
||||
body: `<p>Hallo {{name}},</p>
|
||||
<p>Ihre Zahlung zur Bestellung <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten nun mit der Bearbeitung.</p>`,
|
||||
},
|
||||
versendet: {
|
||||
subject: 'Ihre Bestellung {{order_id}} ist unterwegs',
|
||||
body: `<p>Hallo {{name}},</p>
|
||||
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde versendet.</p>`,
|
||||
},
|
||||
abgeschlossen: {
|
||||
subject: 'Bestellung {{order_id}} abgeschlossen',
|
||||
body: `<p>Hallo {{name}},</p>
|
||||
<p>Ihre Bestellung <strong>{{order_id}}</strong> ist abgeschlossen. Vielen Dank für Ihr Vertrauen!</p>`,
|
||||
},
|
||||
storniert: {
|
||||
subject: 'Bestellung {{order_id}} storniert',
|
||||
body: `<p>Hallo {{name}},</p>
|
||||
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde storniert. Bei Fragen antworten Sie einfach auf diese E-Mail.</p>`,
|
||||
},
|
||||
};
|
||||
|
||||
const render = (str, vars) =>
|
||||
String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ''));
|
||||
const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ',');
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||||
|
||||
// Antwortadresse für Kundenmails – echte, überwachte Adresse statt „noreply".
|
||||
const REPLY_TO = 'hello@skrift.de';
|
||||
|
||||
/** Grobe Text-Alternative aus dem HTML. Reine HTML-Mails ohne Text-Teil kosten
|
||||
* bei Spamfiltern (SpamAssassin) sichere Punkte – der Text-Part senkt die Wertung. */
|
||||
const htmlZuText = (html) => String(html || '')
|
||||
.replace(/<\s*br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
/** Formatiert einen Adress-Snapshot (billing/shipping) als HTML. */
|
||||
function adresseHtml(a) {
|
||||
if (!a || typeof a !== 'object') return '';
|
||||
const zeilen = [];
|
||||
if (a.company) zeilen.push(`<strong>${esc(a.company)}</strong>`);
|
||||
zeilen.push(esc([a.first_name, a.last_name].filter(Boolean).join(' ')));
|
||||
if (a.street) zeilen.push(esc(a.street));
|
||||
zeilen.push(esc([a.zip, a.city].filter(Boolean).join(' ')));
|
||||
if (a.country) zeilen.push(esc(a.country));
|
||||
let html = zeilen.filter(Boolean).join('<br>');
|
||||
if (a.email) html += `<br><br>E-Mail: ${esc(a.email)}`;
|
||||
if (a.phone) html += `<br>Telefon: ${esc(a.phone)}`;
|
||||
return html;
|
||||
}
|
||||
|
||||
export default (register, { services, getSchema, logger }) => {
|
||||
const { ItemsService, MailService } = services;
|
||||
|
||||
async function loadTemplate(key, schema) {
|
||||
const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
|
||||
filter: { key: { _eq: key }, enabled: { _eq: true } },
|
||||
limit: 1, fields: ['subject', 'body', 'append_signature', 'attach_agb'],
|
||||
});
|
||||
return rows?.[0] || null;
|
||||
}
|
||||
|
||||
async function loadSettings(schema) {
|
||||
try {
|
||||
return await new ItemsService('email_settings', { schema, accountability: null })
|
||||
.readSingleton({ fields: ['signature', 'portal_url', 'company_footer', 'agb_file', 'sender_name', 'sender_email'] });
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
/** Konfigurierter Team-Empfänger (separat gelesen, damit ein evtl. noch nicht
|
||||
* gebootstraptes Feld die Kundenmails nicht beeinträchtigt). */
|
||||
async function teamEmpfaenger(schema) {
|
||||
try {
|
||||
const es = await new ItemsService('email_settings', { schema, accountability: null })
|
||||
.readSingleton({ fields: ['team_email'] });
|
||||
return (es?.team_email || '').trim();
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
/** Schlanke interne Benachrichtigung „Neue Bestellung …" (Empfänger konfigurierbar). */
|
||||
async function teamNotify(orderId) {
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const to = await teamEmpfaenger(schema);
|
||||
if (!to) return;
|
||||
const settings = await loadSettings(schema);
|
||||
const order = await new ItemsService('orders', { schema, accountability: null })
|
||||
.readOne(orderId, { fields: ['order_number', 'product.name', 'source'] });
|
||||
if (!order) return;
|
||||
// API-Aufträge NICHT einzeln melden – dafür gibt es die Tagesübersicht (18:00).
|
||||
if (order.source === 'api') return;
|
||||
const html = `<p>Neue Bestellung <strong>${order.order_number}</strong>${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".</p>`;
|
||||
const nachricht = {
|
||||
to,
|
||||
subject: `Neue Bestellung ${order.order_number}`,
|
||||
html,
|
||||
text: htmlZuText(html),
|
||||
replyTo: REPLY_TO,
|
||||
};
|
||||
if (settings.sender_email) {
|
||||
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||||
}
|
||||
await new MailService({ schema, accountability: null }).send(nachricht);
|
||||
} catch (e) {
|
||||
logger.warn(`[skrift-mailer] Team-Benachrichtigung: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */
|
||||
async function agbAnhang(schema, settings) {
|
||||
if (!settings?.agb_file) return null;
|
||||
try {
|
||||
const file = await new ItemsService('directus_files', { schema, accountability: null })
|
||||
.readOne(settings.agb_file, { fields: ['filename_disk', 'filename_download', 'type'] });
|
||||
if (!file?.filename_disk) return null;
|
||||
const buf = fs.readFileSync(path.join(UPLOAD_ROOT, file.filename_disk));
|
||||
return { filename: file.filename_download || 'AGB.pdf', content: buf, contentType: file.type || 'application/pdf' };
|
||||
} catch (e) {
|
||||
logger.warn(`[skrift-mailer] AGB-Anhang übersprungen: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendForOrder(orderId, key, extraVars = {}) {
|
||||
const schema = await getSchema();
|
||||
const tpl = await loadTemplate(key, schema) || FALLBACK_TEMPLATES[key];
|
||||
if (!tpl) return; // weder aktive Vorlage noch Fallback → keine Mail
|
||||
|
||||
const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, {
|
||||
fields: [
|
||||
'order_number', 'customer_email', 'entries_count', 'shipping_type',
|
||||
'needs_envelope', 'envelope_labeling', 'envelope_format',
|
||||
'net_total', 'vat_amount', 'gross_total', 'billing', 'shipping_address',
|
||||
'customer.first_name', 'customer.last_name', 'customer.company',
|
||||
'product.name', 'product.input_mode', 'format.name',
|
||||
],
|
||||
});
|
||||
if (!order?.customer_email) return;
|
||||
// Datei-Produkte (Unterschriftenservice) erhalten KEINE Standard-Bestell-
|
||||
// bestätigung – dafür geht eine eigene Vorlage aus dem Bestell-Endpunkt raus.
|
||||
if (key === 'order_created' && order.product?.input_mode === 'datei') return;
|
||||
|
||||
const settings = await loadSettings(schema);
|
||||
const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ')
|
||||
|| order.customer?.company || '';
|
||||
|
||||
const net = Number(order.net_total) || 0;
|
||||
const vat = Number(order.vat_amount) || 0;
|
||||
const mwstSatz = net > 0 ? Math.round((vat / net) * 100) : 19;
|
||||
|
||||
const kuvertBlock = (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine')
|
||||
? `<table role="presentation" width="100%" style="background:#fafafa;border-radius:6px;margin-top:12px;"><tr><td style="padding:12px 15px;">
|
||||
<p style="margin:0 0 6px;font-weight:600;color:#1a365d;">Umschlag</p>
|
||||
<p style="margin:0;font-size:14px;color:#666;">Format: ${ENVELOPE_LABEL[order.envelope_format] || '—'} · Beschriftung: ${LABELING_LABEL[order.envelope_labeling] || '—'}</p>
|
||||
</td></tr></table>`
|
||||
: '';
|
||||
|
||||
const liefer = adresseHtml(order.shipping_address);
|
||||
const lieferBlock = liefer
|
||||
? `<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Lieferadresse</h2><p style="margin:0;line-height:1.6;">${liefer}</p></td></tr>`
|
||||
: '';
|
||||
|
||||
const vars = {
|
||||
order_id: order.order_number, name, link: settings.portal_url || '',
|
||||
product: esc(order.product?.name || ''), menge: order.entries_count ?? '',
|
||||
format: esc(order.format?.name || ''),
|
||||
versand: order.shipping_type === 'einzeln' ? 'Einzelversand durch Skrift' : 'Sammellieferung',
|
||||
kuvert_block: kuvertBlock,
|
||||
netto: eur(net), mwst_satz: mwstSatz, mwst_betrag: eur(vat), brutto: eur(order.gross_total), waehrung: 'EUR',
|
||||
rechnungsadresse: adresseHtml(order.billing),
|
||||
lieferadresse_block: lieferBlock,
|
||||
footer: settings.company_footer || '',
|
||||
...extraVars,
|
||||
};
|
||||
|
||||
let html = render(tpl.body, vars);
|
||||
if (tpl.append_signature !== false && settings.signature) html += render(settings.signature, vars);
|
||||
|
||||
const nachricht = { to: order.customer_email, subject: render(tpl.subject, vars), html, text: htmlZuText(html), replyTo: REPLY_TO };
|
||||
// Absender aus den Einstellungen (z. B. „Skrift <noreply@skrift.de>").
|
||||
if (settings.sender_email) {
|
||||
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||||
}
|
||||
if (tpl.attach_agb) {
|
||||
const anhang = await agbAnhang(schema, settings);
|
||||
if (anhang) nachricht.attachments = [anhang];
|
||||
}
|
||||
await new MailService({ schema, accountability: null }).send(nachricht);
|
||||
logger.info(`[skrift-mailer] „${key}" an ${order.customer_email} (${order.order_number}).`);
|
||||
}
|
||||
|
||||
register.action('orders.items.create', async ({ key, payload }) => {
|
||||
if (payload?.status === 'entwurf') return;
|
||||
try { await sendForOrder(key, 'order_created'); }
|
||||
catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); }
|
||||
// Schlanke interne Benachrichtigung „Neue Bestellung …" an das Team.
|
||||
await teamNotify(key);
|
||||
});
|
||||
|
||||
register.action('orders.items.update', async ({ keys, payload }) => {
|
||||
if (!payload || payload.status === undefined) return;
|
||||
const status = payload.status;
|
||||
for (const id of keys || []) {
|
||||
try { await sendForOrder(id, status, { status: STATUS_LABEL[status] || status }); }
|
||||
catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tagesübersicht der API-Aufträge (statt Einzelmail je Auftrag) ───────────
|
||||
// Einmal täglich (Default 18:00): pro API-Kunde eine Zusammenfassung der heute
|
||||
// eingegangenen Aufträge, aufgeschlüsselt nach Versandtag.
|
||||
const API_DIGEST_CRON = process.env.SKRIFT_API_DIGEST_CRON || '0 18 * * *';
|
||||
register.schedule(API_DIGEST_CRON, async () => {
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const to = await teamEmpfaenger(schema);
|
||||
if (!to) return;
|
||||
const settings = await loadSettings(schema);
|
||||
const start = new Date(); start.setHours(0, 0, 0, 0);
|
||||
const orders = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
|
||||
filter: { _and: [{ source: { _eq: 'api' } }, { date_created: { _gte: start.toISOString() } }] },
|
||||
fields: ['order_number', 'shipping_day', 'api_client.id', 'api_client.name', 'product.name'],
|
||||
limit: -1, sort: ['order_number'],
|
||||
});
|
||||
if (!orders || !orders.length) return;
|
||||
|
||||
const byClient = new Map();
|
||||
for (const o of orders) {
|
||||
const kid = o.api_client?.id ?? 'x';
|
||||
let g = byClient.get(kid);
|
||||
if (!g) { g = { name: o.api_client?.name || 'API-Kunde', orders: [] }; byClient.set(kid, g); }
|
||||
g.orders.push(o);
|
||||
}
|
||||
for (const g of byClient.values()) {
|
||||
const byDay = {};
|
||||
for (const o of g.orders) { const d = o.shipping_day || 'ohne Versandtag'; byDay[d] = (byDay[d] || 0) + 1; }
|
||||
const zeilen = Object.entries(byDay).map(([d, n]) => `<li>${esc(d)}: <strong>${n}</strong></li>`).join('');
|
||||
const html = `<p>Heute über die API eingegangen von <strong>${esc(g.name)}</strong>: <strong>${g.orders.length}</strong> Auftrag/Aufträge.</p>`
|
||||
+ `<p>Nach Versandtag:</p><ul>${zeilen}</ul>`
|
||||
+ '<p>Im Produktions-Board unter „Neu" als Charge(n) gruppiert.</p>';
|
||||
const nachricht = { to, subject: `API-Tagesübersicht ${g.name} – ${g.orders.length} neue`, html, text: htmlZuText(html), replyTo: REPLY_TO };
|
||||
if (settings.sender_email) nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||||
try { await new MailService({ schema, accountability: null }).send(nachricht); }
|
||||
catch (e) { logger.warn(`[skrift-mailer] API-Tagesmail ${g.name}: ${e.message}`); }
|
||||
}
|
||||
logger.info(`[skrift-mailer] API-Tagesübersicht verschickt: ${byClient.size} Kunde(n)`);
|
||||
} catch (e) { logger.warn(`[skrift-mailer] API-Tageslauf: ${e.message}`); }
|
||||
});
|
||||
};
|
||||
13
extensions/directus-extension-skrift-mailer/package.json
Normal file
13
extensions/directus-extension-skrift-mailer/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-mailer",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Versendet E-Mails (Bestellbestätigung + Statusänderungen) anhand editierbarer Vorlagen (email_templates + email_settings).",
|
||||
"directus:extension": {
|
||||
"type": "hook",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
720
extensions/directus-extension-skrift-neuauftrag/dist/index.js
vendored
Normal file
720
extensions/directus-extension-skrift-neuauftrag/dist/index.js
vendored
Normal file
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* Skrift – Modul „Neuer Auftrag"
|
||||
* ===========================================================================
|
||||
* Eigenständige, manuelle Auftragsanlage im Admin (eigenes Modul, kein Teil des
|
||||
* Produktions-Boards). Nutzt ausschließlich die zentralen Backend-Endpunkte:
|
||||
* POST /skrift-orders/preview – Vorschau (Admin ohne Kontingent)
|
||||
* POST /skrift-orders/manual-order – Auftrag intern anlegen (keine Zahlung)
|
||||
* Der Auftrag erscheint danach als „eingegangen" im Produktions-Board.
|
||||
*
|
||||
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||
*/
|
||||
import { defineComponent, ref, reactive, onMounted, h, resolveComponent, Teleport } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const ACCENT = '#1E50A5';
|
||||
// Weißes, randloses Eingabefeld – hebt sich durch Kontrast + leichten Schatten
|
||||
// vom hellblauen Bereich ab (kein Rahmen).
|
||||
const WFELD = 'background:#fff;border:none;border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;color:#1A1A1A;outline:none;box-shadow:0 1px 2px rgba(0,0,0,.08);';
|
||||
const WSELECT = WFELD + 'cursor:pointer;';
|
||||
// Hellblauer Bereich (kein Rahmen).
|
||||
const BEREICH = 'background:rgba(30,80,165,.06);border-radius:12px;padding:18px 20px;';
|
||||
const STANDARD_PLATZHALTER = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land', 'Briefnummer'];
|
||||
|
||||
function btn(label, onClick, kind) {
|
||||
const base = 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;';
|
||||
const style = kind === 'primary' ? base + `background:${ACCENT};color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15);`
|
||||
: base + `background:#fff;color:${ACCENT};box-shadow:0 1px 2px rgba(0,0,0,.1);`;
|
||||
return h('button', { type: 'button', onClick, style }, label);
|
||||
}
|
||||
function subdued(t, extra) { return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, t); }
|
||||
function segToggle(value, opts, onSel) {
|
||||
return h('div', { style: 'display:flex;width:100%;border-radius:8px;overflow:hidden;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.08);' },
|
||||
opts.map((o) => h('button', {
|
||||
type: 'button', onClick: () => onSel(o.key),
|
||||
style: `flex:1;padding:10px 8px;font:inherit;font-size:13px;text-align:center;cursor:pointer;border:none;`
|
||||
+ (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : `background:transparent;color:${ACCENT};`),
|
||||
}, o.label)));
|
||||
}
|
||||
function bereichKopf(txt) { return h('div', { style: `font-size:15px;font-weight:800;color:${ACCENT};margin-bottom:14px;` }, txt); }
|
||||
function label(txt) { return h('label', { style: 'display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);margin-bottom:4px;' }, txt); }
|
||||
|
||||
const NeuAuftrag = defineComponent({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const produkte = ref([]);
|
||||
const formate = ref([]);
|
||||
const verbotenRe = ref(null);
|
||||
const musterTexte = ref({ text_du: '', text_sie: '' });
|
||||
// Frei anlegbare Mustertexte (Collection muster_texts). Fallback: Du/Sie aus muster_settings.
|
||||
const musterListe = ref([]);
|
||||
// Vorhandene Kundensignaturen (für „Unterschrift auf den Brief setzen").
|
||||
const signaturListe = ref([]);
|
||||
const geladen = ref(false);
|
||||
// Aktives Unterwerkzeug: 'neu' (manueller Druck) oder 'signatur' (Unterschriftenservice).
|
||||
const ansicht = ref('neu');
|
||||
// Unterschriftenservice: Auftrag laden → Dokument + Kundensignaturen wählen →
|
||||
// Vorschau (Marker + Offsets) → A4-SVGs herunterladen.
|
||||
const sig = reactive({
|
||||
modus: 'auftrag', // 'auftrag' (aus Auftrag laden) oder 'manuell' (direkt hochladen)
|
||||
orderNr: '', order: null, docs: [], docId: null, docName: '', signaturen: [],
|
||||
laden: false, pdf: '', msg: '', err: '',
|
||||
});
|
||||
// Datei nach Directus hochladen (liefert die Datei-ID).
|
||||
async function sigUpload(file) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api.post('/files', fd);
|
||||
return res.data?.data?.id;
|
||||
}
|
||||
|
||||
const leereZeile = () => ({ free_text: '', placeholders: {} });
|
||||
const neu = reactive({
|
||||
product: '', format: '', font: 'tilda',
|
||||
// Relative Schriftgröße in % (getrennt Brief/Kuvert); 100 = Standard.
|
||||
fontSizeLetter: 100, fontSizeEnvelope: 100,
|
||||
// Unterschrift auf dem Brief ([[signatur1]]): SVG-Inhalt + Feinjustierung.
|
||||
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100,
|
||||
// Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).
|
||||
mehrseitig: false, seitenInfo: '',
|
||||
// Kuvert-Adresse je Empfänger: '5zeilen' (5 Zeilen) oder 'individuell' (einzeilig).
|
||||
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
|
||||
importOpen: false, importText: '',
|
||||
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
|
||||
});
|
||||
// Kuverts nur generieren, wenn angehakt (Beschriftung aus der Empfängeradresse).
|
||||
const kuvertPayload = () => (neu.kuverts
|
||||
? { needs_envelope: true, envelope_labeling: 'empfaenger', envelope_text: '' }
|
||||
: { needs_envelope: false, envelope_labeling: 'keine', envelope_text: '' });
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const [p, f, ps, mt, mtx, sg] = await Promise.all([
|
||||
api.get('/items/products', { params: { limit: -1, filter: { active: { _eq: true }, type: { _in: ['letter', 'postcard'] } }, fields: 'key,name,type' } }),
|
||||
api.get('/items/formats', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'key,name,product.key' } }),
|
||||
api.get('/items/pricing_settings', { params: { fields: 'char_whitelist' } }).catch(() => ({ data: { data: {} } })),
|
||||
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: {} } })),
|
||||
api.get('/items/muster_texts', { params: { limit: -1, filter: { active: { _eq: true } }, sort: 'sort', fields: 'id,label,text' } }).catch(() => ({ data: { data: [] } })),
|
||||
api.get('/items/signatures', { params: { limit: -1, sort: '-date_created', fields: 'id,label,file,customer.first_name,customer.last_name' } }).catch(() => ({ data: { data: [] } })),
|
||||
]);
|
||||
produkte.value = p.data?.data || [];
|
||||
formate.value = f.data?.data || [];
|
||||
musterTexte.value = mt.data?.data || { text_du: '', text_sie: '' };
|
||||
musterListe.value = mtx.data?.data || [];
|
||||
signaturListe.value = sg.data?.data || [];
|
||||
const wl = ps.data?.data?.char_whitelist;
|
||||
// Zeilenumbrüche immer erlauben (Brieftext ist mehrzeilig). Einzeilige
|
||||
// Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. Tabs (\t)
|
||||
// sind erlaubt, damit man z.B. das Datum an den rechten Rand rücken kann.
|
||||
try { verbotenRe.value = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null; } catch { verbotenRe.value = null; }
|
||||
neu.product = produkte.value[0]?.key || 'briefe';
|
||||
neu.format = neuFormate()[0]?.key || '';
|
||||
} catch (e) {
|
||||
neu.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
|
||||
} finally { geladen.value = true; }
|
||||
}
|
||||
onMounted(laden);
|
||||
|
||||
const saeubere = (v) => {
|
||||
if (!verbotenRe.value) return v;
|
||||
const treffer = String(v).match(verbotenRe.value);
|
||||
if (!treffer || !treffer.length) return v;
|
||||
const einmalig = [...new Set(treffer)].map((c) => (c === ' ' ? '␣' : c)).join(' ');
|
||||
neu.hinweis = `Zeichen ${einmalig} ist nicht erlaubt und wurde entfernt.`;
|
||||
setTimeout(() => { if (neu.hinweis.includes(einmalig)) neu.hinweis = ''; }, 4000);
|
||||
return String(v).replace(verbotenRe.value, '');
|
||||
};
|
||||
// Alle aktiven Formate zur Auswahl (nicht aufs Produkt eingeschränkt) –
|
||||
// so lassen sich auch freie/eigene Formate direkt wählen.
|
||||
const neuFormate = () => formate.value;
|
||||
// Wie viele Adresszeilen je Empfänger: 5 („5 Zeilen") bzw. 1 („Individueller Text").
|
||||
const zeilenAnzahl = () => (neu.adressModus === 'individuell' ? 1 : 5);
|
||||
const freitext = (e, z) => (String(e.free_text || '').split('\n')[z] ?? '');
|
||||
const setFreitext = (i, z, v) => {
|
||||
const max = zeilenAnzahl();
|
||||
const zeilen = String(neu.rows[i].free_text || '').split('\n');
|
||||
while (zeilen.length < max) zeilen.push('');
|
||||
zeilen[z] = saeubere(String(v).replace(/[\r\n]/g, ''));
|
||||
neu.rows[i].free_text = zeilen.slice(0, max).join('\n').replace(/\n+$/, '');
|
||||
neu.previewSession = null;
|
||||
};
|
||||
// Individuelle Platzhalter je Empfänger (z. B. [[Gutschein]]).
|
||||
const setPlatzhalter = (i, name, v) => {
|
||||
neu.rows[i].placeholders = { ...(neu.rows[i].placeholders || {}), [name]: saeubere(v) };
|
||||
neu.previewSession = null;
|
||||
};
|
||||
// Spalten-Einfügen: eine ganze Excel-Spalte (Strg+V) in EINE Zelle einfügen.
|
||||
// Zeilenumbrüche trennen die Empfänger; Kommata bleiben Teil des Werts (kein
|
||||
// Trennzeichen). Legt automatisch so viele Empfänger an wie eingefügte Zeilen
|
||||
// und füllt die Spalte ab der aktuellen Zeile nach unten.
|
||||
// Rückgabe true = mehrzeilig eingefügt (Default-Paste unterdrücken).
|
||||
const spalteEinfuegen = (startI, apply, text) => {
|
||||
const werte = String(text || '').replace(/\r\n?/g, '\n').split('\n');
|
||||
while (werte.length > 1 && werte[werte.length - 1].trim() === '') werte.pop();
|
||||
if (werte.length <= 1) return false; // Einzelwert → normales Einfügen zulassen
|
||||
while (neu.rows.length < startI + werte.length) neu.rows.push(leereZeile());
|
||||
werte.forEach((w, k) => apply(startI + k, w));
|
||||
return true;
|
||||
};
|
||||
// Platzhalter aus dem Brieftext ziehen (ohne den automatischen [[Briefnummer]]).
|
||||
const customPlaceholders = () => {
|
||||
const set = new Set();
|
||||
for (const m of String(neu.text || '').matchAll(/\[\[([^\]]+)\]\]/g)) {
|
||||
const name = m[1].trim();
|
||||
if (name && name.toLowerCase() !== 'briefnummer') set.add(name);
|
||||
}
|
||||
return [...set];
|
||||
};
|
||||
const addZeile = () => neu.rows.push(leereZeile());
|
||||
const delZeile = (i) => { neu.rows.splice(i, 1); if (!neu.rows.length) neu.rows.push(leereZeile()); };
|
||||
// Empfänger zählt, wenn eine Adresszeile ODER ein Platzhalterwert gefüllt ist.
|
||||
const entries = () => neu.rows.filter((e) => String(e.free_text || '').trim()
|
||||
|| (e.placeholders && Object.values(e.placeholders).some((v) => String(v || '').trim())));
|
||||
|
||||
// Aus Excel/CSV eingefügte Zeilen übernehmen. Erst die individuellen
|
||||
// Platzhalter-Spalten, dann die Adresszeilen (Tab/Semikolon/Komma-getrennt).
|
||||
function importUebernehmen() {
|
||||
const ph = customPlaceholders();
|
||||
const max = zeilenAnzahl();
|
||||
const zeilen = String(neu.importText || '').split(/\r?\n/).map((z) => z.trim()).filter(Boolean);
|
||||
if (!zeilen.length) { neu.importOpen = false; return; }
|
||||
const rows = zeilen.map((z) => {
|
||||
const zellen = z.split(/\t|;|,/).map((c) => saeubere(c.trim()));
|
||||
const row = leereZeile();
|
||||
ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; });
|
||||
row.free_text = zellen.slice(ph.length, ph.length + max).join('\n');
|
||||
return row;
|
||||
});
|
||||
neu.rows = rows;
|
||||
neu.importText = ''; neu.importOpen = false; neu.previewSession = null;
|
||||
}
|
||||
|
||||
// ── Unterschrift auf dem Brief ([[signatur1]]) ────────────────────────────
|
||||
// Sorgt dafür, dass der Platzhalter im Text steht (sonst rendert keine Signatur).
|
||||
function sigEinfuegen() {
|
||||
if (!neu.text.includes('[[signatur1]]')) neu.text = `${neu.text.trimEnd()}\n[[signatur1]]`;
|
||||
neu.previewSession = null;
|
||||
}
|
||||
async function sigWaehlen(id) {
|
||||
neu.sigSvg = ''; neu.sigLabel = '';
|
||||
if (!id) { neu.previewSession = null; return; }
|
||||
const s = signaturListe.value.find((x) => String(x.id) === String(id));
|
||||
if (!s?.file) { neu.err = 'Diese Signatur hat keine SVG-Datei.'; return; }
|
||||
try {
|
||||
const res = await api.get(`/assets/${s.file}`, { responseType: 'text' });
|
||||
neu.sigSvg = typeof res.data === 'string' ? res.data : String(res.data || '');
|
||||
neu.sigLabel = s.label || 'Unterschrift';
|
||||
sigEinfuegen();
|
||||
} catch { neu.err = 'Signatur konnte nicht geladen werden.'; }
|
||||
}
|
||||
function sigDatei(e) {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
if (!file) return;
|
||||
const r = new FileReader();
|
||||
r.onload = () => { neu.sigSvg = String(r.result || ''); neu.sigLabel = file.name; sigEinfuegen(); };
|
||||
r.readAsText(file);
|
||||
}
|
||||
function sigEntfernen() {
|
||||
neu.sigSvg = ''; neu.sigLabel = '';
|
||||
neu.text = neu.text.replace(/\n?\[\[signatur1\]\]/g, '');
|
||||
neu.previewSession = null;
|
||||
}
|
||||
// Signatur-Anteil der Payloads (Vorschau + Anlage). „brief"-Präfix, weil es
|
||||
// bereits ein sigPayload() für den Unterschriften-Service (PDF) gibt.
|
||||
const briefSigPayload = () => (neu.sigSvg
|
||||
? { signatures: { signatur1: neu.sigSvg },
|
||||
signature_offsets: { signatur1: { x: Number(neu.sigX) || 0, y: Number(neu.sigY) || 0, scale: (Number(neu.sigScale) || 100) / 100 } } }
|
||||
: {});
|
||||
|
||||
async function preview() {
|
||||
// Vorschau ohne Empfänger erlaubt – das Backend nutzt dann eine Blankozeile.
|
||||
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
|
||||
neu.pLaedt = true; neu.err = ''; neu.msg = '';
|
||||
try {
|
||||
const kv = kuvertPayload();
|
||||
const res = await api.post('/skrift-orders/preview', {
|
||||
text: neu.text, font: neu.font, format: neu.format, realistic: neu.realistic,
|
||||
envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
|
||||
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
|
||||
multi_page: neu.mehrseitig,
|
||||
...briefSigPayload(),
|
||||
entries: entries().slice(0, 3),
|
||||
});
|
||||
const d = res.data || {};
|
||||
if (d.has_overflow) {
|
||||
// Zeigen, wie viele Zeilen zu lang (aus dem ersten überlaufenden Schriftstück).
|
||||
const of = (d.overflow || [])[0];
|
||||
const zuViel = of ? Math.max(1, (Number(of.lineCount) || 0) - (Number(of.lineLimit) || 0)) : 0;
|
||||
neu.err = of
|
||||
? `Text ist ${zuViel} Zeile${zuViel === 1 ? '' : 'n'} zu lang – ${of.lineCount} statt max. ${of.lineLimit} Zeilen. Bitte kürzen.`
|
||||
: 'Text ist zu lang für das Format – bitte kürzen.';
|
||||
neu.docs = []; neu.previewSession = null;
|
||||
} else { neu.docs = d.docs || d.previews || []; neu.previewSession = d.session || d.sessionId || null; }
|
||||
} catch (e) { neu.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; }
|
||||
finally { neu.pLaedt = false; }
|
||||
}
|
||||
// Seiten je Schriftstück berechnen (mehrseitig) und nach Seitenzahl gruppieren:
|
||||
// „2 Seiten: 1, 3, 4" · „3 Seiten: 2, 6, 7" …
|
||||
async function seitenBerechnen() {
|
||||
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
|
||||
neu.seitenInfo = 'Berechne …';
|
||||
try {
|
||||
const res = await api.post('/skrift-orders/pagecount', {
|
||||
text: neu.text, format: neu.format,
|
||||
font_scale_letter: neu.fontSizeLetter / 100,
|
||||
entries: entries(),
|
||||
});
|
||||
const counts = res.data?.counts || [];
|
||||
const gruppen = new Map();
|
||||
counts.forEach((c) => { const g = gruppen.get(c.pages) || []; g.push(c.index); gruppen.set(c.pages, g); });
|
||||
const zeilen = [...gruppen.keys()].sort((a, b) => a - b)
|
||||
.map((p) => `${p} Seite${p === 1 ? '' : 'n'}: ${gruppen.get(p).join(', ')}`);
|
||||
neu.seitenInfo = zeilen.join('\n') || 'Keine Schriftstücke.';
|
||||
} catch (e) {
|
||||
neu.seitenInfo = '';
|
||||
neu.err = e?.response?.data?.error || e?.message || 'Seitenzählung fehlgeschlagen.';
|
||||
}
|
||||
}
|
||||
async function absenden() {
|
||||
// Empfänger sind optional – ohne Empfänger genau ein Schriftstück (Blankozeile).
|
||||
const ent = entries();
|
||||
const payloadEntries = ent.length ? ent : [leereZeile()];
|
||||
neu.err = ''; neu.msg = 'Wird angelegt …';
|
||||
try {
|
||||
const kv = kuvertPayload();
|
||||
const res = await api.post('/skrift-orders/manual-order', {
|
||||
product: neu.product, format: neu.format, font: neu.font,
|
||||
realistic: neu.realistic,
|
||||
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
|
||||
multi_page: neu.mehrseitig,
|
||||
...briefSigPayload(),
|
||||
needs_envelope: kv.needs_envelope, envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
|
||||
shipping_type: 'sammel',
|
||||
customer_email: neu.customer_email.trim() || undefined,
|
||||
text_template: neu.text, preview_session: neu.previewSession || undefined,
|
||||
entries: payloadEntries,
|
||||
});
|
||||
const nr = res.data?.order_number || '';
|
||||
// Felder bewusst NICHT zurücksetzen – der letzte Auftrag bleibt stehen,
|
||||
// damit er sich als Vorlage für den nächsten leicht anpassen lässt.
|
||||
neu.msg = `✓ Auftrag ${nr} angelegt – liegt im Produktions-Board unter „Neu". Felder bleiben stehen; mit „Formular leeren" zurücksetzen.`;
|
||||
} catch (e) { neu.err = e?.response?.data?.error || e?.message || 'Anlegen fehlgeschlagen.'; neu.msg = ''; }
|
||||
}
|
||||
function zuruecksetzen() {
|
||||
Object.assign(neu, {
|
||||
product: produkte.value[0]?.key || 'briefe', format: neuFormate()[0]?.key || '', font: 'tilda',
|
||||
fontSizeLetter: 100, fontSizeEnvelope: 100,
|
||||
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100, mehrseitig: false, seitenInfo: '',
|
||||
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
|
||||
importOpen: false, importText: '',
|
||||
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
|
||||
});
|
||||
}
|
||||
const musterEinfuegen = (wert) => {
|
||||
// Freie Mustertexte (per ID) bevorzugt; sonst Fallback Du/Sie.
|
||||
let t = '';
|
||||
if (wert === 'du' || wert === 'sie') t = wert === 'du' ? musterTexte.value.text_du : musterTexte.value.text_sie;
|
||||
else t = (musterListe.value.find((m) => String(m.id) === String(wert)) || {}).text || '';
|
||||
if (!t) return;
|
||||
neu.text = neu.text.trim() ? `${neu.text}\n\n${t}` : t;
|
||||
neu.previewSession = null;
|
||||
};
|
||||
|
||||
// Weißes Dropdown / Eingabefeld.
|
||||
const sel = (val, opts, on, extra) => h('select', { style: WSELECT + 'width:100%;' + (extra || ''), value: val, onChange: on },
|
||||
opts.map((o) => h('option', { value: o.v, selected: String(val) === String(o.v) }, o.t)));
|
||||
const svgUri = (svg) => { try { return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); } catch { return null; } };
|
||||
// Vorschau-Optik „Kugelschreiber": NUR für die Anzeige die schwarzen Striche
|
||||
// in Kuli-Blau umfärben. Die generierten/gecachten SVGs bleiben schwarz –
|
||||
// die Produktion (die die gezeigten Dokumente 1:1 übernimmt) ist unberührt.
|
||||
const BALLPOINT = '#1a2a6c';
|
||||
const inkify = (svg) => (typeof svg === 'string' ? svg.replace(/stroke="#0{3,6}"/gi, `stroke="${BALLPOINT}"`) : svg);
|
||||
// Vorschau als PNG speichern (wie in der App); Fallback: SVG.
|
||||
function bildSpeichern(d) {
|
||||
const svg = d && typeof d.svg === 'string' ? inkify(d.svg) : null;
|
||||
if (!svg) return;
|
||||
const name = (d?.art === 'umschlag' ? 'kuvert' : 'schriftstueck') + (d?.page ? `_s${d.page}` : '');
|
||||
const uri = svgUri(svg);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const scale = 2;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = (img.naturalWidth || 800) * scale; cv.height = (img.naturalHeight || 1130) * scale;
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, cv.width, cv.height);
|
||||
ctx.drawImage(img, 0, 0, cv.width, cv.height);
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = name + '.png';
|
||||
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = () => {
|
||||
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
|
||||
const a = document.createElement('a'); a.href = url; a.download = name + '.svg';
|
||||
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
};
|
||||
img.src = uri;
|
||||
}
|
||||
const docBild = (d) => {
|
||||
const svg = d && typeof d.svg === 'string' ? d.svg : null;
|
||||
const src = svg ? svgUri(inkify(svg)) : (typeof d === 'string' ? d : (d?.dataUrl || d?.url || d?.image || null));
|
||||
const seite = (d?.page && d?.pageCount) ? ` · Seite ${d.page}/${d.pageCount}` : '';
|
||||
const txt = (d?.art === 'umschlag' ? '✉ Kuvert' : '📄 Schriftstück') + seite;
|
||||
return h('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:4px;' }, [
|
||||
src ? h('img', { src, onClick: () => { neu.lb = src; }, style: 'width:100%;max-height:260px;object-fit:contain;border-radius:6px;background:#fff;cursor:zoom-in;box-shadow:0 1px 3px rgba(0,0,0,.12);' })
|
||||
: h('div', { style: 'width:100%;height:200px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:#fff;color:var(--foreground-subdued);' }, 'Dok.'),
|
||||
h('div', { style: 'display:flex;align-items:center;gap:8px;' }, [
|
||||
h('span', { style: 'font-size:11px;color:var(--foreground-subdued);' }, txt),
|
||||
svg ? h('button', { type: 'button', onClick: () => bildSpeichern(d), style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-page);border-radius:6px;padding:2px 8px;font:inherit;font-size:11px;color:var(--foreground-normal);' }, '💾 Speichern') : null,
|
||||
]),
|
||||
]);
|
||||
};
|
||||
// Weißes, einzeiliges Zellenfeld (feste Breite → Zeile scrollt bei Bedarf).
|
||||
const feld = (lbl, val, on, ph, onPaste) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1 1 0;min-width:90px;' }, [
|
||||
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
|
||||
h('input', { value: val, placeholder: ph || '', style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;', onInput: on, onPaste }),
|
||||
]);
|
||||
|
||||
function formular() {
|
||||
// Empfängerzeile: erst individuelle Platzhalter, dann die Adresszeilen.
|
||||
const zeile = (e, i) => [
|
||||
...customPlaceholders().map((name) => feld(name, e.placeholders?.[name] || '', (ev) => setPlatzhalter(i, name, ev.target.value), '',
|
||||
(ev) => { if (spalteEinfuegen(i, (ri, v) => setPlatzhalter(ri, name, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); })),
|
||||
...Array.from({ length: zeilenAnzahl() }, (_, z) => feld('Zeile ' + (z + 1), freitext(e, z), (ev) => setFreitext(i, z, ev.target.value), '',
|
||||
(ev) => { if (spalteEinfuegen(i, (ri, v) => setFreitext(ri, z, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); })),
|
||||
];
|
||||
|
||||
// Bereich 1 – Produkt
|
||||
const b1 = h('div', { style: BEREICH }, [
|
||||
bereichKopf('📄 Produkt'),
|
||||
h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:12px;' }, [
|
||||
h('div', {}, [label('Produkt'), sel(neu.product, produkte.value.map((p) => ({ v: p.key, t: p.name })), (e) => { neu.product = e.target.value; neu.format = neuFormate()[0]?.key || ''; neu.previewSession = null; })]),
|
||||
h('div', {}, [label('Format'), sel(neu.format, neuFormate().map((f) => ({ v: f.key, t: f.name })), (e) => { neu.format = e.target.value; neu.previewSession = null; })]),
|
||||
h('div', {}, [label('Schrift'), sel(neu.font, [['tilda', 'Tilda'], ['alva', 'Alva'], ['ellie', 'Ellie']].map(([v, t]) => ({ v, t })), (e) => { neu.font = e.target.value; neu.previewSession = null; })]),
|
||||
]),
|
||||
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;' }, [
|
||||
h('div', {}, [label('Schriftgröße Brief (%)'),
|
||||
// Frei tippbar; erst beim Verlassen des Feldes auf 80–150 begrenzen.
|
||||
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeLetter, style: WFELD + 'width:100%;',
|
||||
onInput: (e) => { neu.fontSizeLetter = e.target.value; neu.previewSession = null; },
|
||||
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeLetter = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
|
||||
h('div', {}, [label('Schriftgröße Kuvert (%)'),
|
||||
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeEnvelope, style: WFELD + 'width:100%;',
|
||||
onInput: (e) => { neu.fontSizeEnvelope = e.target.value; neu.previewSession = null; },
|
||||
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeEnvelope = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
|
||||
]),
|
||||
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:12px;cursor:pointer;' }, [
|
||||
h('input', { type: 'checkbox', checked: neu.realistic, onChange: (e) => { neu.realistic = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
|
||||
'Natürliche Handschrift (leichte Variation)',
|
||||
]),
|
||||
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:8px;cursor:pointer;' }, [
|
||||
h('input', { type: 'checkbox', checked: neu.mehrseitig, onChange: (e) => { neu.mehrseitig = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
|
||||
'Mehrseitiges Schriftstück (Text fließt automatisch über mehrere A4-Seiten)',
|
||||
]),
|
||||
]);
|
||||
|
||||
// Bereich 2 – Kuvert (Empfängertabelle)
|
||||
const b2 = h('div', { style: BEREICH }, [
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
|
||||
bereichKopf('✉ Kuvert'),
|
||||
h('div', { style: 'width:300px;' }, [segToggle(neu.adressModus, [{ key: '5zeilen', label: '5 Zeilen' }, { key: 'individuell', label: 'Individueller Text' }], (k) => { neu.adressModus = k; neu.previewSession = null; })]),
|
||||
]),
|
||||
h('div', { style: 'display:flex;flex-direction:column;gap:8px;max-height:34vh;overflow:auto;' },
|
||||
neu.rows.map((e, i) => h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
|
||||
h('span', { style: `min-width:22px;font-weight:700;color:${ACCENT};` }, `#${i + 1}`),
|
||||
h('div', { style: 'flex:1;min-width:0;display:flex;gap:6px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;' }, zeile(e, i)),
|
||||
h('button', { type: 'button', onClick: () => delZeile(i), style: 'cursor:pointer;border:none;background:transparent;color:var(--danger,#c62828);font-size:18px;padding:2px 6px;' }, '×'),
|
||||
]))),
|
||||
h('div', { style: 'margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px;' }, [
|
||||
btn('+ Empfänger', addZeile),
|
||||
btn(neu.importOpen ? 'Import schließen' : '⇪ Aus Excel einfügen', () => { neu.importOpen = !neu.importOpen; }),
|
||||
]),
|
||||
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-top:12px;' }, [
|
||||
h('input', { type: 'checkbox', checked: neu.kuverts, onChange: (e) => { neu.kuverts = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
|
||||
'Kuverts generieren',
|
||||
]),
|
||||
neu.importOpen ? h('div', { style: 'margin-top:12px;' }, [
|
||||
subdued('Aus Excel kopieren und einfügen – eine Zeile pro Empfänger, Spalten mit Tab/Semikolon/Komma. '
|
||||
+ 'Reihenfolge: erst individuelle Platzhalter, dann ' + (neu.adressModus === 'individuell' ? 'die eine Adresszeile.' : 'Zeile 1–5.'), 'margin-bottom:6px;'),
|
||||
h('textarea', { value: neu.importText, rows: 5, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }),
|
||||
h('div', { style: 'margin-top:6px;' }, [btn('Übernehmen', importUebernehmen, 'primary')]),
|
||||
]) : null,
|
||||
]);
|
||||
|
||||
// Bereich 3 – Text (Mustertext-Dropdown direkt darüber)
|
||||
const b3 = h('div', { style: BEREICH }, [
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
|
||||
bereichKopf('✍ Text'),
|
||||
h('select', { style: WSELECT + 'font-size:13px;padding:7px 10px;width:auto;', onChange: (e) => { if (e.target.value) { musterEinfuegen(e.target.value); e.target.value = ''; } } },
|
||||
[h('option', { value: '' }, 'Mustertext einfügen …')].concat(
|
||||
musterListe.value.length
|
||||
? musterListe.value.map((m) => h('option', { value: m.id }, m.label || `Text ${m.id}`))
|
||||
: [h('option', { value: 'sie' }, 'Sie-Form'), h('option', { value: 'du' }, 'Du-Form')],
|
||||
)),
|
||||
]),
|
||||
label(`Brieftext · ${neu.text.length} Zeichen · [[Platzhalter]] erlaubt`),
|
||||
h('textarea', { value: neu.text, rows: 8, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.text = saeubere(e.target.value); neu.previewSession = null; }, onKeydown: (e) => {
|
||||
// Tab-Taste fügt einen echten Tabulator ein (statt den Fokus zu verschieben).
|
||||
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
const ta = e.target, s = ta.selectionStart, en = ta.selectionEnd;
|
||||
neu.text = saeubere(neu.text.slice(0, s) + '\t' + neu.text.slice(en));
|
||||
neu.previewSession = null;
|
||||
requestAnimationFrame(() => { try { ta.selectionStart = ta.selectionEnd = s + 1; } catch {} });
|
||||
} }),
|
||||
// ── Unterschrift auf den Brief ─────────────────────────────────────
|
||||
h('div', { style: 'margin-top:16px;border-top:1px solid rgba(30,80,165,.15);padding-top:14px;' }, [
|
||||
label('Unterschrift auf den Brief (optional) · Platzhalter [[signatur1]]'),
|
||||
h('div', { style: 'display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;' }, [
|
||||
h('select', { style: WSELECT + 'width:100%;', onChange: (e) => sigWaehlen(e.target.value) },
|
||||
[h('option', { value: '' }, 'Vorhandene Unterschrift wählen …')].concat(
|
||||
signaturListe.value.map((s) => {
|
||||
const kunde = [s.customer?.first_name, s.customer?.last_name].filter(Boolean).join(' ');
|
||||
return h('option', { value: s.id }, (s.label || `Signatur ${s.id}`) + (kunde ? ` · ${kunde}` : ''));
|
||||
}))),
|
||||
h('label', { style: 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;background:#fff;color:' + ACCENT + ';box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:nowrap;' }, [
|
||||
'⇪ SVG hochladen',
|
||||
h('input', { type: 'file', accept: '.svg,image/svg+xml', style: 'display:none;', onChange: sigDatei }),
|
||||
]),
|
||||
]),
|
||||
neu.sigSvg ? h('div', { style: 'margin-top:12px;' }, [
|
||||
h('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;' }, [
|
||||
h('span', { style: `color:${ACCENT};font-size:13px;font-weight:600;` }, `✓ ${neu.sigLabel} – als [[signatur1]] im Text`),
|
||||
btn('Entfernen', sigEntfernen),
|
||||
]),
|
||||
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;' }, [
|
||||
h('div', {}, [label('Versatz X (mm)'), h('input', { type: 'number', step: 1, value: neu.sigX, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigX = e.target.value; neu.previewSession = null; } })]),
|
||||
h('div', {}, [label('Versatz Y (mm)'), h('input', { type: 'number', step: 1, value: neu.sigY, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigY = e.target.value; neu.previewSession = null; } })]),
|
||||
h('div', {}, [label('Größe (%)'), h('input', { type: 'number', step: 5, value: neu.sigScale, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigScale = e.target.value; neu.previewSession = null; } })]),
|
||||
]),
|
||||
subdued('Positiv X = nach rechts, positiv Y = nach unten. Nach dem Anpassen „Aktualisieren" in der Vorschau klicken.', 'margin-top:8px;'),
|
||||
]) : null,
|
||||
]),
|
||||
]);
|
||||
|
||||
// Bereich 4 – Kunde
|
||||
const b4 = h('div', { style: BEREICH }, [
|
||||
bereichKopf('👤 Kunde'),
|
||||
h('div', { style: 'max-width:420px;' }, [
|
||||
label('Kunde verknüpfen (E-Mail, optional)'),
|
||||
h('input', { type: 'email', value: neu.customer_email, placeholder: 'kunde@example.de', style: WFELD + 'width:100%;', onInput: (e) => { neu.customer_email = e.target.value; } }),
|
||||
]),
|
||||
]);
|
||||
|
||||
const aktionen = h('div', { class: 'sk-btnrow', style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||
neu.hinweis ? h('div', { style: `color:var(--danger,#c62828);font-size:13px;width:100%;` }, neu.hinweis) : null,
|
||||
neu.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;width:100%;' }, neu.err) : null,
|
||||
neu.msg ? h('div', { style: 'width:100%;' }, subdued(neu.msg)) : null,
|
||||
btn(neu.pLaedt ? 'Vorschau …' : '👁 Vorschau', preview),
|
||||
btn('In Druck geben', absenden, 'primary'),
|
||||
btn('Formular leeren', zuruecksetzen),
|
||||
]);
|
||||
|
||||
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, b3, b4, aktionen]);
|
||||
|
||||
// Vorschau rechts daneben (eigener hellblauer Bereich).
|
||||
const rechts = h('div', { style: BEREICH + 'flex:0 0 340px;position:sticky;top:20px;align-self:flex-start;' }, [
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;' }, [
|
||||
bereichKopf('👁 Vorschau'),
|
||||
btn(neu.pLaedt ? '…' : 'Aktualisieren', preview),
|
||||
]),
|
||||
neu.pLaedt ? subdued('Wird erzeugt …')
|
||||
: neu.docs.length ? h('div', { style: 'display:flex;flex-direction:column;gap:12px;' }, neu.docs.slice(0, 12).map(docBild))
|
||||
: subdued('Noch keine Vorschau. „Aktualisieren" klicken (Klick aufs Bild = groß).'),
|
||||
neu.mehrseitig ? h('div', { style: 'margin-top:14px;border-top:1px solid rgba(30,80,165,.15);padding-top:12px;' }, [
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;' }, [
|
||||
h('div', { style: `font-size:13px;font-weight:700;color:${ACCENT};` }, 'Seiten je Schriftstück'),
|
||||
btn('Berechnen', seitenBerechnen),
|
||||
]),
|
||||
neu.seitenInfo
|
||||
? h('pre', { style: 'margin:0;white-space:pre-wrap;font:inherit;font-size:13px;color:var(--foreground);' }, neu.seitenInfo)
|
||||
: subdued('„Berechnen" klicken – zeigt, wie viele Seiten je Empfänger entstehen.'),
|
||||
]) : null,
|
||||
]);
|
||||
|
||||
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
|
||||
}
|
||||
|
||||
// ── Unterschriftenservice ────────────────────────────────────────────────
|
||||
async function sigLaden() {
|
||||
sig.err = ''; sig.msg = ''; sig.pdf = ''; sig.order = null; sig.signaturen = []; sig.docs = []; sig.docId = null;
|
||||
const nr = sig.orderNr.trim();
|
||||
if (!nr) { sig.err = 'Bitte Auftragsnummer eingeben.'; return; }
|
||||
sig.laden = true;
|
||||
try {
|
||||
const o = (await api.get('/items/orders', { params: {
|
||||
filter: { order_number: { _eq: nr } }, limit: 1,
|
||||
fields: 'id,order_number,source_file,source_files,customer.id',
|
||||
} })).data?.data?.[0];
|
||||
if (!o) { sig.err = `Auftrag ${nr} nicht gefunden.`; sig.laden = false; return; }
|
||||
sig.order = o;
|
||||
const docs = Array.isArray(o.source_files) && o.source_files.length
|
||||
? o.source_files : (o.source_file ? [o.source_file] : []);
|
||||
sig.docs = docs;
|
||||
sig.docId = docs[0] || null;
|
||||
if (o.customer?.id) {
|
||||
const rows = (await api.get('/items/signatures', { params: {
|
||||
filter: { customer: { _eq: o.customer.id } }, limit: -1,
|
||||
fields: 'id,label,file,marker_key,offset_x_mm,offset_y_mm,width_mm',
|
||||
} })).data?.data || [];
|
||||
sig.signaturen = rows.filter((r) => r.file).map((r) => ({
|
||||
file_id: r.file, label: r.label || 'Signatur', aktiv: true,
|
||||
marker_key: r.marker_key || 'SIG1', position: 'genau',
|
||||
width_mm: r.width_mm ?? 40, offset_x_mm: r.offset_x_mm ?? 0, offset_y_mm: r.offset_y_mm ?? 0,
|
||||
}));
|
||||
}
|
||||
if (!sig.docs.length) sig.err = 'An diesem Auftrag ist kein Dokument hochgeladen.';
|
||||
else if (!sig.signaturen.length) sig.err = 'Für diesen Kunden ist keine Unterschrift (mit Datei) hinterlegt.';
|
||||
} catch (e) { sig.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.'; }
|
||||
finally { sig.laden = false; }
|
||||
}
|
||||
const sigAktive = () => sig.signaturen.filter((s) => s.aktiv);
|
||||
const sigPayload = () => ({ order: sig.order?.id || undefined, document_id: sig.docId || undefined, signatures: sigAktive() });
|
||||
// PDF direkt hochladen (manueller Modus).
|
||||
async function sigPdfUpload(file) {
|
||||
if (!file) return;
|
||||
sig.err = ''; sig.msg = 'Dokument wird hochgeladen …'; sig.pdf = '';
|
||||
try { sig.docId = await sigUpload(file); sig.docName = file.name; sig.msg = ''; }
|
||||
catch (e) { sig.err = 'Dokument-Upload fehlgeschlagen.'; sig.msg = ''; }
|
||||
}
|
||||
// Signatur-SVG hochladen und als Zeile hinzufügen (manuell oder ergänzend).
|
||||
async function sigSvgUpload(file) {
|
||||
if (!file) return;
|
||||
sig.err = '';
|
||||
try {
|
||||
const id = await sigUpload(file);
|
||||
sig.signaturen.push({ file_id: id, label: file.name, aktiv: true, marker_key: 'SIG' + (sig.signaturen.length + 1), position: 'genau', width_mm: 40, offset_x_mm: 0, offset_y_mm: 0 });
|
||||
sig.pdf = '';
|
||||
} catch (e) { sig.err = 'Signatur-Upload fehlgeschlagen.'; }
|
||||
}
|
||||
async function sigVorschau() {
|
||||
if (!sig.docId) { sig.err = 'Bitte ein Dokument wählen/hochladen.'; return; }
|
||||
if (!sigAktive().length) { sig.err = 'Mindestens eine Unterschrift aktivieren.'; return; }
|
||||
sig.err = ''; sig.msg = 'Vorschau wird erzeugt …'; sig.pdf = '';
|
||||
try {
|
||||
const res = await api.post('/skrift-orders/signature-preview', sigPayload());
|
||||
sig.pdf = 'data:application/pdf;base64,' + res.data.pdf_base64;
|
||||
sig.msg = '';
|
||||
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; sig.msg = ''; }
|
||||
}
|
||||
async function sigDownload() {
|
||||
if (!sig.docId || !sigAktive().length) { sig.err = 'Dokument und Signatur wählen.'; return; }
|
||||
sig.err = ''; sig.msg = 'A4-Signaturen werden erzeugt …';
|
||||
try {
|
||||
const res = await api.post('/skrift-orders/signature-render', sigPayload());
|
||||
const bin = atob(res.data.zip_base64);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
const url = URL.createObjectURL(new Blob([arr], { type: 'application/zip' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `signaturen-${res.data.order_number || sig.order.order_number}.zip`;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
sig.msg = 'ZIP heruntergeladen – A4-Signaturen an den Plotter geben.';
|
||||
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Erzeugung fehlgeschlagen.'; sig.msg = ''; }
|
||||
}
|
||||
|
||||
function signaturPanel() {
|
||||
const sigFeld = (s, k, lbl, step) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1;min-width:90px;' }, [
|
||||
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
|
||||
h('input', { value: s[k], type: k === 'marker_key' ? 'text' : 'number', step: step || 'any',
|
||||
style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;',
|
||||
onInput: (e) => { s[k] = k === 'marker_key' ? e.target.value : (parseFloat(e.target.value) || 0); sig.pdf = ''; } }),
|
||||
]);
|
||||
const b1 = h('div', { style: BEREICH }, [
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;' }, [
|
||||
bereichKopf('✍ Unterschriften'),
|
||||
h('div', { style: 'width:280px;' }, [segToggle(sig.modus, [{ key: 'auftrag', label: 'Aus Auftrag' }, { key: 'manuell', label: 'Manuell' }], (k) => { sig.modus = k; sig.pdf = ''; })]),
|
||||
]),
|
||||
sig.modus === 'auftrag'
|
||||
? h('div', {}, [
|
||||
h('div', { style: 'display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;' }, [
|
||||
h('div', { style: 'flex:1;min-width:200px;' }, [label('Auftragsnummer'), h('input', { value: sig.orderNr, placeholder: 'SK-2026-…', style: WFELD + 'width:100%;', onInput: (e) => { sig.orderNr = e.target.value; } })]),
|
||||
btn(sig.laden ? 'Lädt …' : 'Auftrag laden', sigLaden, 'primary'),
|
||||
]),
|
||||
sig.docs.length > 1 ? h('div', { style: 'margin-top:12px;' }, [label('Dokument'), sel(sig.docId, sig.docs.map((d, i) => ({ v: d, t: `Dokument ${i + 1}` })), (e) => { sig.docId = e.target.value; sig.pdf = ''; })]) : null,
|
||||
])
|
||||
: h('div', {}, [
|
||||
label('Dokument (PDF mit Marker §§SIG…§§)'),
|
||||
h('input', { type: 'file', accept: '.pdf', onChange: (e) => sigPdfUpload(e.target.files && e.target.files[0]), style: 'font-size:13px;' }),
|
||||
sig.docName ? subdued(`${sig.docName} hochgeladen.`, 'margin-top:6px;') : null,
|
||||
]),
|
||||
]);
|
||||
const b2 = h('div', { style: BEREICH }, [
|
||||
bereichKopf('Unterschriften & Position'),
|
||||
subdued(sig.modus === 'auftrag'
|
||||
? 'Aus dem Kundenkonto vorbelegt (Marker + Feinjustierung), hier überschreibbar. Weitere Unterschrift-SVGs sind ergänzbar.'
|
||||
: 'Unterschrift(en) als SVG hochladen und Marker/Position setzen.', 'margin-bottom:10px;'),
|
||||
h('div', { style: 'margin-bottom:10px;' }, [
|
||||
h('label', { style: `display:inline-block;cursor:pointer;color:${ACCENT};font-size:13px;` }, [
|
||||
'+ Unterschrift (SVG) hinzufügen',
|
||||
h('input', { type: 'file', accept: '.svg', style: 'display:none;', onChange: (e) => sigSvgUpload(e.target.files && e.target.files[0]) }),
|
||||
]),
|
||||
]),
|
||||
...sig.signaturen.map((s) => h('div', { style: 'background:#fff;border-radius:8px;padding:10px 12px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.08);' }, [
|
||||
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin-bottom:8px;cursor:pointer;' }, [
|
||||
h('input', { type: 'checkbox', checked: s.aktiv, onChange: (e) => { s.aktiv = e.target.checked; sig.pdf = ''; }, style: 'width:16px;height:16px;' }),
|
||||
s.label,
|
||||
]),
|
||||
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||
sigFeld(s, 'marker_key', 'Marker'),
|
||||
sigFeld(s, 'width_mm', 'Breite mm'),
|
||||
sigFeld(s, 'offset_x_mm', 'X mm'),
|
||||
sigFeld(s, 'offset_y_mm', 'Y mm'),
|
||||
]),
|
||||
])),
|
||||
h('div', { class: 'sk-btnrow', style: 'display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;' }, [
|
||||
btn(sig.msg ? '…' : '👁 Vorschau', sigVorschau, 'primary'),
|
||||
btn('⤓ A4-Signaturen (ZIP)', sigDownload),
|
||||
]),
|
||||
]);
|
||||
const meldung = h('div', {}, [
|
||||
sig.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, sig.err) : null,
|
||||
sig.msg ? subdued(sig.msg, 'margin-top:8px;') : null,
|
||||
]);
|
||||
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, meldung]);
|
||||
const rechts = h('div', { style: BEREICH + 'flex:0 0 420px;position:sticky;top:20px;align-self:flex-start;min-height:200px;' }, [
|
||||
bereichKopf('👁 Vorschau'),
|
||||
sig.pdf
|
||||
? h('iframe', { src: sig.pdf, style: 'width:100%;height:70vh;border:none;border-radius:8px;background:#fff;' })
|
||||
: subdued('Noch keine Vorschau. Auftrag laden, Signatur wählen, „Vorschau" klicken.'),
|
||||
]);
|
||||
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
|
||||
}
|
||||
|
||||
// Linke Navigationsspalte: Werkzeuge dieses Moduls.
|
||||
const navItem = (label, aktiv, onClick) => h('div', {
|
||||
onClick,
|
||||
style: 'display:flex;align-items:center;gap:8px;border-radius:6px;padding:10px 12px;margin-bottom:3px;font-size:14px;cursor:pointer;'
|
||||
+ (aktiv ? `background:${ACCENT};color:#fff;font-weight:600;` : 'color:var(--foreground-normal);'),
|
||||
}, label);
|
||||
|
||||
return () => h(resolveComponent('private-view'), { title: ansicht.value === 'signatur' ? 'Unterschriften' : 'Neuer Auftrag' }, {
|
||||
navigation: () => h('div', { style: 'padding:12px 10px;' }, [
|
||||
navItem('📝 Neuer Auftrag', ansicht.value === 'neu', () => { ansicht.value = 'neu'; }),
|
||||
navItem('✍ Unterschriften', ansicht.value === 'signatur', () => { ansicht.value = 'signatur'; }),
|
||||
]),
|
||||
default: () => [
|
||||
// Mobil: Button-Reihen bleiben in einer Zeile und füllen die Breite.
|
||||
h('style', {}, '@media(max-width:640px){.sk-btnrow>button{flex:1 1 auto!important;}}'),
|
||||
h('div', { style: 'padding:24px 28px;background:var(--background-subdued,#f1f4f9);min-height:100%;' },
|
||||
ansicht.value === 'signatur' ? signaturPanel() : (!geladen.value ? subdued('Lädt …') : formular())),
|
||||
neu.lb ? h(Teleport, { to: 'body' }, [
|
||||
h('div', {
|
||||
onClick: () => { neu.lb = ''; },
|
||||
style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;align-items:center;justify-content:center;padding:4vh 4vw;cursor:zoom-out;',
|
||||
}, [h('img', { src: neu.lb, style: 'max-width:100%;max-height:90vh;object-fit:contain;background:#fff;border-radius:8px;' })]),
|
||||
]) : null,
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
id: 'skrift-neuauftrag',
|
||||
name: 'Manueller Druck',
|
||||
icon: 'print',
|
||||
routes: [{ path: '', component: NeuAuftrag }],
|
||||
};
|
||||
13
extensions/directus-extension-skrift-neuauftrag/package.json
Normal file
13
extensions/directus-extension-skrift-neuauftrag/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-neuauftrag",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Modul: Neuer Auftrag – manuelle Auftragsanlage (Format/Kuvert/Schrift, Struktur- oder 5-Zeilen-Adressen, Vorschau) über die zentralen Backend-Endpunkte.",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
149
extensions/directus-extension-skrift-orderentries/dist/index.js
vendored
Normal file
149
extensions/directus-extension-skrift-orderentries/dist/index.js
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Skrift – Interface „Empfänger (durchsuchbar)"
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zeigt im Auftrag alle Empfängerzeilen als kompakte, durchsuchbare Tabelle –
|
||||
* die Produktionsansicht aus dem alten Job-Manager: pro Zeile die Briefnummer
|
||||
* und die VOLLE Anschrift bzw. der Freitext (nicht nur der Name). Das Suchfeld
|
||||
* filtert über die ganze Zeile (Name, Straße, Ort, Land, Platzhalter …).
|
||||
*
|
||||
* Rein lesend/suchend – bearbeitet wird eine Zeile weiterhin über das darunter
|
||||
* liegende o2m-Feld „Empfänger (bearbeiten)".
|
||||
*
|
||||
* Bewusst von Hand als ESM geschrieben (kein Build-Schritt): Vue und der
|
||||
* Extensions-SDK werden von Directus zur Laufzeit bereitgestellt.
|
||||
*/
|
||||
import { defineComponent, ref, computed, inject, watch, onMounted, h } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const INLAND = /^(de|deutschland|germany)$/i;
|
||||
|
||||
/** Baut die volle Anzeigezeile einer Empfängerzeile. */
|
||||
function volleZeile(e) {
|
||||
if (e.free_text && String(e.free_text).trim()) {
|
||||
return String(e.free_text).trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean).join(' · ');
|
||||
}
|
||||
const name = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' ');
|
||||
const strasse = [e.street, e.house_no].filter(Boolean).join(' ');
|
||||
const ort = [e.zip, e.city].filter(Boolean).join(' ');
|
||||
const land = e.country && !INLAND.test(String(e.country).trim()) ? e.country : null;
|
||||
return [name, strasse, ort, land].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** Zusätzliche freie Platzhalter als „Key: Wert"-Liste. */
|
||||
function extras(e) {
|
||||
if (!e.placeholders || typeof e.placeholders !== 'object') return '';
|
||||
return Object.entries(e.placeholders)
|
||||
.filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '')
|
||||
.map(([k, v]) => `${k}: ${v}`).join(' · ');
|
||||
}
|
||||
|
||||
const EmpfaengerSuche = defineComponent({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const values = inject('values', ref({}));
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const geladen = ref(false);
|
||||
const suche = ref('');
|
||||
|
||||
const orderId = () => (values && values.value ? values.value.id : null);
|
||||
|
||||
async function laden() {
|
||||
const id = orderId();
|
||||
if (!id) { rows.value = []; geladen.value = true; return; }
|
||||
loading.value = true; error.value = '';
|
||||
try {
|
||||
const res = await api.get('/items/order_entries', {
|
||||
params: {
|
||||
'filter[order][_eq]': id,
|
||||
sort: 'letter_number',
|
||||
limit: -1,
|
||||
fields: '*',
|
||||
},
|
||||
});
|
||||
const data = (res.data && res.data.data) || [];
|
||||
rows.value = data.map((e) => ({
|
||||
nr: e.letter_number,
|
||||
zeile: volleZeile(e),
|
||||
extra: extras(e),
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = (e && e.response && e.response.data && e.response.data.errors &&
|
||||
e.response.data.errors[0] && e.response.data.errors[0].message) ||
|
||||
(e && e.message) || 'Empfänger konnten nicht geladen werden.';
|
||||
} finally {
|
||||
loading.value = false; geladen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(laden);
|
||||
watch(orderId, laden);
|
||||
|
||||
const treffer = computed(() => {
|
||||
const q = suche.value.trim().toLowerCase();
|
||||
if (!q) return rows.value;
|
||||
return rows.value.filter((r) =>
|
||||
(`#${r.nr} ${r.zeile} ${r.extra}`).toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const hinweis = (txt) => h('div', { style: 'color:var(--foreground-subdued);font-size:14px;padding:8px 0;' }, txt);
|
||||
|
||||
return () => {
|
||||
if (loading.value) return hinweis('Empfänger werden geladen …');
|
||||
if (error.value) return hinweis(error.value);
|
||||
if (!geladen.value) return hinweis('');
|
||||
if (!rows.value.length) return hinweis('Noch keine Empfängerzeilen.');
|
||||
|
||||
const suchfeld = h('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:10px;' }, [
|
||||
h('input', {
|
||||
type: 'search',
|
||||
value: suche.value,
|
||||
placeholder: 'Empfänger durchsuchen (Name, Straße, Ort, Platzhalter …)',
|
||||
onInput: (ev) => { suche.value = ev.target.value; },
|
||||
style: 'flex:1;padding:8px 12px;border:1px solid var(--border-normal);border-radius:6px;' +
|
||||
'background:var(--background-page);color:var(--foreground-normal);font:inherit;font-size:14px;',
|
||||
}),
|
||||
h('span', { style: 'font-size:12px;color:var(--foreground-subdued);white-space:nowrap;' },
|
||||
`${treffer.value.length} von ${rows.value.length}`),
|
||||
]);
|
||||
|
||||
const zeilen = treffer.value.map((r) =>
|
||||
h('div', {
|
||||
key: r.nr,
|
||||
style: 'display:flex;align-items:baseline;gap:12px;padding:7px 12px;' +
|
||||
'border:1px solid var(--border-subdued);border-radius:6px;',
|
||||
}, [
|
||||
h('span', {
|
||||
style: 'min-width:44px;font-family:var(--family-monospace, monospace);font-size:13px;' +
|
||||
'font-weight:600;color:var(--primary);',
|
||||
}, `#${r.nr}`),
|
||||
h('div', { style: 'flex:1;font-size:14px;line-height:1.4;word-break:break-word;' }, [
|
||||
h('span', {}, r.zeile || '—'),
|
||||
r.extra
|
||||
? h('span', { style: 'display:block;font-size:12px;color:var(--foreground-subdued);margin-top:2px;' }, r.extra)
|
||||
: null,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
const body = treffer.value.length
|
||||
? h('div', { style: 'display:flex;flex-direction:column;gap:5px;' }, zeilen)
|
||||
: hinweis('Keine Treffer.');
|
||||
|
||||
return h('div', {}, [suchfeld, body]);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
id: 'skrift-order-entries',
|
||||
name: 'Empfänger (durchsuchbar)',
|
||||
icon: 'search',
|
||||
description: 'Durchsuchbare Empfängertabelle: Briefnummer + volle Anschrift (Produktionsansicht).',
|
||||
component: EmpfaengerSuche,
|
||||
types: ['alias'],
|
||||
localTypes: ['presentation'],
|
||||
group: 'presentation',
|
||||
options: null,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-orderentries",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Interface: durchsuchbare Empfängertabelle eines Auftrags (Briefnummer + volle Anschrift) – Produktionsansicht wie im alten Job-Manager.",
|
||||
"directus:extension": {
|
||||
"type": "interface",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
105
extensions/directus-extension-skrift-orderfiles/dist/index.js
vendored
Normal file
105
extensions/directus-extension-skrift-orderfiles/dist/index.js
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Skrift – Interface „Auftragsdateien"
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zeigt im Auftrag die generierten Dateien (Schriftstücke, Umschläge,
|
||||
* Platzhalter-CSV) direkt aus dem gemounteten Ausgabe-Ordner an – ohne Kopie
|
||||
* in den Directus-Speicher. Die Liste und der Download laufen über den
|
||||
* Endpoint /skrift-orders/files/* und die angemeldete Directus-Session
|
||||
* (kein Token in der URL nötig).
|
||||
*
|
||||
* Bewusst von Hand als ESM geschrieben (kein Build-Schritt): Vue und der
|
||||
* Extensions-SDK werden von Directus zur Laufzeit bereitgestellt.
|
||||
*/
|
||||
import { defineComponent, ref, inject, watch, onMounted, h } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const KIND_LABEL = {
|
||||
schriftstueck: 'Schriftstück',
|
||||
umschlag: 'Umschlag',
|
||||
platzhalter: 'Platzhalter-CSV',
|
||||
sonstiges: 'Datei',
|
||||
};
|
||||
|
||||
const AuftragsDateien = defineComponent({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const values = inject('values', ref({}));
|
||||
const files = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const geladen = ref(false);
|
||||
|
||||
function nummer() {
|
||||
return values && values.value ? values.value.order_number : null;
|
||||
}
|
||||
|
||||
async function laden() {
|
||||
const nr = nummer();
|
||||
if (!nr) { files.value = []; geladen.value = true; return; }
|
||||
loading.value = true; error.value = '';
|
||||
try {
|
||||
const res = await api.get('/skrift-orders/files/' + encodeURIComponent(nr));
|
||||
files.value = (res.data && res.data.files) || [];
|
||||
} catch (e) {
|
||||
error.value = (e && e.response && e.response.data && e.response.data.error) || (e && e.message) || 'Fehler beim Laden.';
|
||||
} finally {
|
||||
loading.value = false; geladen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function download(f) {
|
||||
const nr = nummer();
|
||||
if (!nr) return;
|
||||
try {
|
||||
const res = await api.get('/skrift-orders/files/' + encodeURIComponent(nr) + '/download', {
|
||||
params: { rel: f.rel }, responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = f.name;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
error.value = 'Download fehlgeschlagen.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(laden);
|
||||
watch(nummer, laden);
|
||||
|
||||
const hinweis = (txt) => h('div', { style: 'color:var(--foreground-subdued);font-size:14px;padding:8px 0;' }, txt);
|
||||
|
||||
return () => {
|
||||
if (loading.value) return hinweis('Dateien werden geladen …');
|
||||
if (error.value) return hinweis(error.value);
|
||||
if (!files.value.length) return hinweis(geladen.value ? 'Noch keine generierten Dateien.' : '');
|
||||
return h('div', { style: 'display:flex;flex-direction:column;gap:6px;' }, files.value.map((f) =>
|
||||
h('div', {
|
||||
key: f.rel,
|
||||
style: 'display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--border-normal);border-radius:6px;',
|
||||
}, [
|
||||
h('span', { style: 'font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);min-width:110px;' },
|
||||
KIND_LABEL[f.kind] || 'Datei'),
|
||||
h('span', { style: 'flex:1;font-family:var(--family-monospace, monospace);font-size:13px;word-break:break-all;' }, f.name),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
onClick: () => download(f),
|
||||
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:6px;padding:4px 12px;font:inherit;font-size:13px;',
|
||||
}, 'Herunterladen'),
|
||||
]),
|
||||
));
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
id: 'skrift-order-files',
|
||||
name: 'Auftragsdateien',
|
||||
icon: 'folder_open',
|
||||
description: 'Zeigt die generierten Dateien des Auftrags (Direktzugriff auf den Server-Ordner).',
|
||||
component: AuftragsDateien,
|
||||
types: ['alias'],
|
||||
localTypes: ['presentation'],
|
||||
group: 'presentation',
|
||||
options: null,
|
||||
};
|
||||
13
extensions/directus-extension-skrift-orderfiles/package.json
Normal file
13
extensions/directus-extension-skrift-orderfiles/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-orderfiles",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Interface: zeigt die generierten Auftragsdateien per Direktzugriff auf den Ausgabe-Ordner (kein Download in Directus, keine Kopie).",
|
||||
"directus:extension": {
|
||||
"type": "interface",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
1739
extensions/directus-extension-skrift-orders/dist/index.js
vendored
Normal file
1739
extensions/directus-extension-skrift-orders/dist/index.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
13
extensions/directus-extension-skrift-orders/package.json
Normal file
13
extensions/directus-extension-skrift-orders/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-orders",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Kontrollierter Bestell-Endpunkt (Gast + eingeloggt) mit serverseitiger Preisberechnung.",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
83
extensions/directus-extension-skrift-printbutton/dist/index.js
vendored
Normal file
83
extensions/directus-extension-skrift-printbutton/dist/index.js
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Skrift – Interface „Drucken"
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zeigt im Auftrag eine Maschinen-Auswahl + „Drucken"-Button. Der Button reiht
|
||||
* den Auftrag über POST /skrift-orders/enqueue als Druck-Job(s) ein (Brief +
|
||||
* ggf. Umschlag, mit aufgelöstem Template). Läuft über die angemeldete Session.
|
||||
*
|
||||
* Bewusst von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||
*/
|
||||
import { defineComponent, ref, inject, onMounted, h } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const PrintButton = defineComponent({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const values = inject('values', ref({}));
|
||||
const machines = ref([]);
|
||||
const selected = ref(null);
|
||||
const busy = ref(false);
|
||||
const msg = ref('');
|
||||
const err = ref('');
|
||||
|
||||
async function ladeMaschinen() {
|
||||
try {
|
||||
const res = await api.get('/items/machines', { params: { fields: 'id,name,active', limit: -1 } });
|
||||
machines.value = (res.data?.data || []).filter((m) => m.active !== false);
|
||||
if (machines.value.length && selected.value == null) selected.value = machines.value[0].id;
|
||||
} catch {
|
||||
err.value = 'Maschinen konnten nicht geladen werden.';
|
||||
}
|
||||
}
|
||||
onMounted(ladeMaschinen);
|
||||
|
||||
async function drucken() {
|
||||
const orderId = values && values.value ? values.value.id : null;
|
||||
if (!orderId) { err.value = 'Auftrag zuerst speichern.'; return; }
|
||||
if (selected.value == null) { err.value = 'Bitte eine Maschine wählen.'; return; }
|
||||
busy.value = true; err.value = ''; msg.value = '';
|
||||
try {
|
||||
const res = await api.post('/skrift-orders/enqueue', { order: orderId, machine: selected.value });
|
||||
const n = (res.data?.jobs || []).length;
|
||||
msg.value = `${n} Druck-Job(s) eingereiht.`;
|
||||
} catch (e) {
|
||||
err.value = (e && e.response && e.response.data && e.response.data.error) || (e && e.message) || 'Fehler beim Einreihen.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!machines.value.length) {
|
||||
return h('div', { style: 'color:var(--foreground-subdued);font-size:13px;' },
|
||||
err.value || 'Keine aktive Maschine – bitte unter Einstellungen → Maschinen anlegen.');
|
||||
}
|
||||
return h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, [
|
||||
h('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;' }, [
|
||||
h('select', {
|
||||
style: 'padding:6px 10px;border:1px solid var(--border-normal);border-radius:6px;background:var(--background-page);font:inherit;',
|
||||
onChange: (e) => { selected.value = e.target.value; },
|
||||
}, machines.value.map((m) => h('option', { value: m.id, selected: String(m.id) === String(selected.value) }, m.name))),
|
||||
h('button', {
|
||||
type: 'button', disabled: busy.value, onClick: drucken,
|
||||
style: 'cursor:pointer;border:1px solid var(--primary);background:var(--primary);color:#fff;border-radius:6px;padding:6px 16px;font:inherit;',
|
||||
}, busy.value ? 'Wird eingereiht …' : 'Drucken'),
|
||||
]),
|
||||
msg.value ? h('div', { style: 'color:var(--success, #2e7d32);font-size:13px;' }, msg.value) : null,
|
||||
err.value ? h('div', { style: 'color:var(--danger, #c62828);font-size:13px;' }, err.value) : null,
|
||||
]);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
id: 'skrift-print-button',
|
||||
name: 'Drucken',
|
||||
icon: 'print',
|
||||
description: 'Reiht den Auftrag als Druck-Job für eine Maschine ein.',
|
||||
component: PrintButton,
|
||||
types: ['alias'],
|
||||
localTypes: ['presentation'],
|
||||
group: 'presentation',
|
||||
options: null,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-printbutton",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Interface: reiht einen Auftrag als Druck-Job für eine Maschine ein (POST /skrift-orders/enqueue).",
|
||||
"directus:extension": {
|
||||
"type": "interface",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
1107
extensions/directus-extension-skrift-produktion/dist/index.js
vendored
Normal file
1107
extensions/directus-extension-skrift-produktion/dist/index.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
13
extensions/directus-extension-skrift-produktion/package.json
Normal file
13
extensions/directus-extension-skrift-produktion/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-produktion",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Modul: Produktions-Kommandozentrale (Status-Board, Druck-Popup, Empfänger/Dateien, Muster-Druck).",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
59
extensions/directus-extension-skrift-testprint/dist/index.js
vendored
Normal file
59
extensions/directus-extension-skrift-testprint/dist/index.js
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Skrift – Interface „Testdruck generieren & drucken"
|
||||
* ---------------------------------------------------------------------------
|
||||
* Button im test_prints-Datensatz: erzeugt aus Template + Text ein SVG und
|
||||
* reiht es als Druck-Job für die gewählte Maschine ein
|
||||
* (POST /skrift-orders/test-print). Datensatz vorher speichern.
|
||||
*
|
||||
* Von Hand als ESM (kein Build) – Vue + SDK stellt Directus zur Laufzeit bereit.
|
||||
*/
|
||||
import { defineComponent, ref, inject, h } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const TestPrintButton = defineComponent({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const values = inject('values', ref({}));
|
||||
const busy = ref(false);
|
||||
const msg = ref('');
|
||||
const err = ref('');
|
||||
|
||||
async function ausloesen() {
|
||||
const id = values && values.value ? values.value.id : null;
|
||||
if (!id) { err.value = 'Bitte den Datensatz zuerst speichern.'; return; }
|
||||
busy.value = true; err.value = ''; msg.value = '';
|
||||
try {
|
||||
const res = await api.post('/skrift-orders/test-print', { id });
|
||||
msg.value = `Generiert & eingereiht (Job ${res.data?.job}, Ordner ${res.data?.ref}).`;
|
||||
} catch (e) {
|
||||
const d = e && e.response && e.response.data;
|
||||
err.value = (d && (d.error + (d.detail ? ' – ' + d.detail : ''))) || (e && e.message) || 'Fehler.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return () => h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, [
|
||||
h('button', {
|
||||
type: 'button', disabled: busy.value, onClick: ausloesen,
|
||||
style: 'align-self:flex-start;cursor:pointer;border:1px solid var(--primary);background:var(--primary);color:#fff;border-radius:6px;padding:8px 18px;font:inherit;',
|
||||
}, busy.value ? 'Wird generiert …' : 'Generieren & drucken'),
|
||||
msg.value ? h('div', { style: 'color:var(--success, #2e7d32);font-size:13px;' }, msg.value) : null,
|
||||
err.value ? h('div', { style: 'color:var(--danger, #c62828);font-size:13px;' }, err.value) : null,
|
||||
h('div', { style: 'color:var(--foreground-subdued);font-size:12px;' },
|
||||
'Hinweis: Datensatz mit Template, Format, Maschine und Text speichern, dann klicken.'),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
id: 'skrift-testprint-button',
|
||||
name: 'Testdruck',
|
||||
icon: 'science',
|
||||
description: 'Generiert einen Testdruck und reiht ihn zum Drucken ein.',
|
||||
component: TestPrintButton,
|
||||
types: ['alias'],
|
||||
localTypes: ['presentation'],
|
||||
group: 'presentation',
|
||||
options: null,
|
||||
};
|
||||
13
extensions/directus-extension-skrift-testprint/package.json
Normal file
13
extensions/directus-extension-skrift-testprint/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-testprint",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Interface: generiert einen Testdruck aus einem Template + Text und reiht ihn zum Drucken ein (POST /skrift-orders/test-print).",
|
||||
"directus:extension": {
|
||||
"type": "interface",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user