Produktions-Modul, überarbeitete Preislogik und WordPress-Preisrechner
Directus - Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/ Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange) und Empfängerliste mit Vorschau von Schriftstück und Kuvert. - Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche. - Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang. - Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert), Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard. - Absender aller Mails ist Skrift (Projektname + email_settings); die Angebotsanfrage nennt jetzt das angefragte Produkt. Preislogik - Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt. - Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen. - Preis pro Stück ohne einmalige Kosten (unit_net_total). - Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt. - Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm. - Briefnummern 1-basiert: Dateiname entspricht der Briefnummer. app.skrift.de - Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als eigene Seite, Kontaktprodukte direkt im Anfrageformular. - Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator. - Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet. - Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar, Standardmenge 100; Checkout-Checkboxen im Kartenstil. Produktion - skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter und automatischer Übergang in die Kuvertierung. WordPress - Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt, stündlicher Abgleich der Preisdaten in WordPress und ein sicherer Formel-Interpreter, damit Formeländerungen automatisch greifen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ import crypto from 'node:crypto';
|
||||
const CODE_TTL_MIN = 10;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const RESEND_COOLDOWN_SEC = 60;
|
||||
const CUSTOMER_ROLE_NAME = 'Kunde (Login)';
|
||||
const CUSTOMER_ROLE_NAME = 'Kunde';
|
||||
|
||||
const handler = (router, { services, getSchema, env, logger }) => {
|
||||
const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services;
|
||||
@@ -35,6 +35,12 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
||||
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 });
|
||||
|
||||
async function findUserByEmail(email, schema) {
|
||||
@@ -48,18 +54,41 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
||||
}
|
||||
|
||||
async function sendCodeMail(email, code) {
|
||||
const mail = new MailService({ schema: await getSchema(), accountability: null });
|
||||
await mail.send({
|
||||
to: email,
|
||||
subject: `Ihr Anmeldecode: ${code}`,
|
||||
html: `
|
||||
<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>
|
||||
`,
|
||||
});
|
||||
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 ────────────────────────────────────────────────────────
|
||||
@@ -146,28 +175,38 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
||||
|
||||
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
|
||||
const users = new UsersService({ schema, accountability: null });
|
||||
const tempPassword = crypto.randomBytes(32).toString('base64url');
|
||||
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 result = await auth.login('default', { email, password: tempPassword });
|
||||
|
||||
// Kundendatensatz mit dem Login-Konto verknüpfen, falls noch offen.
|
||||
const customers = new ItemsService('customers', { schema, accountability: null });
|
||||
const cust = await customers.readByQuery({
|
||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'linked_user'],
|
||||
});
|
||||
if (cust?.[0] && !cust[0].linked_user) {
|
||||
await customers.updateOne(cust[0].id, { linked_user: user.id });
|
||||
const versuche = [
|
||||
() => auth.login('default', { email, password: tempPassword }),
|
||||
() => auth.login({ email, password: tempPassword }),
|
||||
() => auth.login({ email, 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.message}`);
|
||||
logger.error(`[skrift-auth] verify-code: ${err.stack || err.message}`);
|
||||
return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' });
|
||||
}
|
||||
});
|
||||
@@ -193,28 +232,18 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
||||
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 });
|
||||
const userId = await users.createOne({
|
||||
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,
|
||||
});
|
||||
|
||||
// Kundendatensatz anlegen oder verknüpfen (Zuordnung immer per E-Mail).
|
||||
const customers = new ItemsService('customers', { schema, accountability: null });
|
||||
const cust = (await customers.readByQuery({
|
||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
||||
}))?.[0];
|
||||
if (cust) await customers.updateOne(cust.id, { linked_user: userId });
|
||||
else await customers.createOne({
|
||||
email, linked_user: userId,
|
||||
first_name: req.body?.first_name ?? null,
|
||||
last_name: req.body?.last_name ?? null,
|
||||
person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||
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 });
|
||||
|
||||
158
Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js
vendored
Normal file
158
Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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]));
|
||||
|
||||
/** 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 {}; }
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!tpl) return; // keine (aktive) Vorlage → 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', 'format.name',
|
||||
],
|
||||
});
|
||||
if (!order?.customer_email) 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 };
|
||||
// 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}`); }
|
||||
});
|
||||
|
||||
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}`); }
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
149
Docker/directus/extensions/directus-extension-skrift-orderentries/dist/index.js
vendored
Normal file
149
Docker/directus/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
Docker/directus/extensions/directus-extension-skrift-orderfiles/dist/index.js
vendored
Normal file
105
Docker/directus/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,
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
83
Docker/directus/extensions/directus-extension-skrift-printbutton/dist/index.js
vendored
Normal file
83
Docker/directus/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"
|
||||
}
|
||||
}
|
||||
701
Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js
vendored
Normal file
701
Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js
vendored
Normal file
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* Skrift – Modul „Produktion"
|
||||
* ===========================================================================
|
||||
* Die Produktions-Kommandozentrale: ein Board mit sechs Status-Umschaltern,
|
||||
* zentraler Suche und pro Auftrag den drei Aktionen Drucken / Empfänger /
|
||||
* Dateien – man muss die Ansicht für nichts verlassen.
|
||||
*
|
||||
* 1 Neu – neue Aufträge + Muster- und Angebotsanfragen
|
||||
* 2 Warteschlange – eingereihte Druck-Jobs, nach Maschine unterteilt
|
||||
* 3 Im Druck – laufende Aufträge (Brief/Kuvert getrennt gekennzeichnet)
|
||||
* 4 Kuvertierung – fertig gedruckt, wird eingetütet
|
||||
* 5 Versandfertig – bereit zum Versand
|
||||
* 6 Versendet – abgeschlossen
|
||||
*
|
||||
* Druck-Popup wie im alten Job-Manager: Maschine + je Typ Template mit
|
||||
* vorgeschlagenem Format, editierbaren Maßen und Nummern-Bereich, dazu
|
||||
* Warteschlange vs. Direktdruck (Direkt gesperrt, wenn die Maschine druckt).
|
||||
* Muster: manueller Druck mit Adresse aus der Anfrage + Du/Sie-Standardtext.
|
||||
*
|
||||
* Bewusst von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||
*/
|
||||
import { defineComponent, ref, reactive, computed, onMounted, h, Teleport, resolveComponent } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
// Auftragsformat → Standard-Template-Schlüssel (wie im Backend /enqueue).
|
||||
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6_v2' };
|
||||
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
|
||||
const INLAND = /^(de|deutschland|germany)$/i;
|
||||
|
||||
const TABS = [
|
||||
{ key: 'neu', label: '🆕 Neu' },
|
||||
{ key: 'warteschlange', label: '⏳ Warteschlange' },
|
||||
{ key: 'druck', label: '🖨 Im Druck' },
|
||||
{ key: 'kuvertierung', label: '✉ Kuvertierung' },
|
||||
{ key: 'versandfertig', label: '📦 Versandfertig' },
|
||||
{ key: 'versendet', label: '🚚 Versendet' },
|
||||
{ key: 'archiv', label: '🗄 Archiv' },
|
||||
];
|
||||
const STATUS_LABEL = {
|
||||
eingegangen: 'Eingegangen', im_druck: 'Im Druck', kuvertieren: 'Kuvertierung',
|
||||
versandfertig: 'Versandfertig', versendet: 'Versendet', storniert: 'Storniert',
|
||||
};
|
||||
// Tage, nach denen ein versendeter Auftrag automatisch ins Archiv wandert.
|
||||
const ARCHIV_TAGE = 14;
|
||||
const PARAM_FELDER = [
|
||||
['width', 'Breite'], ['height', 'Höhe'], ['xpos', 'X'], ['ypos', 'Y'], ['scale', 'Scale'], ['rotation', 'Rot°'],
|
||||
];
|
||||
|
||||
// ── kleine Stil-/Render-Helfer ──────────────────────────────────────────────
|
||||
// Weiße Karte, die sich klar von der (leicht getönten) Inhaltsfläche abhebt.
|
||||
const CARD = 'border:1px solid var(--border-subdued);border-radius:8px;padding:16px 18px;background:var(--background-page,#fff);box-shadow:0 1px 3px rgba(0,0,0,.08);';
|
||||
// Getönte Sektion (Briefe/Kuverts) mit Rand + Radius; Abstand über margin-bottom.
|
||||
const SECTION = 'background:var(--background-subdued,#f1f4f9);border:1px solid var(--border-normal);border-radius:8px;padding:16px 18px;margin-bottom:16px;';
|
||||
// Deutlich sichtbares Eingabefeld (weiß auf getönter Sektion).
|
||||
const S_FELD = 'border:1px solid var(--border-normal);border-radius:7px;padding:6px 10px;background:var(--background-page,#fff);';
|
||||
const S_INPUT = 'padding:7px 10px;border:1px solid var(--border-normal);border-radius:7px;background:var(--background-page);color:var(--foreground-normal);font:inherit;font-size:13px;';
|
||||
const S_SELECT = S_INPUT + 'cursor:pointer;';
|
||||
|
||||
// Directus-Primärfarbe (Marke) + Kartenfarben je Vorgangstyp.
|
||||
const ACCENT = '#1E50A5'; // Aufträge / Druck → blau
|
||||
const FARBE_MUSTER = '#d13438'; // Musteranfrage → rot
|
||||
const FARBE_ANGEBOT = '#e07b00'; // Angebotsanfrage → orange
|
||||
|
||||
function btn(label, onClick, kind) {
|
||||
const base = 'cursor:pointer;border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;border:1px solid;';
|
||||
const style = kind === 'primary'
|
||||
? base + `border-color:${ACCENT};background:${ACCENT};color:#fff;`
|
||||
: kind === 'danger'
|
||||
? base + 'border-color:var(--danger,#c62828);background:transparent;color:var(--danger,#c62828);'
|
||||
: 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:11px;padding:2px 8px;border-radius:20px;`
|
||||
+ `background:${farbe || 'var(--background-subdued)'};color:var(--foreground-subdued);white-space:nowrap;`,
|
||||
}, text);
|
||||
}
|
||||
function subdued(text, extra) {
|
||||
return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, text);
|
||||
}
|
||||
// Segmentierter Umschalter mit sichtbarem Rahmen: Optionen teilen sich die Breite.
|
||||
function segToggle(value, opts, onSel) {
|
||||
return h('div', { style: 'display:flex;width:100%;border:1px solid var(--border-normal);border-radius:8px;overflow:hidden;background:var(--background-subdued,#f1f4f9);' },
|
||||
opts.map((o, i) => h('button', {
|
||||
type: 'button', disabled: o.disabled, onClick: () => { if (!o.disabled) onSel(o.key); },
|
||||
style: `flex:1;padding:12px 8px;font:inherit;font-size:13px;text-align:center;cursor:${o.disabled ? 'not-allowed' : 'pointer'};border:none;`
|
||||
+ (i > 0 ? 'border-left:1px solid var(--border-normal);' : '')
|
||||
+ (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : 'background:transparent;color:var(--foreground-normal);')
|
||||
+ (o.disabled ? 'opacity:.45;' : ''),
|
||||
}, o.label)));
|
||||
}
|
||||
|
||||
// ── Datenhelfer ─────────────────────────────────────────────────────────────
|
||||
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(' · ');
|
||||
}
|
||||
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(' · ');
|
||||
}
|
||||
function kundeName(o) {
|
||||
const c = o.customer || {};
|
||||
return [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email || '—';
|
||||
}
|
||||
|
||||
const Board = defineComponent({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const tab = ref('neu');
|
||||
const suche = ref('');
|
||||
const loading = ref(false);
|
||||
const fehler = ref('');
|
||||
const meldung = ref('');
|
||||
|
||||
const orders = ref([]);
|
||||
const requests = ref([]);
|
||||
const jobs = ref([]);
|
||||
const machines = ref([]);
|
||||
const templates = ref([]);
|
||||
const muster = ref({ text_du: '', text_sie: '' });
|
||||
|
||||
// ── Modale ──
|
||||
const druck = reactive({ open: false, order: null, machine: null, mode: 'queue', busy: false, brief: null, umschlag: null, msg: '', err: '' });
|
||||
const manuell = reactive({ open: false, req: null, form: 'sie', font: 'tilda', format: 'a4', template: null, machine: null, mode: 'queue', text: '', msg: '', err: '' });
|
||||
const empf = reactive({ open: false, mode: 'order', titel: '', order: null, suche: '', rows: [], files: [],
|
||||
info: '', laden: false, selected: null, pl: '', pe: '', pLoad: false, perr: '', lb: '', lbTitel: '' });
|
||||
|
||||
// ── Laden ──
|
||||
// Basis-Felder ohne die evtl. noch nicht gebootstrapten Felder – diese
|
||||
// werden separat versucht, damit ein fehlendes Feld nicht das ganze Board sperrt.
|
||||
const ORDER_FELDER = 'id,order_number,status,payment_status,production_status,needs_envelope,envelope_format,'
|
||||
+ 'envelope_labeling,shipping_type,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,'
|
||||
+ 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,'
|
||||
+ 'customer.first_name,customer.last_name,customer.email,customer.phone';
|
||||
async function ladeOrders() {
|
||||
try {
|
||||
return await api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER + ',versandt_am' } });
|
||||
} catch {
|
||||
// Feld „versandt_am" fehlt noch (Bootstrap nicht gelaufen) → ohne laden,
|
||||
// Archiv nutzt ersatzweise date_updated.
|
||||
return api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER } });
|
||||
}
|
||||
}
|
||||
async function laden() {
|
||||
loading.value = true; fehler.value = '';
|
||||
try {
|
||||
const [o, r, j, m, t, ms] = await Promise.all([
|
||||
ladeOrders(),
|
||||
api.get('/items/offer_requests', { params: {
|
||||
limit: -1, sort: '-date_created',
|
||||
filter: { status: { _in: ['neu', 'in_bearbeitung'] } },
|
||||
fields: 'id,type,name,email,phone,quantity,product,message,address,status,date_created',
|
||||
} }),
|
||||
api.get('/items/jobs', { params: {
|
||||
limit: -1, sort: '-priority,date_created',
|
||||
filter: { status: { _in: ['queued', 'printing', 'failed'] } },
|
||||
fields: 'id,type,status,priority,numbers,output_ref,error,date_created,machine.id,machine.name,order.id,order.order_number',
|
||||
} }),
|
||||
api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } }),
|
||||
api.get('/items/format_templates', { params: { limit: -1, fields: 'id,key,name,type,width,height,xpos,ypos,scale,rotation' } }),
|
||||
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: { text_du: '', text_sie: '' } } })),
|
||||
]);
|
||||
orders.value = o.data?.data || [];
|
||||
requests.value = r.data?.data || [];
|
||||
jobs.value = j.data?.data || [];
|
||||
machines.value = (m.data?.data || []);
|
||||
templates.value = t.data?.data || [];
|
||||
muster.value = ms.data?.data || { text_du: '', text_sie: '' };
|
||||
} catch (e) {
|
||||
fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
onMounted(laden);
|
||||
|
||||
const aktiveMaschinen = computed(() => machines.value.filter((m) => m.active !== false));
|
||||
const maschineBusy = (id) => jobs.value.some((j) => j.status === 'printing' && String(j.machine?.id) === String(id));
|
||||
|
||||
// Aufträge mit offenen/laufenden Jobs → gehören in „Warteschlange"/„Im Druck", nicht mehr in „Neu".
|
||||
const orderIdsInArbeit = computed(() => {
|
||||
const s = new Set();
|
||||
for (const j of jobs.value) if (j.order?.id && (j.status === 'queued' || j.status === 'printing')) s.add(String(j.order.id));
|
||||
return s;
|
||||
});
|
||||
|
||||
function trifftSuche(hay) {
|
||||
const q = suche.value.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return hay.toLowerCase().includes(q);
|
||||
}
|
||||
const orderHay = (o) => [o.order_number, kundeName(o), o.customer?.email, o.customer?.phone,
|
||||
o.product?.name, o.format?.name, o.shipping_type, o.production_status, o.payment_status].filter(Boolean).join(' ');
|
||||
|
||||
// Tab-Listen
|
||||
const neueOrders = computed(() => orders.value.filter((o) =>
|
||||
(o.production_status || 'eingegangen') === 'eingegangen' && !orderIdsInArbeit.value.has(String(o.id)) && trifftSuche(orderHay(o))));
|
||||
const neueRequests = computed(() => requests.value.filter((r) =>
|
||||
trifftSuche([r.name, r.email, r.phone, r.product, r.message, r.type].filter(Boolean).join(' '))));
|
||||
const ordersByStatus = (st) => orders.value.filter((o) => (o.production_status || 'eingegangen') === st && trifftSuche(orderHay(o)));
|
||||
// Archiv: storniert ODER seit mehr als 14 Tagen versendet.
|
||||
const alterTage = (ts) => (ts ? (Date.now() - new Date(ts).getTime()) / 86400000 : Infinity);
|
||||
const istArchiviert = (o) => o.production_status === 'storniert'
|
||||
|| (o.production_status === 'versendet' && alterTage(o.versandt_am || o.date_updated) > ARCHIV_TAGE);
|
||||
const versendetAktiv = () => orders.value.filter((o) => o.production_status === 'versendet' && !istArchiviert(o) && trifftSuche(orderHay(o)));
|
||||
const archivListe = () => orders.value.filter((o) => istArchiviert(o) && trifftSuche(orderHay(o)));
|
||||
const queuedByMachine = computed(() => {
|
||||
const groups = {};
|
||||
for (const m of aktiveMaschinen.value) groups[m.id] = { machine: m, jobs: [] };
|
||||
groups.__ohne = { machine: { name: 'Ohne Maschine' }, jobs: [] };
|
||||
for (const j of jobs.value) {
|
||||
if (j.status !== 'queued') continue;
|
||||
if (!trifftSuche([j.order?.order_number, j.output_ref, j.type].filter(Boolean).join(' '))) continue;
|
||||
(groups[j.machine?.id] || groups.__ohne).jobs.push(j);
|
||||
}
|
||||
return Object.values(groups).filter((g) => g.jobs.length);
|
||||
});
|
||||
const tabCount = (key) => {
|
||||
if (key === 'neu') return neueOrders.value.length + neueRequests.value.length;
|
||||
if (key === 'warteschlange') return jobs.value.filter((j) => j.status === 'queued').length;
|
||||
if (key === 'druck') return ordersByStatus('im_druck').length;
|
||||
if (key === 'kuvertierung') return ordersByStatus('kuvertieren').length;
|
||||
if (key === 'versandfertig') return ordersByStatus('versandfertig').length;
|
||||
if (key === 'versendet') return versendetAktiv().length;
|
||||
if (key === 'archiv') return archivListe().length;
|
||||
return 0;
|
||||
};
|
||||
|
||||
function flash(msg) { meldung.value = msg; setTimeout(() => { if (meldung.value === msg) meldung.value = ''; }, 4000); }
|
||||
|
||||
// ── Statuswechsel eines Auftrags ──
|
||||
async function setStatus(order, neu) {
|
||||
const patch = { production_status: neu };
|
||||
// Versanddatum festhalten (Basis fürs Auto-Archiv nach 14 Tagen).
|
||||
if (neu === 'versendet' && !order.versandt_am) patch.versandt_am = new Date().toISOString();
|
||||
try {
|
||||
await api.patch(`/items/orders/${order.id}`, patch);
|
||||
order.production_status = neu;
|
||||
if (patch.versandt_am) order.versandt_am = patch.versandt_am;
|
||||
flash(`${order.order_number} → ${STATUS_LABEL[neu]}`);
|
||||
} catch (e) { fehler.value = e?.message || 'Statuswechsel fehlgeschlagen.'; }
|
||||
}
|
||||
|
||||
// ── Druck-Popup ──
|
||||
const tplByKey = (key, typ) => templates.value.find((t) => t.key === key && t.type === typ) || templates.value.find((t) => t.type === typ) || null;
|
||||
const tplToFelder = (t) => ({ tplId: t?.id ?? null, width: t?.width ?? 0, height: t?.height ?? 0, xpos: t?.xpos ?? 0, ypos: t?.ypos ?? 0, scale: t?.scale ?? 1, rotation: t?.rotation ?? 0 });
|
||||
|
||||
function openDruck(order) {
|
||||
const bt = tplByKey(LETTER_TPL[order.format?.key] || 'a4_single', 'brief');
|
||||
const wantsEnv = order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine';
|
||||
const et = wantsEnv ? tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1', 'umschlag') : null;
|
||||
const n = Math.max(1, order.entries_count || 1);
|
||||
druck.order = order;
|
||||
druck.machine = aktiveMaschinen.value[0]?.id ?? null;
|
||||
druck.mode = 'queue'; druck.msg = ''; druck.err = '';
|
||||
druck.brief = { enabled: true, numbers: `1-${n}`, done: !!order.brief_gedruckt, ...tplToFelder(bt) };
|
||||
druck.umschlag = wantsEnv ? { enabled: true, numbers: `1-${n}`, done: !!order.kuvert_gedruckt, ...tplToFelder(et) } : null;
|
||||
druck.open = true;
|
||||
}
|
||||
function druckTplWechsel(sec, tplId) {
|
||||
const t = templates.value.find((x) => String(x.id) === String(tplId));
|
||||
if (!t) return;
|
||||
Object.assign(druck[sec], tplToFelder(t));
|
||||
}
|
||||
async function submitDruck() {
|
||||
if (!druck.machine) { druck.err = 'Bitte Maschine wählen.'; return; }
|
||||
const sections = {};
|
||||
const bau = (sec) => ({ enabled: true, template: sec.tplId, numbers: sec.numbers || '',
|
||||
params: { width: sec.width, height: sec.height, xpos: sec.xpos, ypos: sec.ypos, scale: sec.scale, rotation: sec.rotation } });
|
||||
if (druck.brief?.enabled) sections.brief = bau(druck.brief);
|
||||
if (druck.umschlag?.enabled) sections.umschlag = bau(druck.umschlag);
|
||||
if (!Object.keys(sections).length) { druck.err = 'Kein Abschnitt gewählt.'; return; }
|
||||
druck.err = ''; druck.msg = 'Wird eingereiht …';
|
||||
try {
|
||||
const res = await api.post('/skrift-orders/enqueue', { order: druck.order.id, machine: druck.machine, mode: druck.mode, sections });
|
||||
const n = res.data?.jobs?.length || 0;
|
||||
const modus = res.data?.mode === 'direct' ? 'Direktdruck' : 'Warteschlange';
|
||||
flash(`${n} Job(s) eingereiht (${modus}).`);
|
||||
druck.open = false;
|
||||
await laden();
|
||||
} catch (e) { druck.err = e?.response?.data?.error || e?.message || 'Einreihen fehlgeschlagen.'; druck.msg = ''; }
|
||||
}
|
||||
|
||||
// ── Muster: manueller Druck ──
|
||||
function musterAufloesen() {
|
||||
const roh = manuell.form === 'du' ? muster.value.text_du : muster.value.text_sie;
|
||||
const a = manuell.req?.address && typeof manuell.req.address === 'object' ? manuell.req.address : {};
|
||||
const name = manuell.req?.name || '';
|
||||
const teile = name.split(' ');
|
||||
const vor = teile[0] || ''; const nach = teile.slice(1).join(' ');
|
||||
manuell.text = String(roh || '')
|
||||
.replace(/\[\[Vorname\]\]/gi, a.first_name || a.vorname || vor)
|
||||
.replace(/\[\[Nachname\]\]/gi, a.last_name || a.nachname || nach)
|
||||
.replace(/\[\[Anrede\]\]/gi, a.salutation || a.anrede || '')
|
||||
.replace(/\[\[Name\]\]/gi, name);
|
||||
}
|
||||
function openMuster(req) {
|
||||
manuell.req = req; manuell.form = 'sie'; manuell.font = 'tilda'; manuell.format = 'a4';
|
||||
manuell.template = tplByKey('a4_single', 'brief')?.id ?? templates.value.find((t) => t.type === 'brief')?.id ?? null;
|
||||
manuell.machine = aktiveMaschinen.value[0]?.id ?? null; manuell.mode = 'queue';
|
||||
manuell.msg = ''; manuell.err = '';
|
||||
musterAufloesen();
|
||||
manuell.open = true;
|
||||
}
|
||||
async function submitMuster() {
|
||||
if (!manuell.text.trim()) { manuell.err = 'Text fehlt.'; return; }
|
||||
if (!manuell.template || !manuell.machine) { manuell.err = 'Template und Maschine wählen.'; return; }
|
||||
manuell.err = ''; manuell.msg = 'Wird erzeugt …';
|
||||
try {
|
||||
const res = await api.post('/skrift-orders/manual-print', {
|
||||
text: manuell.text, font: manuell.font, format: manuell.format,
|
||||
template: manuell.template, machine: manuell.machine, mode: manuell.mode,
|
||||
offer_request: manuell.req?.id || null,
|
||||
});
|
||||
const modus = res.data?.mode === 'direct' ? 'Direktdruck' : 'Warteschlange';
|
||||
flash(`Muster erzeugt & eingereiht (${modus}).`);
|
||||
manuell.open = false;
|
||||
await laden();
|
||||
} catch (e) { manuell.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Fehlgeschlagen.'; manuell.msg = ''; }
|
||||
}
|
||||
|
||||
// ── Empfänger-Drawer (mit Dokument-Vorschau je Zeile) ──
|
||||
const dateiNummer = (name) => {
|
||||
const z = String(name || '').replace(/\.[^.]*$/, '').replace(/\D/g, '');
|
||||
return z ? parseInt(z, 10) : null;
|
||||
};
|
||||
async function openEmpf(order) {
|
||||
empf.open = true; empf.mode = 'order'; empf.titel = `Empfänger · ${order.order_number}`;
|
||||
empf.order = order.order_number; empf.suche = ''; empf.rows = []; empf.files = []; empf.info = '';
|
||||
empf.selected = null; empf.pl = ''; empf.pe = ''; empf.perr = ''; empf.lb = ''; empf.laden = true;
|
||||
try {
|
||||
const [ent, fl] = await Promise.all([
|
||||
api.get('/items/order_entries', { params: { 'filter[order][_eq]': order.id, sort: 'letter_number', limit: -1, fields: '*' } }),
|
||||
api.get('/skrift-orders/files/' + encodeURIComponent(order.order_number)).catch(() => ({ data: { files: [] } })),
|
||||
]);
|
||||
empf.rows = (ent.data?.data || []).map((e) => ({ nr: e.letter_number, zeile: volleZeile(e), extra: extras(e) }));
|
||||
empf.files = fl.data?.files || [];
|
||||
} catch (e) { empf.info = e?.message || 'Empfänger konnten nicht geladen werden.'; } finally { empf.laden = false; }
|
||||
}
|
||||
function openReqInfo(req) {
|
||||
empf.open = true; empf.mode = 'request'; empf.titel = `Anfrage · ${req.name}`; empf.suche = ''; empf.info = '';
|
||||
empf.files = []; empf.selected = null; empf.pl = ''; empf.pe = ''; empf.lb = '';
|
||||
const a = req.address && typeof req.address === 'object' ? Object.entries(req.address).map(([k, v]) => `${k}: ${v}`).join(' · ') : (req.address || '');
|
||||
empf.rows = [
|
||||
{ nr: '✉', zeile: `${req.name} · ${req.email}${req.phone ? ' · ' + req.phone : ''}`, extra: '' },
|
||||
{ nr: '📦', zeile: a || 'Keine Adresse angegeben.', extra: '' },
|
||||
{ nr: '💬', zeile: req.message || '—', extra: `Produkt: ${req.product || '—'} · Menge: ${req.quantity ?? '—'}` },
|
||||
];
|
||||
empf.laden = false;
|
||||
}
|
||||
async function empfBlob(f) {
|
||||
const res = await api.get('/skrift-orders/files/' + encodeURIComponent(empf.order) + '/download', { params: { rel: f.rel }, responseType: 'blob' });
|
||||
return URL.createObjectURL(res.data);
|
||||
}
|
||||
// Positionsbasierter Abgleich: der i-te Empfänger → die i-te Datei. Robust
|
||||
// gegen alte (0-basierte) und neue (1-basierte) Dateinamen.
|
||||
const nachNummer = (kind) => empf.files
|
||||
.filter((f) => f.kind === kind)
|
||||
.sort((a, b) => (dateiNummer(a.name) ?? 0) - (dateiNummer(b.name) ?? 0));
|
||||
async function previewEntry(nr, rang) {
|
||||
empf.selected = nr; empf.perr = ''; empf.pLoad = true;
|
||||
if (empf.pl) URL.revokeObjectURL(empf.pl); if (empf.pe) URL.revokeObjectURL(empf.pe);
|
||||
empf.pl = ''; empf.pe = '';
|
||||
try {
|
||||
const brief = nachNummer('schriftstueck')[rang] || empf.files.find((f) => f.kind === 'schriftstueck' && dateiNummer(f.name) === nr);
|
||||
const kuvert = nachNummer('umschlag')[rang] || empf.files.find((f) => f.kind === 'umschlag' && dateiNummer(f.name) === nr);
|
||||
if (brief) empf.pl = await empfBlob(brief);
|
||||
if (kuvert) empf.pe = await empfBlob(kuvert);
|
||||
if (!brief && !kuvert) {
|
||||
empf.perr = empf.files.length
|
||||
? 'Für diese Nummer sind noch keine Dateien generiert.'
|
||||
: 'Für diesen Auftrag wurden noch keine Dateien generiert (erst nach Abschluss/Generierung).';
|
||||
}
|
||||
} catch (e) { empf.perr = 'Vorschau fehlgeschlagen.'; } finally { empf.pLoad = false; }
|
||||
}
|
||||
const empfTreffer = computed(() => {
|
||||
const q = empf.suche.trim().toLowerCase();
|
||||
if (!q) return empf.rows;
|
||||
return empf.rows.filter((r) => `#${r.nr} ${r.zeile} ${r.extra}`.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// ── Job abbrechen ──
|
||||
async function cancelJob(job) {
|
||||
try { await api.delete(`/items/jobs/${job.id}`); flash('Job entfernt.'); await laden(); }
|
||||
catch (e) { fehler.value = e?.message || 'Job konnte nicht entfernt werden.'; }
|
||||
}
|
||||
async function setRequestStatus(req, st) {
|
||||
try { await api.patch(`/items/offer_requests/${req.id}`, { status: st }); flash('Anfrage aktualisiert.'); await laden(); }
|
||||
catch (e) { fehler.value = e?.message || 'Fehlgeschlagen.'; }
|
||||
}
|
||||
|
||||
// ── Render-Bausteine ──────────────────────────────────────────────────
|
||||
function actionButtons(order) {
|
||||
// Drucken + Empfänger (Empfänger enthält die Vorschau je Dokument).
|
||||
return h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||
btn('🖨 Drucken', () => openDruck(order), 'primary'),
|
||||
btn('👥 Empfänger', () => openEmpf(order)),
|
||||
]);
|
||||
}
|
||||
function statusSelect(order) {
|
||||
return h('select', {
|
||||
style: S_SELECT + 'font-size:12px;padding:4px 8px;',
|
||||
value: order.production_status || 'eingegangen',
|
||||
onChange: (e) => setStatus(order, e.target.value),
|
||||
}, Object.entries(STATUS_LABEL).map(([k, v]) => h('option', { value: k, selected: (order.production_status || 'eingegangen') === k }, v)));
|
||||
}
|
||||
function orderCard(order, opts) {
|
||||
const o = order; const zeigeBadges = opts?.badges;
|
||||
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;' }, [
|
||||
h('div', {}, [
|
||||
h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`),
|
||||
subdued(`${kundeName(o)}${o.customer?.email ? ' · ' + o.customer.email : ''}${o.customer?.phone ? ' · ' + o.customer.phone : ''}`, 'margin-top:2px;'),
|
||||
]),
|
||||
statusSelect(o),
|
||||
]);
|
||||
const meta = h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin:10px 0;' }, [
|
||||
chip(`${o.entries_count || 0} Briefe`),
|
||||
chip(o.needs_envelope ? `${o.entries_count || 0} Kuverts` : 'ohne Kuvert'),
|
||||
chip(o.shipping_type === 'einzeln' ? 'Einzelversand' : 'Sammelversand'),
|
||||
chip(o.product?.name || 'Produkt?'),
|
||||
chip(o.format?.name || o.format?.key || 'Format?'),
|
||||
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))'),
|
||||
]);
|
||||
const badges = zeigeBadges ? h('div', { style: 'display:flex;gap:8px;margin-bottom:10px;' }, [
|
||||
chip(o.brief_gedruckt ? '✓ Brief gedruckt' : '… Brief offen', o.brief_gedruckt ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--background-subdued)'),
|
||||
o.needs_envelope ? chip(o.kuvert_gedruckt ? '✓ Kuvert gedruckt' : '… Kuvert offen', o.kuvert_gedruckt ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--background-subdued)') : null,
|
||||
]) : null;
|
||||
return h('div', { key: 'o' + o.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${ACCENT};` }, [kopf, meta, badges, actionButtons(o)]);
|
||||
}
|
||||
function requestCard(req) {
|
||||
const istMuster = req.type === 'muster';
|
||||
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;' }, [
|
||||
h('div', {}, [
|
||||
h('div', { style: 'font-weight:700;font-size:15px;' }, `${istMuster ? '🧪 Muster' : '📝 Angebot'} · ${req.name}`),
|
||||
subdued(`${req.email}${req.phone ? ' · ' + req.phone : ''}`, 'margin-top:2px;'),
|
||||
]),
|
||||
h('select', { style: S_SELECT + 'font-size:12px;padding:4px 8px;', value: req.status, onChange: (e) => setRequestStatus(req, e.target.value) },
|
||||
['neu', 'in_bearbeitung', 'beantwortet', 'abgelehnt'].map((s) => h('option', { value: s, selected: req.status === s }, s))),
|
||||
]);
|
||||
const meta = h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin:10px 0;' }, [
|
||||
chip(req.product || 'Produkt?'), req.quantity ? chip(`Menge ${req.quantity}`) : null,
|
||||
]);
|
||||
const nachricht = req.message ? subdued(req.message, 'margin:0 0 10px;white-space:pre-wrap;') : null;
|
||||
const aktionen = h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||
// Muster (Druckprodukt) → manueller Druck; individuelles Angebot → kein Druck.
|
||||
istMuster ? btn('🖨 Muster drucken', () => openMuster(req), 'primary') : null,
|
||||
btn('👤 Details', () => openReqInfo(req)),
|
||||
]);
|
||||
return h('div', { key: 'r' + req.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${istMuster ? FARBE_MUSTER : FARBE_ANGEBOT};` }, [kopf, meta, nachricht, aktionen]);
|
||||
}
|
||||
|
||||
function tabInhalt() {
|
||||
if (tab.value === 'neu') {
|
||||
const items = [...neueOrders.value.map((o) => orderCard(o)), ...neueRequests.value.map(requestCard)];
|
||||
return items.length ? items : subdued('Nichts Neues.');
|
||||
}
|
||||
if (tab.value === 'warteschlange') {
|
||||
const gruppen = queuedByMachine.value;
|
||||
if (!gruppen.length) return subdued('Warteschlange leer.');
|
||||
return gruppen.map((g) => h('div', { style: 'margin-bottom:18px;' }, [
|
||||
h('div', { style: 'font-weight:700;margin-bottom:8px;' }, `🖨 ${g.machine.name}${g.machine.status ? ' · ' + g.machine.status : ''}`),
|
||||
...g.jobs.map((j) => h('div', { key: 'j' + j.id, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [
|
||||
h('div', {}, [
|
||||
h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type}`),
|
||||
subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`),
|
||||
]),
|
||||
btn('Entfernen', () => cancelJob(j), 'danger'),
|
||||
])),
|
||||
]));
|
||||
}
|
||||
if (tab.value === 'druck') {
|
||||
const list = ordersByStatus('im_druck');
|
||||
return list.length ? list.map((o) => orderCard(o, { badges: true })) : subdued('Nichts im Druck.');
|
||||
}
|
||||
if (tab.value === 'versendet') {
|
||||
const list = versendetAktiv();
|
||||
return list.length ? list.map((o) => orderCard(o)) : subdued('Nichts (kürzlich) versendet.');
|
||||
}
|
||||
if (tab.value === 'archiv') {
|
||||
const list = archivListe();
|
||||
return list.length ? list.map((o) => orderCard(o)) : subdued('Archiv leer.');
|
||||
}
|
||||
const map = { kuvertierung: 'kuvertieren', versandfertig: 'versandfertig' };
|
||||
const st = map[tab.value];
|
||||
const list = ordersByStatus(st);
|
||||
return list.length ? list.map((o) => orderCard(o, { badges: st === 'kuvertieren' })) : subdued('Leer.');
|
||||
}
|
||||
|
||||
// ── Modale rendern ──────────────────────────────────────────────────────
|
||||
function overlay(inhalt, close, breite) {
|
||||
return h('div', {
|
||||
onClick: (e) => { if (e.target === e.currentTarget) close(); },
|
||||
style: 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:flex-start;justify-content:center;padding:5vh 16px;overflow:auto;',
|
||||
}, [h('div', { style: `background:var(--background-page,#fff);border:1px solid var(--border-subdued);border-radius:12px;width:${breite || '620px'};max-width:100%;box-shadow:0 20px 60px rgba(0,0,0,.35);` }, inhalt)]);
|
||||
}
|
||||
function modalKopf(titel, close) {
|
||||
return h('div', { style: 'display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border-subdued);' }, [
|
||||
h('div', { style: 'font-weight:700;font-size:16px;' }, titel),
|
||||
btn('✕', close),
|
||||
]);
|
||||
}
|
||||
function feldGrid(sec, secKey) {
|
||||
return h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px 0;' }, PARAM_FELDER.map(([k, lbl]) =>
|
||||
h('div', { style: S_FELD + 'display:flex;flex-direction:column;' }, [
|
||||
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);margin-bottom:2px;' }, lbl),
|
||||
h('input', { type: 'number', step: '0.01', value: sec[k],
|
||||
style: 'border:none;background:transparent;color:var(--foreground-normal);font:inherit;font-size:15px;font-weight:600;width:100%;padding:0;outline:none;',
|
||||
onInput: (e) => { druck[secKey][k] = parseFloat(e.target.value) || 0; } }),
|
||||
])));
|
||||
}
|
||||
function druckSektion(secKey, titel, ikon, typ) {
|
||||
const sec = druck[secKey]; if (!sec) return null;
|
||||
const opts = templates.value.filter((t) => t.type === typ);
|
||||
return h('div', { style: SECTION }, [
|
||||
h('div', { style: 'display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;' }, [
|
||||
h('label', { style: 'display:flex;align-items:center;gap:8px;font-weight:700;font-size:15px;' }, [
|
||||
h('input', { type: 'checkbox', checked: sec.enabled, onChange: (e) => { druck[secKey].enabled = e.target.checked; }, style: 'width:16px;height:16px;' }),
|
||||
`${ikon} ${titel}`,
|
||||
]),
|
||||
sec.done ? chip('✓ bereits gedruckt', 'var(--success-25,rgba(46,125,50,.15))') : null,
|
||||
]),
|
||||
h('select', { style: S_SELECT + 'width:100%;margin-bottom:6px;', value: sec.tplId, onChange: (e) => druckTplWechsel(secKey, e.target.value) },
|
||||
opts.map((t) => h('option', { value: t.id, selected: String(sec.tplId) === String(t.id) }, t.name))),
|
||||
feldGrid(sec, secKey),
|
||||
h('div', { style: S_FELD + 'display:flex;flex-direction:column;gap:4px;' }, [
|
||||
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);' }, 'Zu druckende Nummern'),
|
||||
h('input', { value: sec.numbers, placeholder: 'z. B. 1,4,8 oder 1-9 oder 1-3,5,8',
|
||||
style: 'border:none;background:transparent;color:var(--foreground-normal);font:inherit;font-size:14px;width:100%;padding:0;outline:none;',
|
||||
onInput: (e) => { druck[secKey].numbers = e.target.value; } }),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
function druckModal() {
|
||||
if (!druck.open) return null;
|
||||
const busy = druck.machine != null && maschineBusy(druck.machine);
|
||||
const body = h('div', { style: 'padding:18px 20px;' }, [
|
||||
h('div', { style: 'display:flex;flex-direction:column;gap:4px;margin-bottom:14px;' }, [
|
||||
h('label', { style: 'font-size:12px;color:var(--foreground-subdued);' }, 'Maschine'),
|
||||
aktiveMaschinen.value.length
|
||||
? h('select', { style: S_SELECT + 'width:100%;', value: druck.machine, onChange: (e) => { druck.machine = e.target.value; if (maschineBusy(e.target.value)) druck.mode = 'queue'; } },
|
||||
aktiveMaschinen.value.map((m) => h('option', { value: m.id, selected: String(druck.machine) === String(m.id) }, `${m.name}${maschineBusy(m.id) ? ' (druckt)' : ''}`)))
|
||||
: subdued('Keine aktive Maschine – bitte unter Einstellungen → Maschinen anlegen.'),
|
||||
]),
|
||||
druckSektion('brief', 'Briefe', '📄', 'brief'),
|
||||
druckSektion('umschlag', 'Kuverts', '✉', 'umschlag'),
|
||||
h('div', { style: 'margin:12px 0 4px;' }, [
|
||||
segToggle(busy ? 'queue' : druck.mode, [
|
||||
{ key: 'direct', label: '⚡ Direktdruck', disabled: busy },
|
||||
{ key: 'queue', label: '⏳ Warteschlange' },
|
||||
], (k) => { druck.mode = k; }),
|
||||
busy ? subdued('Maschine druckt gerade → nur Warteschlange.', 'margin-top:6px;') : null,
|
||||
]),
|
||||
druck.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, druck.err) : null,
|
||||
druck.msg ? subdued(druck.msg, 'margin-top:8px;') : null,
|
||||
]);
|
||||
const foot = h('div', { style: 'display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border-subdued);' }, [
|
||||
btn('Abbrechen', () => { druck.open = false; }),
|
||||
btn('In Produktion geben', submitDruck, 'primary'),
|
||||
]);
|
||||
return overlay([modalKopf(`Drucken · #${druck.order.order_number}`, () => { druck.open = false; }), body, foot], () => { druck.open = false; }, '640px');
|
||||
}
|
||||
function musterModal() {
|
||||
if (!manuell.open) return null;
|
||||
const briefTpls = templates.value.filter((t) => t.type === 'brief');
|
||||
const body = h('div', { style: 'padding:18px 20px;display:flex;flex-direction:column;gap:12px;' }, [
|
||||
h('div', { style: 'display:flex;gap:16px;align-items:center;' }, [
|
||||
h('label', { style: 'display:flex;align-items:center;gap:6px;' }, [h('input', { type: 'radio', name: 'anrede', checked: manuell.form === 'sie', onChange: () => { manuell.form = 'sie'; musterAufloesen(); } }), 'Sie-Form']),
|
||||
h('label', { style: 'display:flex;align-items:center;gap:6px;' }, [h('input', { type: 'radio', name: 'anrede', checked: manuell.form === 'du', onChange: () => { manuell.form = 'du'; musterAufloesen(); } }), 'Du-Form']),
|
||||
subdued('Adresse aus der Anfrage übernommen · [[abschnitt]] bitte füllen'),
|
||||
]),
|
||||
h('textarea', { style: S_INPUT + 'width:100%;min-height:200px;font-family:var(--family-monospace,monospace);', value: manuell.text, onInput: (e) => { manuell.text = e.target.value; } }),
|
||||
h('div', { style: 'display:grid;grid-template-columns:repeat(4,1fr);gap:8px;' }, [
|
||||
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Schrift'),
|
||||
h('select', { style: S_SELECT + 'width:100%;', value: manuell.font, onChange: (e) => { manuell.font = e.target.value; } }, ['tilda', 'alva', 'ellie'].map((f) => h('option', { value: f, selected: manuell.font === f }, f)))]),
|
||||
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Format'),
|
||||
h('select', { style: S_SELECT + 'width:100%;', value: manuell.format, onChange: (e) => { manuell.format = e.target.value; } },
|
||||
[['a4', 'A4'], ['a6_hoch', 'A6 hoch'], ['a6_quer', 'A6 quer']].map(([v, l]) => h('option', { value: v, selected: manuell.format === v }, l)))]),
|
||||
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'),
|
||||
h('select', { style: S_SELECT + 'width:100%;', value: manuell.template, onChange: (e) => { manuell.template = e.target.value; } }, briefTpls.map((t) => h('option', { value: t.id, selected: String(manuell.template) === String(t.id) }, t.name)))]),
|
||||
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Maschine'),
|
||||
h('select', { style: S_SELECT + 'width:100%;', value: manuell.machine, onChange: (e) => { manuell.machine = e.target.value; } }, aktiveMaschinen.value.map((m) => h('option', { value: m.id, selected: String(manuell.machine) === String(m.id) }, m.name)))]),
|
||||
]),
|
||||
segToggle(manuell.mode, [
|
||||
{ key: 'direct', label: '⚡ Direktdruck' },
|
||||
{ key: 'queue', label: '⏳ Warteschlange' },
|
||||
], (k) => { manuell.mode = k; }),
|
||||
manuell.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;' }, manuell.err) : null,
|
||||
manuell.msg ? subdued(manuell.msg) : null,
|
||||
]);
|
||||
const foot = h('div', { style: 'display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border-subdued);' }, [
|
||||
btn('Abbrechen', () => { manuell.open = false; }),
|
||||
btn('Erzeugen & drucken', submitMuster, 'primary'),
|
||||
]);
|
||||
return overlay([modalKopf(`Muster drucken · ${manuell.req?.name || ''}`, () => { manuell.open = false; }), body, foot], () => { manuell.open = false; }, '680px');
|
||||
}
|
||||
function empfModal() {
|
||||
if (!empf.open) return null;
|
||||
const istOrder = empf.mode === 'order';
|
||||
// Linke Spalte (~70 %): Suche + klickbare Empfängerliste.
|
||||
const liste = h('div', { style: `flex:1 1 66%;min-width:260px;` }, [
|
||||
h('input', { type: 'search', value: empf.suche, placeholder: 'Durchsuchen (Name, Straße, Ort, Platzhalter …)', style: S_INPUT + 'width:100%;margin-bottom:12px;', onInput: (e) => { empf.suche = e.target.value; } }),
|
||||
empf.laden ? subdued('Wird geladen …') : empf.info ? subdued(empf.info)
|
||||
: h('div', { style: 'display:flex;flex-direction:column;gap:6px;max-height:62vh;overflow:auto;' }, empfTreffer.value.length
|
||||
? empfTreffer.value.map((r) => h('div', {
|
||||
onClick: istOrder ? () => previewEntry(r.nr, empf.rows.findIndex((x) => x.nr === r.nr)) : undefined,
|
||||
style: 'display:flex;gap:12px;align-items:baseline;border:1px solid var(--border-subdued);border-radius:8px;padding:12px 14px;'
|
||||
+ `${istOrder ? 'cursor:pointer;' : ''}`
|
||||
+ (istOrder && empf.selected === r.nr ? `border-color:${ACCENT};background:rgba(30,80,165,.07);` : 'background:var(--background-page);'),
|
||||
}, [
|
||||
h('span', { style: `min-width:40px;font-weight:700;color:${ACCENT};font-family:var(--family-monospace,monospace);` }, `#${r.nr}`),
|
||||
h('div', { style: 'flex:1;' }, [h('div', {}, r.zeile || '—'), r.extra ? subdued(r.extra, 'margin-top:2px;') : null]),
|
||||
]))
|
||||
: subdued('Keine Treffer.')),
|
||||
]);
|
||||
// Rechte Spalte (~30 %): Vorschau; Klick aufs Bild → große Lightbox.
|
||||
const vorschauBox = (titel, url) => h('div', { style: 'display:flex;flex-direction:column;gap:6px;' }, [
|
||||
h('div', { style: 'font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);' }, titel),
|
||||
h('div', {
|
||||
onClick: url ? () => { empf.lb = url; empf.lbTitel = titel; } : undefined,
|
||||
style: 'border:1px solid var(--border-subdued);border-radius:8px;background:#fff;display:flex;align-items:center;justify-content:center;min-height:150px;padding:8px;'
|
||||
+ (url ? 'cursor:zoom-in;' : ''),
|
||||
}, url ? h('img', { src: url, style: 'max-width:100%;max-height:30vh;object-fit:contain;' }) : subdued('—')),
|
||||
]);
|
||||
const vorschau = istOrder ? h('div', { style: 'flex:0 0 30%;min-width:220px;display:flex;flex-direction:column;gap:14px;' },
|
||||
empf.selected == null ? [subdued('Empfänger anklicken → Vorschau (Klick aufs Bild = groß).')]
|
||||
: empf.pLoad ? [subdued('Vorschau wird geladen …')]
|
||||
: [
|
||||
empf.perr ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;' }, empf.perr) : null,
|
||||
vorschauBox('📄 Schriftstück', empf.pl),
|
||||
vorschauBox('✉ Kuvert', empf.pe),
|
||||
]) : null;
|
||||
const body = h('div', { style: 'padding:16px 20px;display:flex;gap:20px;flex-wrap:wrap;' }, [liste, vorschau]);
|
||||
return overlay([modalKopf(empf.titel, () => { empf.open = false; }), body], () => { empf.open = false; }, istOrder ? '980px' : '620px');
|
||||
}
|
||||
// Große Einzelvorschau (Lightbox) über dem Empfänger-Dialog.
|
||||
function lightboxModal() {
|
||||
if (!empf.lb) return null;
|
||||
return h('div', {
|
||||
onClick: () => { empf.lb = ''; },
|
||||
style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:4vh 4vw;gap:14px;cursor:zoom-out;',
|
||||
}, [
|
||||
h('div', { style: 'color:#fff;font-size:13px;text-transform:uppercase;letter-spacing:.05em;' }, empf.lbTitel),
|
||||
h('img', { src: empf.lb, style: 'max-width:100%;max-height:82vh;object-fit:contain;background:#fff;border-radius:8px;' }),
|
||||
h('div', { style: 'color:rgba(255,255,255,.7);font-size:12px;' }, 'Klicken zum Schließen'),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Gesamt-Render ─────────────────────────────────────────────────────
|
||||
// In <private-view> gehängt → normaler Directus-Content-Bereich + Kopf.
|
||||
// Die sechs Umschalter liegen in der nativen Navigationsspalte links.
|
||||
return () => {
|
||||
const navi = h('div', { style: 'padding:12px 10px;' }, TABS.map((t) =>
|
||||
h('button', {
|
||||
type: 'button', onClick: () => { tab.value = t.key; },
|
||||
style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%;text-align:left;'
|
||||
+ 'cursor:pointer;border:none;border-radius:6px;padding:10px 12px;margin-bottom:3px;font:inherit;font-size:14px;'
|
||||
+ (tab.value === t.key ? `background:${ACCENT};color:#fff;font-weight:600;` : 'background:transparent;color:var(--foreground-normal);'),
|
||||
}, [
|
||||
h('span', {}, t.label),
|
||||
h('span', { style: 'font-size:12px;opacity:.75;' }, String(tabCount(t.key))),
|
||||
])));
|
||||
|
||||
const kopfzeile = h('div', { style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px;' }, [
|
||||
h('input', { type: 'search', value: suche.value, placeholder: '🔎 Auftrag, Kunde, E-Mail, Produkt …',
|
||||
style: S_INPUT + 'flex:1;min-width:240px;', onInput: (e) => { suche.value = e.target.value; } }),
|
||||
btn('↻ Aktualisieren', laden),
|
||||
]);
|
||||
|
||||
// Leicht getönte Inhaltsfläche → weiße Karten heben sich klar ab (wie im Referenz-Dashboard).
|
||||
const inhalt = h('div', { style: 'padding:24px 28px;min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [
|
||||
kopfzeile,
|
||||
meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, meldung.value) : null,
|
||||
fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, fehler.value) : null,
|
||||
loading.value ? subdued('Lädt …') : h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, tabInhalt()),
|
||||
]);
|
||||
|
||||
const modale = [druckModal(), musterModal(), empfModal(), lightboxModal()].filter(Boolean);
|
||||
|
||||
return h(resolveComponent('private-view'), { title: 'Produktion' }, {
|
||||
navigation: () => navi,
|
||||
default: () => [inhalt, modale.length ? h(Teleport, { to: 'body' }, modale) : null],
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
id: 'skrift-produktion',
|
||||
name: 'Produktion',
|
||||
icon: 'precision_manufacturing',
|
||||
routes: [{ path: '', component: Board }],
|
||||
};
|
||||
@@ -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
Docker/directus/extensions/directus-extension-skrift-testprint/dist/index.js
vendored
Normal file
59
Docker/directus/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,
|
||||
};
|
||||
@@ -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