Motiv-Freigabe: A6-Entwurf erzeugen + Kundenfreigabe (Backend/Board)
- skrift-orders: bei Konfigurator-Bestellung mit Motiv-Upload wird ein A6- Drucklayout (152×109 mm, 2 mm Beschnitt, Motiv mittig/randabfallend) erzeugt und als orders.motif_layout abgelegt; approval_status='pending' + Token gesetzt; Freigabe-Mail (motiv_freigabe) mit Einmal-Link verschickt. Öffentliche Endpunkte /freigabe/:token (Info), /:token/layout (SVG), POST (freigeben/ändern). - bootstrap: orders.approval_status/approval_token/approval_note/motif_layout, users.sevdesk_contact_id (bereits), Mailvorlage motiv_freigabe. - Produktion: Chip „wartet auf Freigabe / freigegeben / Änderungswunsch". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -272,6 +272,12 @@ const collections = [
|
||||
{ default: 'eingegangen', note: 'Produktions-/Kanban-Status.' }),
|
||||
f('brief_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Schriftstücke gedruckt (vom Agent gesetzt).' }),
|
||||
f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }),
|
||||
// Motiv-Freigabe (Postkarten mit Motiv-Upload): Kunde genehmigt den A6-Entwurf.
|
||||
dropdown('approval_status', ['none', 'pending', 'approved', 'changes'],
|
||||
{ default: 'none', note: 'Motiv-Freigabe: none=keine, pending=wartet auf Freigabe, approved=freigegeben, changes=Änderungswunsch.' }),
|
||||
f('approval_token', 'string', { interface: 'input', hidden: true, note: 'Token für den Freigabe-Link (Einmal-Zugang zum Entwurf).' }),
|
||||
f('approval_note', 'text', { interface: 'input-multiline', note: 'Änderungswunsch des Kunden zum Motiv-Entwurf.' }),
|
||||
m2o('motif_layout', 'directus_files', { note: 'Generiertes A6-Drucklayout (Motiv mittig, randabfallend) zur Freigabe.' }),
|
||||
// Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen).
|
||||
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } },
|
||||
{ field: 'production_status_changed_at', type: 'timestamp', meta: { interface: 'datetime', note: 'Zeitpunkt der letzten Produktionsstatus-Änderung (für „status_since" der Public-API).' }, schema: { is_nullable: true } },
|
||||
@@ -925,6 +931,12 @@ async function applySeed() {
|
||||
body: `<p>${G}</p><p>vielen Dank – Ihr Auftrag <strong>{{order_number}}</strong> für den Unterschriftenservice ist bei uns eingegangen. Wir prüfen Ihr Dokument und melden uns mit den nächsten Schritten.</p>` },
|
||||
{ key: 'extern_eingang', label: 'Externe Bestellung: Eingang', subject: 'Ihre Bestellung {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
|
||||
body: `<p>${G}</p><p>vielen Dank – Ihre Bestellung <strong>{{order_number}}</strong> ist bei uns eingegangen und geht direkt in die Produktion.</p>` },
|
||||
// Motiv-Freigabe: Kunde genehmigt den A6-Entwurf über den Link (Einmal-Zugang).
|
||||
{ key: 'motiv_freigabe', label: 'Motiv: Freigabe anfordern', subject: 'Bitte Ihren Postkarten-Entwurf freigeben – {{order_number}}', append_signature: true, attach_agb: false,
|
||||
body: `<p>${G}</p><p>vielen Dank für Ihre Bestellung <strong>{{order_number}}</strong>! Wir haben aus Ihrem Motiv einen Druck-Entwurf (A6) erstellt.</p>`
|
||||
+ `<p>Bitte prüfen und <strong>freigeben</strong> Sie den Entwurf – oder hinterlegen Sie einen Änderungswunsch:</p>`
|
||||
+ `<p><a href="{{freigabe_link}}" style="display:inline-block;background:#1E50A5;color:#fff;text-decoration:none;padding:12px 22px;border-radius:8px;font-weight:600;">Entwurf ansehen & freigeben</a></p>`
|
||||
+ `<p style="font-size:13px;color:#666;">Erst nach Ihrer Freigabe geht die Karte in den Druck.</p>` },
|
||||
];
|
||||
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });
|
||||
|
||||
|
||||
@@ -202,6 +202,30 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
return fs.readFileSync(path.join(UPLOAD_ROOT, f.filename_disk));
|
||||
}
|
||||
|
||||
/** Erzeugt aus einem hochgeladenen Motiv das A6-Drucklayout (152×109 mm, 2 mm
|
||||
* Beschnitt, Endformat 148×105) – Motiv mittig, randabfallend – und legt es als
|
||||
* Datei ab (orders.motif_layout). Rein serverseitig, für die Kundenfreigabe. */
|
||||
async function erzeugeMotivEntwurf(schema, orderId, fileId) {
|
||||
const meta = await new ItemsService('directus_files', { schema, accountability: null })
|
||||
.readOne(fileId, { fields: ['type', 'filename_disk'] });
|
||||
if (!meta?.filename_disk) throw new Error(`Motiv-Datei ${fileId} nicht gefunden.`);
|
||||
const base64 = fs.readFileSync(path.join(UPLOAD_ROOT, meta.filename_disk)).toString('base64');
|
||||
const mime = meta.type || 'image/png';
|
||||
// A6 quer randabfallend: Motiv füllt die volle Beschnittfläche (cover), mittig.
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="152mm" height="109mm" viewBox="0 0 152 109">`
|
||||
+ `<image href="data:${mime};base64,${base64}" x="0" y="0" width="152" height="109" preserveAspectRatio="xMidYMid slice"/>`
|
||||
+ `</svg>`;
|
||||
const files = new FilesService({ schema, accountability: null });
|
||||
const storage = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
|
||||
const layoutId = await files.uploadOne(
|
||||
Readable.from(Buffer.from(svg, 'utf8')),
|
||||
{ storage, filename_download: `a6-entwurf-${orderId}.svg`, title: `A6-Entwurf ${orderId}`, type: 'image/svg+xml' },
|
||||
undefined, { emitEvents: false },
|
||||
);
|
||||
await new ItemsService('orders', { schema, accountability: null }).updateOne(orderId, { motif_layout: layoutId });
|
||||
return layoutId;
|
||||
}
|
||||
|
||||
/** Baut die Multipart-Anfrage an den Signatur-Service (Vorschau/Render). */
|
||||
async function signatureForm(schema, pdfId, sigList) {
|
||||
const pdfBytes = await dateiBytes(schema, pdfId);
|
||||
@@ -914,6 +938,79 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Motiv-Freigabe (öffentlich, per Einmal-Token) ──────────────────────────
|
||||
const escHtml = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||||
const freigabeAuftrag = async (schema, token, fields) => {
|
||||
const t = String(token || '').trim();
|
||||
if (!t) return null;
|
||||
return (await new ItemsService('orders', { schema, accountability: null }).readByQuery({
|
||||
filter: { approval_token: { _eq: t } }, limit: 1, fields,
|
||||
}))?.[0] || null;
|
||||
};
|
||||
|
||||
// Auftrags-/Statusinfo für die Freigabe-Seite (kein Login nötig – Token genügt).
|
||||
router.get('/freigabe/:token', async (req, res) => {
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const o = await freigabeAuftrag(schema, req.params.token,
|
||||
['id', 'order_number', 'approval_status', 'approval_note', 'motif_layout', 'product.name']);
|
||||
if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
|
||||
return res.json({
|
||||
order_number: o.order_number,
|
||||
product: o.product?.name || 'Postkarte',
|
||||
status: o.approval_status || 'pending',
|
||||
note: o.approval_note || '',
|
||||
has_layout: !!o.motif_layout,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`[skrift-orders] freigabe GET: ${e.message}`);
|
||||
return res.status(500).json({ error: 'Fehler beim Laden.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Das A6-Entwurfs-SVG direkt ausliefern (Token-gated, kein Asset-Login nötig).
|
||||
router.get('/freigabe/:token/layout', async (req, res) => {
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const o = await freigabeAuftrag(schema, req.params.token, ['motif_layout']);
|
||||
if (!o?.motif_layout) return res.status(404).send('Kein Entwurf.');
|
||||
const bytes = await dateiBytes(schema, o.motif_layout);
|
||||
res.setHeader('Content-Type', 'image/svg+xml');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.send(bytes);
|
||||
} catch (e) {
|
||||
logger.error(`[skrift-orders] freigabe layout: ${e.message}`);
|
||||
return res.status(500).send('Fehler.');
|
||||
}
|
||||
});
|
||||
|
||||
// Freigabe oder Änderungswunsch speichern + Team benachrichtigen.
|
||||
router.post('/freigabe/:token', async (req, res) => {
|
||||
const action = String(req.body?.action || '');
|
||||
const note = String(req.body?.note || '').slice(0, 2000).trim();
|
||||
if (!['approve', 'changes'].includes(action)) return res.status(400).json({ error: 'Ungültige Aktion.' });
|
||||
if (action === 'changes' && !note) return res.status(400).json({ error: 'Bitte den Änderungswunsch beschreiben.' });
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const o = await freigabeAuftrag(schema, req.params.token, ['id', 'order_number', 'approval_status']);
|
||||
if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
|
||||
const neu = action === 'approve' ? 'approved' : 'changes';
|
||||
await svc('orders').updateOne(o.id, { approval_status: neu, approval_note: action === 'changes' ? note : null });
|
||||
await svc('status_history').createOne({ order: o.id, status: 'eingegangen',
|
||||
note: action === 'approve' ? 'Motiv-Entwurf vom Kunden freigegeben.' : `Motiv-Änderungswunsch: ${note}` }).catch(() => {});
|
||||
await teamMail(schema,
|
||||
action === 'approve' ? `✅ Motiv freigegeben: ${o.order_number}` : `✏️ Motiv-Änderungswunsch: ${o.order_number}`,
|
||||
action === 'approve'
|
||||
? `<p>Der Kunde hat den Motiv-Entwurf zu <strong>${escHtml(o.order_number)}</strong> freigegeben – kann in den Druck.</p>`
|
||||
: `<p>Änderungswunsch zum Motiv-Entwurf <strong>${escHtml(o.order_number)}</strong>:</p><p style="white-space:pre-wrap">${escHtml(note)}</p>`);
|
||||
return res.json({ ok: true, status: neu });
|
||||
} catch (e) {
|
||||
logger.error(`[skrift-orders] freigabe POST: ${e.message}`);
|
||||
return res.status(500).json({ error: 'Konnte nicht gespeichert werden.' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */
|
||||
router.get('/preview/quota', async (req, res) => {
|
||||
try {
|
||||
@@ -2053,11 +2150,29 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.',
|
||||
});
|
||||
|
||||
// Motiv-Upload (Postkarte) → A6-Entwurf erzeugen + Kundenfreigabe anfordern.
|
||||
let freigabeToken = null;
|
||||
const istMotivUpload = body.motif_mode === 'upload' && (body.motif_upload || body.source_file);
|
||||
if (istMotivUpload) {
|
||||
freigabeToken = crypto.randomBytes(24).toString('base64url');
|
||||
try { await svc('orders').updateOne(orderId, { approval_status: 'pending', approval_token: freigabeToken }); }
|
||||
catch (e) { logger.warn(`[skrift-orders] Freigabe-Setup: ${e.message}`); }
|
||||
// A6-Layout asynchron erzeugen – blockiert die Antwort an den Kunden nicht.
|
||||
erzeugeMotivEntwurf(schema, orderId, body.motif_upload || body.source_file)
|
||||
.catch((e) => logger.warn(`[skrift-orders] Motiv-Entwurf ${orderNumber}: ${e.message}`));
|
||||
}
|
||||
|
||||
// Separate, im Admin editierbare Kunden-Mail für Sonderfälle.
|
||||
const kundeEmail = norm(body.email);
|
||||
if (isEmail(kundeEmail)) {
|
||||
const vars = { order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' ') };
|
||||
if (quote.product?.input_mode === 'datei') sendeVorlage(schema, 'unterschriften_eingang', kundeEmail, vars);
|
||||
let portal = (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim();
|
||||
try { const es = await svc('email_settings').readSingleton({ fields: ['portal_url'] }); if (es?.portal_url) portal = String(es.portal_url).trim(); } catch { /* Default */ }
|
||||
const vars = {
|
||||
order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' '),
|
||||
freigabe_link: freigabeToken ? `${portal.replace(/\/$/, '')}/freigabe/${freigabeToken}` : '',
|
||||
};
|
||||
if (istMotivUpload) sendeVorlage(schema, 'motiv_freigabe', kundeEmail, vars);
|
||||
else if (quote.product?.input_mode === 'datei') sendeVorlage(schema, 'unterschriften_eingang', kundeEmail, vars);
|
||||
else if (body.source === 'extern') sendeVorlage(schema, 'extern_eingang', kundeEmail, vars);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ const Board = defineComponent({
|
||||
+ 'envelope_labeling,shipping_type,shipping_day,keep_contacts,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,'
|
||||
+ 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,'
|
||||
+ 'source,api_client.id,api_client.name,'
|
||||
+ 'source_file,internal_note,linked_order.id,linked_order.order_number,'
|
||||
+ 'source_file,internal_note,approval_status,approval_note,linked_order.id,linked_order.order_number,'
|
||||
+ 'customer.first_name,customer.last_name,customer.email,customer.phone';
|
||||
async function ladeOrders() {
|
||||
try {
|
||||
@@ -1078,6 +1078,9 @@ const Board = defineComponent({
|
||||
chip(o.payment_status === 'bezahlt' ? '✓ bezahlt' : (o.payment_status === 'storniert' ? 'storniert' : 'offen'),
|
||||
o.payment_status === 'bezahlt' ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--warning-25,rgba(230,160,0,.15))'),
|
||||
o.linked_order?.order_number ? chip('🔗 Kuverts: #' + o.linked_order.order_number, 'var(--primary-25,rgba(30,80,165,.14))') : null,
|
||||
o.approval_status === 'pending' ? chip('⏳ wartet auf Freigabe', 'var(--warning-25,rgba(230,160,0,.2))') : null,
|
||||
o.approval_status === 'approved' ? chip('✓ Motiv freigegeben', 'var(--success-25,rgba(46,125,50,.15))') : null,
|
||||
o.approval_status === 'changes' ? chip('✏ Änderungswunsch', 'var(--danger-25,rgba(198,40,40,.15))') : null,
|
||||
]);
|
||||
// „Auf einen Blick": welche Nummern wurden schon in den Druck gesendet
|
||||
// (aus den Jobs, inkl. bereits gedruckter). Kein Status, nur die Eingabe.
|
||||
|
||||
Reference in New Issue
Block a user