From f8f219a3fa15299660be80622bb9a581b8a4e4c0 Mon Sep 17 00:00:00 2001 From: s4luorth Date: Thu, 1 Oct 2026 08:34:27 +0200 Subject: [PATCH] Bestandspflege: Material je Standort, Auto-Abbuchung beim Druck Datenmodell (bootstrap): - Collections standorte (seed Skrift/Signascript), inventory (Papier/Kuvert/ Briefmarke je Standort, initiale Zeilen) und inventory_log. - directus_users.produktionsstandort (m2o standorte; USER_FIELDS-Relation wird jetzt angelegt); orders.frankiert + porto_klasse; jobs.inventory_done. Neue Hook-Extension skrift-inventory: - Bucht beim Job-Status 'printed' automatisch ab (am Kunden-Standort, +10 %): Brief->Papier (phys. Format), Kuvert->Kuvert + Briefmarke (nur wenn frankiert; Inland Standard / Ausland International, bzw. porto_klasse). Idempotent. produktion: neuer Tab 'Bestand' - je Standort, Unterschreitung rot ('nachbestellen'), manuelles Zubuchen + Mindestbestand/Bestand editierbar. Co-Authored-By: Claude Opus 4.8 --- bootstrap/bootstrap.mjs | 68 +++++++++- .../dist/index.js | 121 ++++++++++++++++++ .../package.json | 13 ++ .../dist/index.js | 73 ++++++++++- 4 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 extensions/directus-extension-skrift-inventory/dist/index.js create mode 100644 extensions/directus-extension-skrift-inventory/package.json diff --git a/bootstrap/bootstrap.mjs b/bootstrap/bootstrap.mjs index f3b7df7..a05c519 100644 --- a/bootstrap/bootstrap.mjs +++ b/bootstrap/bootstrap.mjs @@ -137,6 +137,8 @@ const USER_FIELDS = [ // Veraltet (frühere Mehrfach-Adressliste) – bleibt für Alt-Daten erhalten. f('addresses', 'json', { interface: 'list', hidden: true, note: 'Veraltet – ersetzt durch billing_address/shipping_address.', options: { fields: ADDRESS_SUBFIELDS } }), f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }), + // Produktionsstandort dieses Kunden (Bestandsabbuchung). Leer = Skrift (Standard). + m2o('produktionsstandort', 'standorte', { note: 'Wo die Aufträge dieses Kunden produziert werden. Leer = Skrift.' }), f('sevdesk_contact_id', 'string', { interface: 'input', hidden: true, note: 'Verknüpfter sevDesk-Kontakt (für Rechnungen).' }), // Zusätzliche Login-Mailadressen: Wer sich mit einer dieser Mails per Code // anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails. @@ -311,6 +313,10 @@ const collections = [ // Rechnungs-/Lieferadresse als Snapshot (für Bestätigung, Rechnung, Versand). f('billing', 'json', { interface: 'input-code', note: 'Rechnungsadresse (Snapshot bei Bestellung).' }), f('shipping_address', 'json', { interface: 'input-code', note: 'Abweichende Lieferadresse (falls vorhanden).' }), + // Bestandsabbuchung Briefmarke: von uns frankiert? Leer = automatisch + // (Einzelversand = ja, Sammelversand = nein). Bei Sammelversand umstellbar. + f('frankiert', 'boolean', { interface: 'boolean', note: 'Von uns frankiert → Briefmarke wird abgebucht. Leer = automatisch (Einzel ja / Sammel nein).' }), + dropdown('porto_klasse', ['auto', 'standard', 'grossbrief', 'maxibrief'], { default: 'auto', note: 'Markentyp-Override. auto = Inland 0,95 € / Ausland 1,25 €.' }), f('entries_count', 'integer', { interface: 'input', default: 0 }), f('count_inland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Deutschland.' }), f('count_ausland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Ausland.' }), @@ -467,6 +473,8 @@ const collections = [ f('numbers', 'string', { interface: 'input', note: 'Zu druckende Briefnummern (1-basiert). Leer = alle.' }), f('spaltenweise', 'boolean', { interface: 'boolean', default: false, note: 'Rollenschonende Zeichenreihenfolge (spaltenweise, für A6 quer). Der Agent lädt das SVG dann umsortiert.' }), f('error', 'text', { interface: 'input-multiline', note: 'Fehlermeldung vom Agent (falls fehlgeschlagen).' }), + // Guard: Bestand wurde für diesen Job bereits abgebucht (Idempotenz des Inventar-Hooks). + f('inventory_done', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Material bereits abgebucht (Bestands-Hook).' }), { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, { field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} }, ] }, @@ -722,6 +730,32 @@ const collections = [ f('bundled_at', 'timestamp', { interface: 'datetime', readonly: true }), { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, ] }, + + // ── Bestandspflege (zwei Produktionsstandorte) ───────────────────────────── + { collection: 'standorte', meta: { icon: 'factory', note: 'Produktionsstandorte (für Bestand + Kundenzuordnung).' }, fields: [ + f('name', 'string', { interface: 'input', required: true, unique: true }), + f('aktiv', 'boolean', { interface: 'boolean', default: true }), + f('sort', 'integer', { interface: 'input' }), + ] }, + { collection: 'inventory', meta: { icon: 'inventory', note: 'Materialbestand je Standort (Papier/Kuvert/Briefmarke, je Format/Typ).' }, fields: [ + m2o('standort', 'standorte', { onDelete: 'CASCADE', note: 'Produktionsstandort.' }), + dropdown('kategorie', ['papier', 'kuvert', 'briefmarke'], { note: 'Materialart.' }), + f('format', 'string', { interface: 'input', note: 'Schlüssel zum Abgleich: Papier/Kuvert = Format-Key (a4,a5p,a6l,dinlang,c6,c4 …); Briefmarke = standard/international/grossbrief …' }), + f('bezeichnung', 'string', { interface: 'input', note: 'Lesbare Bezeichnung (z. B. „A4 Papier", „Standard 0,95 €").' }), + f('bestand', 'integer', { interface: 'input', default: 0, note: 'Aktueller Bestand.' }), + f('mindestbestand', 'integer', { interface: 'input', default: 0, note: 'Schwelle → bei Unterschreitung „nachbestellen".' }), + f('einheit', 'string', { interface: 'input', default: 'Stück' }), + f('notiz', 'string', { interface: 'input' }), + { field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} }, + ] }, + { collection: 'inventory_log', meta: { icon: 'receipt_long', hidden: true, note: 'Bestandsbuchungen (Abgänge beim Druck, Auffüllungen, Korrekturen).' }, fields: [ + m2o('inventory', 'inventory', { onDelete: 'CASCADE', note: 'Betroffene Bestandszeile.' }), + f('delta', 'integer', { interface: 'input', note: 'Veränderung (negativ = Abgang, positiv = Zugang).' }), + dropdown('grund', ['druck', 'auffuellung', 'korrektur'], { note: 'Buchungsgrund.' }), + m2o('order', 'orders', { onDelete: 'SET NULL', note: 'Auslösender Auftrag (bei Abgang).' }), + f('notiz', 'string', { interface: 'input' }), + { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, + ] }, ]; // ── Schema anlegen ────────────────────────────────────────────────────────── @@ -752,13 +786,16 @@ async function applySchema() { // Profilfelder direkt am Login-Konto (Kunde = directus_users). console.log('› Kundenfelder an directus_users'); for (const fld of USER_FIELDS) { + const { __related, __onDelete, ...clean } = fld; if (!existingFields.has(`directus_users.${fld.field}`)) { - await api('POST', '/fields/directus_users', fld); + await api('POST', '/fields/directus_users', clean); log('field +', `directus_users.${fld.field}`); } else { // Vorhandenes Feld: Oberfläche/Optionen aktualisieren (z. B. Adressliste). - try { await api('PATCH', `/fields/directus_users/${fld.field}`, { meta: fld.meta }); } catch { /* egal */ } + try { await api('PATCH', `/fields/directus_users/${fld.field}`, { meta: clean.meta }); } catch { /* egal */ } } + // m2o-Beziehung (z. B. produktionsstandort → standorte) nachziehen. + if (__related) relationsToCreate.push({ collection: 'directus_users', field: fld.field, related: __related, onDelete: __onDelete }); } // Ungenutzte Standardfelder ausblenden. Direkt patchen (System-Felder existieren // immer); ein Fehler wird geloggt statt still verschluckt – so sieht man, falls @@ -1063,6 +1100,30 @@ async function applySeed() { else { await api('POST', '/items/render_formats', rf); log('seed + render_formats.' + rf.key); } } + // ── Standorte + initialer Materialbestand ────────────────────────────────── + const standortId = {}; + for (const s of [{ name: 'Skrift', sort: 1, aktiv: true }, { name: 'Signascript', sort: 2, aktiv: true }]) { + const da = await api('GET', `/items/standorte?filter[name][_eq]=${encodeURIComponent(s.name)}&limit=1&fields=id`); + if (da && da.length) standortId[s.name] = da[0].id; + else { const neu = await api('POST', '/items/standorte', s); standortId[s.name] = neu?.id; log('seed + standorte.' + s.name); } + } + // Materialzeilen je Standort (Bestand/Mindestbestand füllt der Betreiber im Board). + const papierNamen = { a4: 'A4', a5: 'A5', a6: 'A6', dinlang: 'DIN lang' }; + const material = [ + ...Object.entries(papierNamen).map(([k, n]) => ({ kategorie: 'papier', format: k, bezeichnung: `${n} Papier`, einheit: 'Blatt' })), + ...[['dinlang', 'DIN lang'], ['c6', 'C6'], ['c4', 'C4']].map(([k, n]) => ({ kategorie: 'kuvert', format: k, bezeichnung: `Kuvert ${n}`, einheit: 'Stück' })), + { kategorie: 'briefmarke', format: 'standard', bezeichnung: 'Standard 0,95 €', einheit: 'Stück' }, + { kategorie: 'briefmarke', format: 'international', bezeichnung: 'International 1,25 €', einheit: 'Stück' }, + ]; + for (const sid of Object.values(standortId)) { + if (!sid) continue; + for (const m of material) { + const da = await api('GET', `/items/inventory?filter[standort][_eq]=${sid}&filter[kategorie][_eq]=${m.kategorie}&filter[format][_eq]=${encodeURIComponent(m.format)}&limit=1&fields=id`); + if (da && da.length) continue; + await api('POST', '/items/inventory', { standort: sid, bestand: 0, mindestbestand: 0, ...m }); + } + } + // email_settings (Singleton) – Signatur nur setzen, wenn leer. const es = await api('GET', '/items/email_settings'); if (!es || !es.signature) { @@ -1102,7 +1163,8 @@ async function applySeed() { const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history', 'offer_requests', 'signatures', 'customer_discounts', 'customer_products', 'contacts', - 'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints']; + 'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints', + 'standorte', 'inventory', 'inventory_log']; // Kunde = Login-Konto: Aufträge hängen direkt am Nutzer. const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } }; const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } }; diff --git a/extensions/directus-extension-skrift-inventory/dist/index.js b/extensions/directus-extension-skrift-inventory/dist/index.js new file mode 100644 index 0000000..c53d5d8 --- /dev/null +++ b/extensions/directus-extension-skrift-inventory/dist/index.js @@ -0,0 +1,121 @@ +/** + * Skrift – Bestands-Hook (Auto-Abbuchung) + * --------------------------------------------------------------------------- + * Bucht Material automatisch ab, sobald ein Druck-Job als „printed" gemeldet + * wird (Abgang BEIM DRUCK), am Produktionsstandort des Kunden (leer = Skrift): + * + * Brief gedruckt → Papier (physisches Format) × Menge × 1,10 + * Kuvert gedruckt → Kuvert (envelope_format) × Menge × 1,10 + * + Briefmarke, NUR wenn von uns frankiert: + * Inland → Standard, Ausland → International + * (bzw. porto_klasse-Override) × Menge × 1,10 + * + * „Von uns frankiert" = order.frankiert, sonst automatisch: Einzelversand ja, + * Sammelversand nein. +10 % Puffer (Zusatzversorgung), aufgerundet. + * + * Idempotent über jobs.inventory_done. Fehlende Bestandszeilen werden + * übersprungen (geloggt), damit nie ein Druck blockiert. + */ + +// Auftrags-Format-Key (formats.key) → physischer Papier-Key (inventory.format). +const PAPIER = { + a4: 'a4', + a5p: 'a5', a5l: 'a5', a5_hoch: 'a5', a5_quer: 'a5', + a6p: 'a6', a6l: 'a6', a6_hoch: 'a6', a6_quer: 'a6', + dinlang_karte: 'dinlang', +}; +// +10 % Zusatzversorgung, aufgerundet. +const puffer = (n) => Math.ceil((Number(n) || 0) * 1.1); + +// Anzahl aus einem numbers-String (z. B. „1-50", „1,3,5-7"). Leer = 0. +function anzahlAusNumbers(s) { + const txt = String(s || '').trim(); + if (!txt) return 0; + let n = 0; + for (const teil of txt.split(',')) { + const t = teil.trim(); + const m = t.match(/^(\d+)\s*-\s*(\d+)$/); + if (m) n += Math.max(0, Number(m[2]) - Number(m[1]) + 1); + else if (/^\d+$/.test(t)) n += 1; + } + return n; +} + +export default (register, { services, getSchema, logger }) => { + const { ItemsService } = services; + let skriftId = null; // Cache: Standort-ID „Skrift" (Default). + + async function defaultStandort(svc) { + if (skriftId) return skriftId; + const rows = await svc('standorte').readByQuery({ filter: { name: { _eq: 'Skrift' } }, limit: 1, fields: ['id'] }); + skriftId = (rows && rows[0]) ? rows[0].id : null; + return skriftId; + } + + async function abbuchen(svc, standortId, kategorie, formatKey, menge, orderId) { + if (!standortId || menge <= 0 || !formatKey) return; + const rows = await svc('inventory').readByQuery({ + filter: { _and: [{ standort: { _eq: standortId } }, { kategorie: { _eq: kategorie } }, { format: { _eq: String(formatKey) } }] }, + limit: 1, fields: ['id', 'bestand', 'mindestbestand', 'bezeichnung'], + }); + const row = rows && rows[0]; + if (!row) { logger.info(`[skrift-inventory] keine Bestandszeile ${kategorie}/${formatKey} @Standort ${standortId} – übersprungen`); return; } + const neu = (Number(row.bestand) || 0) - menge; + await svc('inventory').updateOne(row.id, { bestand: neu }); + await svc('inventory_log').createOne({ inventory: row.id, delta: -menge, grund: 'druck', order: orderId || null }); + if (neu < (Number(row.mindestbestand) || 0)) { + logger.warn(`[skrift-inventory] Mindestbestand unterschritten: ${row.bezeichnung || formatKey} (${neu})`); + } + } + + async function verbucheJob(svc, jobId) { + const job = await svc('jobs').readOne(jobId, { + fields: ['id', 'type', 'status', 'numbers', 'inventory_done', + 'order.id', 'order.format.key', 'order.envelope_format', 'order.entries_count', + 'order.count_inland', 'order.count_ausland', 'order.shipping_type', + 'order.frankiert', 'order.porto_klasse', 'order.customer.produktionsstandort'], + }); + if (!job || job.status !== 'printed' || job.inventory_done || !job.order) return; + const o = job.order; + const standortId = o.customer?.produktionsstandort || await defaultStandort(svc); + if (!standortId) { logger.info('[skrift-inventory] kein Standort (auch kein „Skrift") – übersprungen'); return; } + + const menge = anzahlAusNumbers(job.numbers) || Number(o.entries_count) || 0; + if (menge <= 0) { await svc('jobs').updateOne(jobId, { inventory_done: true }); return; } + + if (job.type === 'umschlag') { + await abbuchen(svc, standortId, 'kuvert', o.envelope_format || 'dinlang', puffer(menge), o.id); + // Briefmarke nur, wenn von uns frankiert (Default: Einzel ja / Sammel nein). + const frankiert = (typeof o.frankiert === 'boolean') ? o.frankiert : (o.shipping_type !== 'sammel'); + if (frankiert) { + const inland = Number(o.count_inland) || 0; + const ausland = Number(o.count_ausland) || 0; + const klasse = (o.porto_klasse && o.porto_klasse !== 'auto') ? o.porto_klasse : null; + if (klasse) { + await abbuchen(svc, standortId, 'briefmarke', klasse, puffer((inland || ausland) ? inland + ausland : menge), o.id); + } else { + const nInland = (inland || ausland) ? inland : menge; // keine Länderzählung → alles Inland + if (nInland > 0) await abbuchen(svc, standortId, 'briefmarke', 'standard', puffer(nInland), o.id); + if (ausland > 0) await abbuchen(svc, standortId, 'briefmarke', 'international', puffer(ausland), o.id); + } + } + } else { + // Brief → Papier (physisches Format). 1 Blatt je Brief (mehrseitig: späteres TODO). + const papierKey = PAPIER[String(o.format?.key || 'a4').toLowerCase()] || 'a4'; + await abbuchen(svc, standortId, 'papier', papierKey, puffer(menge), o.id); + } + await svc('jobs').updateOne(jobId, { inventory_done: true }); + } + + register.action('jobs.items.update', async ({ keys, payload }) => { + if (!payload || payload.status !== 'printed') return; + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + for (const key of (keys || [])) { + try { await verbucheJob(svc, key); } + catch (e) { logger.warn(`[skrift-inventory] Job ${key}: ${e.message}`); } + } + } catch (e) { logger.warn(`[skrift-inventory] update-Hook: ${e.message}`); } + }); +}; diff --git a/extensions/directus-extension-skrift-inventory/package.json b/extensions/directus-extension-skrift-inventory/package.json new file mode 100644 index 0000000..09d24c0 --- /dev/null +++ b/extensions/directus-extension-skrift-inventory/package.json @@ -0,0 +1,13 @@ +{ + "name": "directus-extension-skrift-inventory", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Bucht Materialbestand (Papier/Kuvert/Briefmarke) automatisch ab, sobald ein Druck-Job als gedruckt gemeldet wird – am Produktionsstandort des Kunden, mit +10 % Puffer.", + "directus:extension": { + "type": "hook", + "path": "dist/index.js", + "source": "dist/index.js", + "host": "^11.0.0" + } +} diff --git a/extensions/directus-extension-skrift-produktion/dist/index.js b/extensions/directus-extension-skrift-produktion/dist/index.js index a1318b6..af31dc4 100644 --- a/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/extensions/directus-extension-skrift-produktion/dist/index.js @@ -61,6 +61,7 @@ const TABS = [ { key: 'versandfertig', label: '📦 Versandfertig' }, { key: 'versendet', label: '🚚 Versendet' }, { key: 'batch', label: '📦 Übermittlungen' }, + { key: 'bestand', label: '🧾 Bestand' }, { key: 'archiv', label: '🗄 Archiv' }, ]; const STATUS_LABEL = { @@ -187,6 +188,11 @@ const Board = defineComponent({ let sucheTimer = null; const sucheSetzen = (v) => { clearTimeout(sucheTimer); sucheTimer = setTimeout(() => { sucheText.value = v; }, 250); }; const loading = ref(false); + // Bestand (Material je Standort) + Standorte für den Bestand-Tab. + const bestand = ref([]); + const standorte = ref([]); + const bestandMsg = ref(''); + const bestandEingabe = reactive({}); // je Bestandszeile: Zugangsmenge const fehler = ref(''); const meldung = ref(''); @@ -399,7 +405,7 @@ const Board = defineComponent({ // leise = Hintergrund-Aktualisierung: kein Spinner, kein Fehlerbanner. if (!leise) { loading.value = true; fehler.value = ''; } try { - const [o, r, j, m, t, ms, mtx, bs] = await Promise.all([ + const [o, r, j, m, t, ms, mtx, bs, inv, st] = await Promise.all([ ladeOrders(), api.get('/items/offer_requests', { params: { limit: -1, sort: '-date_created', @@ -416,6 +422,8 @@ const Board = defineComponent({ filter: { status: { _eq: 'pending' } }, fields: 'id,api_client.id,api_client.name,firma,vorname,nachname,zeile1,zeile2,zeile3,plz,stadt,land,text,pdf_file,status,versendet,date_created', } }).catch(() => ({ data: { data: [] } })), + api.get('/items/inventory', { params: { limit: -1, fields: 'id,standort,kategorie,format,bezeichnung,bestand,mindestbestand,einheit', sort: 'kategorie,bezeichnung' } }).catch(() => ({ data: { data: [] } })), + api.get('/items/standorte', { params: { limit: -1, fields: 'id,name,sort', sort: 'sort' } }).catch(() => ({ data: { data: [] } })), ]); orders.value = o.data?.data || []; requests.value = r.data?.data || []; @@ -425,6 +433,8 @@ const Board = defineComponent({ muster.value = ms.data?.data || { text_du: '', text_sie: '' }; musterListe.value = mtx.data?.data || []; batch.value = bs.data?.data || []; + bestand.value = inv.data?.data || []; + standorte.value = st.data?.data || []; // Empfänger-Index nur beim vollen Laden neu bauen (nicht bei der 15-s- // Hintergrund-Aktualisierung) – im Hintergrund, blockiert das Board nicht. if (!leise) { ladeEmpfIndex(); ladeSendIndex(); ladeChargeNotizen(); } @@ -1628,8 +1638,69 @@ const Board = defineComponent({ return [leiste, ...bloecke]; } + // ── Bestand (Material je Standort) ──────────────────────────────────────── + async function bestandPatch(row, felder) { + try { await api.patch('/items/inventory/' + row.id, felder); Object.assign(row, felder); } + catch (e) { bestandMsg.value = 'Speichern fehlgeschlagen: ' + (e?.response?.data?.errors?.[0]?.message || e?.message || ''); } + } + async function bestandBuchen(row, delta, grund) { + const neu = (Number(row.bestand) || 0) + delta; + await bestandPatch(row, { bestand: neu }); + try { await api.post('/items/inventory_log', { inventory: row.id, delta, grund: grund || (delta >= 0 ? 'auffuellung' : 'korrektur') }); } catch { /* Log optional */ } + bestandMsg.value = `${row.bezeichnung}: ${delta >= 0 ? '+' : ''}${delta} → ${neu} ${row.einheit || ''}`; + setTimeout(() => { if (bestandMsg.value.startsWith(row.bezeichnung)) bestandMsg.value = ''; }, 3000); + } + const klLabel = (t) => h('span', { style: 'font-size:11px;color:var(--foreground-subdued);' }, t); + function bestandZeile(b) { + const unter = (Number(b.bestand) || 0) < (Number(b.mindestbestand) || 0); + if (!(b.id in bestandEingabe)) bestandEingabe[b.id] = ''; + return h('div', { key: 'inv' + b.id, style: `display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:6px 8px;border-radius:8px;margin-bottom:4px;${unter ? 'background:var(--danger-25,rgba(198,40,40,.08));' : ''}` }, [ + h('div', { style: 'flex:1 1 180px;min-width:150px;font-size:14px;' }, [ + h('span', { style: 'font-weight:600;' }, b.bezeichnung || b.format), + unter ? h('span', { style: 'margin-left:8px;color:var(--danger,#c62828);font-weight:700;font-size:12px;' }, '• nachbestellen') : null, + ]), + h('div', { style: 'display:flex;align-items:center;gap:4px;' }, [klLabel('Bestand'), + h('input', { type: 'number', value: b.bestand, style: 'width:84px;' + S_INPUT, onChange: (e) => bestandPatch(b, { bestand: Math.round(Number(e.target.value) || 0) }) }), + h('span', { style: 'font-size:12px;color:var(--foreground-subdued);' }, b.einheit || '')]), + h('div', { style: 'display:flex;align-items:center;gap:4px;' }, [klLabel('Mindest'), + h('input', { type: 'number', value: b.mindestbestand, style: 'width:70px;' + S_INPUT, onChange: (e) => bestandPatch(b, { mindestbestand: Math.round(Number(e.target.value) || 0) }) })]), + h('div', { style: 'display:flex;align-items:center;gap:4px;' }, [ + h('input', { type: 'number', placeholder: 'Menge', value: bestandEingabe[b.id], style: 'width:84px;' + S_INPUT, onInput: (e) => { bestandEingabe[b.id] = e.target.value; } }), + btn('+ Zubuchen', () => { const n = Math.round(Number(bestandEingabe[b.id]) || 0); if (n) { bestandBuchen(b, n, 'auffuellung'); bestandEingabe[b.id] = ''; } }, 'primary'), + ]), + ]); + } + function bestandInhalt() { + if (!standorte.value.length) return subdued('Noch keine Standorte/Bestand angelegt. Bitte den Directus-Bootstrap ausführen.'); + const KAT = [['papier', 'Papier'], ['kuvert', 'Kuvert'], ['briefmarke', 'Briefmarke']]; + const bloecke = standorte.value.map((s) => { + const rows = bestand.value.filter((b) => String(b.standort) === String(s.id)); + const unter = rows.filter((b) => (Number(b.bestand) || 0) < (Number(b.mindestbestand) || 0)).length; + const katBloecke = KAT.map(([k, kl]) => { + const items = rows.filter((b) => b.kategorie === k); + if (!items.length) return null; + return h('div', { style: 'margin-bottom:8px;' }, [ + h('div', { style: 'font-size:12px;font-weight:700;color:var(--foreground-subdued);text-transform:uppercase;letter-spacing:.03em;margin:6px 0 2px;' }, kl), + ...items.map(bestandZeile), + ]); + }); + return h('div', { key: 'st' + s.id, style: CARD + 'margin-bottom:16px;' }, [ + h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;flex-wrap:wrap;gap:8px;' }, [ + h('div', { style: 'font-weight:700;font-size:16px;' }, '🏭 ' + s.name), + unter ? chip(`⚠ ${unter}× nachbestellen`, 'var(--danger-25,rgba(198,40,40,.15))') : chip('✓ Bestand ok', 'var(--success-25,rgba(46,125,50,.15))'), + ]), + rows.length ? h('div', {}, katBloecke) : subdued('Keine Materialzeilen an diesem Standort.'), + ]); + }); + return [ + bestandMsg.value ? h('div', { style: 'margin-bottom:10px;color:var(--primary,#1e50a5);font-size:13px;font-weight:600;' }, bestandMsg.value) : null, + subdued('Zugänge/Auffüllungen hier eintragen. Abgänge werden beim Druck automatisch gebucht (+10 % Puffer).', 'margin-bottom:10px;'), + ...bloecke, + ]; + } function tabInhalt() { if (tab.value === 'batch') return batchInhalt(); + if (tab.value === 'bestand') return bestandInhalt(); if (tab.value === 'suche') return sucheInhalt(); if (tab.value === 'neu') { const items = [