PageSourceSearch

https://app.medi-one.ai/modules/aiTeam.js?v=20260810-aiteam1b-takt…t2-abscroll1-mehrlauf1-spin1-dlspin1-fehlg1

js medi-one.ai collected 2026-10-05 14:15:04 UTC 738,307 bytes, 13,831 lines download raw bytes

1// ai-team-ui-v2 + ai-team-org-ui-v1 (2026-08-10) — "Mein KI-Team" (vorerst NUR Admin sichtbar).
2//
3// V2 nach Michaels Feedback (Screenshot OneAI Command Center):
4//   - Das Team-Board ist ein DASHBOARD: Kennzahlen-Kacheln oben,
5//     Mitarbeiterkarten mit Zahlen (Offen/Heute/Erledigt), Button
6//     "Mitarbeiter einstellen" rechts oben.
7//   - EINSTELLEN IST EINE EIGENE SEITE, KEIN MODAL. Drei Schritte als
8//     Vollbild-Ansichten mit breiten Rollen-Karten (zwei Spalten
9//     "uebernimmt / kann er nicht").
10//   - Modale gibt es nur noch fuer kurze Bestaetigungen (Entlassen,
11//     Ablehnen-mit-Grund, Fehler).
12//
13// BEWUSSTE BAU-REGELN (aus dokumentierten eigenen Fehlern):
14//   - KEINE relativen Imports (Anamnese-Lehre: ein fehlender Unter-Import
15//     macht das GANZE Modul tot); t() und Token kommen von window.
16//   - Jeder Fremdwert laeuft durch esc(); Klick-Handler ausschliesslich
17//     ueber EIN delegiertes data-action-Muster mit IDs, nie Werte in
18//     onclick (Buchhaltungs-XSS-Lehre 07.08.).
19//   - Zustand wird in show() ZURUECKGESETZT (Lehre: Zustandsvariablen,
20//     die laenger leben als das DOM — dritte Auspraegung vermeiden).
21
22function t(s) {
23    try { return (window.t ? window.t(s) : s); } catch (e) { return s; }
24}
25
26class ApiFehler extends Error {
27    /* kiteam-oberflaeche-v1: 'code' kommt dazu. Der Server unterscheidet
28       'kein Abo gebucht' von 'kein Zugriff' - ohne den Code koennte die
29       Oberflaeche den Kauffall nicht vom Fehlerfall trennen und wuerde
30       einem kaufwilligen Kunden eine rote Sperrmeldung zeigen. */
31    constructor(text, status, code) {
32        super(text); this.status = status; this.code = code || '';
33    }
34}
35
36// Token-Kette wie verordnungen.js (GEMESSEN 10.08.: window.getAuthToken
37// existiert im Desktop-Kontext NICHT — window.Auth wird in
38// index_v2.html:702 gesetzt und ist der verlaessliche Weg).
39async function _token() {
40    try {
41        if (window.Auth && window.Auth.currentUser) {
42            return await window.Auth.currentUser.getIdToken();
43        }
44    } catch (e) { /* weiter zur naechsten Quelle */ }
45    try {
46        if (typeof firebase !== 'undefined' && firebase.auth && firebase.auth().currentUser) {
47            return await firebase.auth().currentUser.getIdToken();
48        }
49    } catch (e) { /* weiter */ }
50    try {
51        if (window.getAuthToken) return await window.getAuthToken();
52    } catch (e) { /* App uebernimmt die Anmeldung */ }
53    return null;
54}
55
56async function api(pfad, methode = 'GET', body) {
57    const o = { method: methode, headers: { 'Content-Type': 'application/json' } };
58    try {
59        const tok = await _token();
60        if (tok) o.headers['Authorization'] = 'Bearer ' + tok;
61    } catch (e) { /* Anmeldung uebernimmt die App */ }
62    if (body !== undefined) o.body = JSON.stringify(body);
63    /* pf-frisch-v2: Ohne cache:'no-store' darf ein Browser eine
64       Antwort ohne Cache-Control heuristisch zwischenspeichern. Beim
65       Postfach faellt das sofort auf: der Inhaber arbeitet parallel mit
66       seinem eigenen Mailprogramm im selben Konto, jede alte Liste bietet
67       Nachrichten an, die es nicht mehr gibt. */
68    o.cache = 'no-store';
69    const r = await fetch(pfad, o);
70    const ct = (r.headers.get('content-type') || '').toLowerCase();
71    if (!ct.includes('application/json')) {
72        throw new ApiFehler(t('Der Server hat unerwartet geantwortet. Bitte neu laden.'), r.status);
73    }
74    const d = await r.json();
75    if (!r.ok) throw new ApiFehler(d.error || (t('Fehler') + ' ' + r.status), r.status, d.code);
76    return d;
77}
78
79function esc(v) {
80    return String(v === undefined || v === null ? '' : v)
81        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
82        .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
83}
84
85const STATUS_ANZEIGE = {
86    probezeit: ['#0d6efd', 'Probezeit'],
87    aktiv: ['#198754', 'arbeitet'],
88    pausiert: ['#6c757d', 'pausiert'],
89};
90
91const ROLLEN_FARBEN = {
92    office_manager: '#0d6efd',
93    vertrieb: '#6610f2',
94    buchhaltung: '#198754',
95    marketing: '#fd7e14',
96    praxismanager: '#d63384',
97    // ai-team-board-v2: Die drei Rollen aus dem Ausstattungs-Bauabschnitt
98    // hatten keine eigene Farbe und fielen alle auf Blau zurueck — ein
99    // frisch eingestellter CEO sah damit aus wie der Office-Manager.
100    leitung: '#7048c4',
101    abteilungsleitung: '#5b6ee1',
102    eigene: '#6c757d',
103    // ai-team-rollen-v2: entwickler und recht hatten seit
104    // ai-team-rollen-v1 KEINE eigene Farbe und fielen beide auf
105    // Blau zurueck -- ein Entwickler sah damit aus wie der
106    // Office-Manager. Genau der Befund, der bei leitung/
107    // abteilungsleitung/eigene schon einmal behoben wurde.
108    // Die Werte entsprechen der Farbe ihrer Abteilung.
109    entwickler: '#0dcaf0',
110    recht: '#adb5bd',
111    qualitaet: '#0f8b8d',
112    // depot-ki-v1: Farbe der Abteilung Finanzen.
113    investment: '#b8860b',
114    datenschutz: '#862e9c',
115    personal: '#e03131',
116    // ai-team-projekt-v1: Farbe der Abteilung 'Projekte'.
117    projektmanager: '#5c940d',
118    einkauf: '#6d4c41',
119    // ai-team-lohn-v1: Der Lohnbuchhalter sitzt in der
120    // Abteilung 'Personal' (#e03131) und traegt deshalb
121    // deren Farbfamilie -- aber einen dunkleren Ton, damit
122    // er auf dem Board nicht mit dem Personalassistenten
123    // verwechselt wird. Gleiche Abteilung, zwei Rollen.
124    lohnbuchhaltung: '#c92a2a',
125    // ai-team-support-v1: Der Support-Mitarbeiter sitzt in
126    // 'Buero & Empfang' (#20c997) wie der Office-Manager und traegt
127    // deshalb dessen Farbfamilie -- aber einen dunkleren Ton, damit die
128    // beiden auf dem Board unterscheidbar bleiben. Gleiche Abteilung,
129    // zwei Rollen, wie bei Personal/Lohnbuchhaltung.
130    support: '#0ca678',
131    // ai-team-strategie-v1: Der Strategie-Manager sitzt in der
132    // Abteilung 'Leitung' (#7048c4) und traegt deren Farbfamilie
133    // in einem dunkleren Ton, damit er auf dem Board nicht mit
134    // der Leitung verwechselt wird. Gleiche Abteilung, zwei
135    // Rollen -- dasselbe Muster wie Personal/Lohnbuchhaltung.
136    strategie: '#5f3dc4',
137};
138
139// ai-team-board-v2: Statusanzeige traegt IMMER Symbol + Wort, nie Farbe
140// allein — sonst ist der Zustand fuer farbfehlsichtige Leser nicht
141// unterscheidbar.
142/* ai-team-board-v3: Ab dieser Teamgroesse lohnt sich Gliederung.
143   Darunter ueberwiegt die Bedienlast den Nutzen — bei zwei Leuten
144   sind Filter und Abteilungs-Ueberschriften reine Struktur ohne
145   Aussage. Der Wert ist bewusst konservativ: lieber eine Weile
146   ungruppiert als zu frueh zerstueckelt. */
147const GRUPPIEREN_AB = 6;
148
149// ai-team-stand-v1: Wie alt der Zahlenstand hoechstens sein darf, bevor
150// ein Wechsel auf das Board ihn still nachzieht. Ohne Zeitschwelle
151// wuerde JEDER Reiter-Klick eine Anfrage ausloesen; ohne Nachziehen
152// zeigt das Band Zahlen von vor dem letzten Takt.
153const STAND_MAX_MS = 10000;
154
155const STATUS_SYMBOL = {
156    aktiv: 'fa-circle-check',
157    probezeit: 'fa-hourglass-half',
158    pausiert: 'fa-circle-pause',
159};
160
161export class AiTeam {
162
163    /* ai-team-einstieg-v1: ziel = {reiter:'postfach'|'dateien'} springt nach
164       dem Laden direkt in den passenden Reiter des Mitarbeiters, dem das
165       Betriebsmittel zugewiesen ist. Ohne ziel bleibt alles wie bisher. */
166    static async show(ziel) {
167        const host = document.getElementById('mainContent');
168        if (!host) return;
169        // Zustand HART zuruecksetzen — nichts ueberlebt einen Seitenwechsel.
170        this._members = []; this._telefon = []; this._katalog = null;
171        this._stats = null; this._departments = []; this._auftraege = {}; this._standZeit = 0;
172        /* aiteam-offenerlauf-v1: wer gerade arbeitet, je Mitarbeiter. */
173        this._offeneLaeufe = {};
174        this._filterDep = ''; this._suche = ''; this._nurAktive = false;
175        this._zu = {}; this._org = null;
176        this._akteId = null; this._akteDaten = null; this._hire = null;
177        // ai-team-chat-ui-v1b: der Gespraechszustand gehoert zum HARTEN
178        // Ruecksetzen. Ohne ihn ueberlebt ein Verlauf den Seitenwechsel und
179        // taucht spaeter an einer Stelle wieder auf, an der niemand ihn
180        // erwartet — genau die Klasse _neLastConflicts / _soapKiQuelle.
181        // Verloren geht nichts: das Gespraech liegt auf dem Server und ist
182        // ueber "Fruehere Gespraeche" erreichbar.
183        this._chat = null;
184        // ai-team-ausstattung-ui-v1: Betriebsmittel + Einrichtungsstand
185        this._ressourcen = []; this._spec = null; this._setup = null;
186        this._dok = null;   /* dokdesk1: Zustands-Hygiene */
187        // ai-team-einstieg-v1: 'Zu' gilt nur fuer diesen Besuch. Ein
188        // dauerhaftes Wegklicken waere genau die stille Kappung, die
189        // uns die acht leeren Tabellen eingebracht hat.
190        this._einrichtung = null; this._einrichtungZu = false;
191        this._einrichtungFrisch = '';
192        this._resForm = null; this._resTest = null;
193        this._adsWahl = null;
194        /* kiteam-oberflaeche-v1 */
195        this._abr = null; this._keinAbo = false; this._abrMeldung = null;
196        this._tab = 'board';
197        this._einstiegReiter = (ziel || {}).reiter || null;
198        this._einstiegWahl = null;
199        host.innerHTML = this._geruest();
200        this._binde(host);
201        try {
202            // ai-team-ausstattung-ui-v1c: Katalog PARALLEL mitladen — ohne
203            // ihn zeigt _rolleTitel den technischen Schluessel
204            // ("office_manager" statt "Office-Manager"). Ein Fehler beim
205            // Katalog darf das Board nicht verhindern, deshalb catch.
206            const [d] = await Promise.all([
207                api('/api/ai-team/members'),
208                this._katalogLaden().catch(() => null),
209            ]);
210            this._members = d.members || [];
211            this._telefon = d.telefon || [];
212            this._departments = d.departments || [];
213            this._auftraege = d.auftraege || {};
214            /* aiteam-offenerlauf-v1 */
215            this._offeneLaeufe = d.offene_laeufe || {};
216            this._stats = d.stats || null;
217            this._setup = d.setup || null;
218            this._ressourcen = (d.setup || {}).ressourcen || [];
219            // ai-team-einstieg-v1: was noch eingerichtet werden muss.
220            this._einrichtung = d.einrichtung || null;
221            AiTeam._badgeSetzen(d.badge || 0);
222            if ((d.badge || 0) > 0) this._tab = 'freigaben';
223        } catch (e) {
224            /* kiteam-oberflaeche-v1: "Noch kein KI-Team gebucht" ist KEIN
225               Fehler, sondern der Kauffall. Ohne diese Weiche saehe ein
226               kaufwilliger Kunde einen roten Kasten und haette keinen Weg
227               weiter - die Sackgasse, die wie ein Defekt aussieht. */
228            if (e && e.code === 'kiteam_kein_abo') {
229                this._keinAbo = true;
230                this._tab = 'abrechnung';
231                this._render();
232                return;
233            }
234            host.querySelector('#aiTeamInhalt').innerHTML =
235                '<div class="alert alert-danger">' + esc(e.message) + '</div>';
236            return;
237        }
238        // ads-oauth-v1: Rueckkehr von der Google-Anmeldung. Steht
239        // ABSICHTLICH nach der Badge-Zeile - sonst zoege ein offener
240        // Freigabe-Vorgang den Nutzer auf einen anderen Reiter, und die
241        // Kontoauswahl waere unsichtbar. Die Adresse wird danach
242        // bereinigt, damit ein Neuladen keinen verbrauchten Vorgang
243        // erneut vorlegt.
244        let adsZustand = null;
245        try {
246            const p = new URLSearchParams(window.location.search || '');
247            adsZustand = p.get('adsoauth');
248            if (adsZustand && window.history && window.history.replaceState) {
249                p.delete('adsoauth'); p.delete('adsfehler');
250                const rest = p.toString();
251                window.history.replaceState({}, '',
252                    window.location.pathname + (rest ? '?' + rest : ''));
253            }
254        } catch (e) { adsZustand = null; }
255        if (adsZustand) {
256            if (!this._adsKatalog) await this._adsLaden(false);
257            await this._adsOauthRueckkehr(adsZustand);
258            return;
259        }
260        this._laeufeWache();        // aiteam-offenerlauf-v1
261        if (this._einstiegReiter) {
262            // Der Direkteinstieg entscheidet selbst, ob er die Akte oeffnet
263            // (genau ein Kandidat) oder das Board mit Auswahl zeigt.
264            if (await this._einstieg(this._einstiegReiter)) return;
265        }
266        this._render();
267    }
268
269    /* ================================================================
270       ai-team-einstieg-v1 — DIREKTER EINSTIEG AUS DEM MENUE
271
272       Ein Menuepunkt "Postfach" ist nur dann eindeutig, wenn es GENAU EIN
273       Postfach gibt. Bei mehreren muss der Mensch waehlen - stillschweigend
274       das erste zu nehmen waere die schlechteste Antwort, weil er dann in
275       einem fremden Postfach liest, ohne es zu merken.
276       Rueckgabe: true = die Ansicht ist bereits gesetzt.
277       ================================================================ */
278    static EINSTIEG_TYP(reiter) {
279        // Der Reiter heisst "dateien", das Betriebsmittel "ablage".
280        return reiter === 'dateien' ? 'ablage' : 'postfach';
281    }
282
283    static _mitAusstattung(typ) {
284        return (this._members || []).filter(m => {
285            const aus = ((m.config || {}).ausstattung) || {};
286            return !!aus[typ];
287        });
288    }
289
290    static async _einstieg(reiter) {
291        const typ = this.EINSTIEG_TYP(reiter);
292        const leute = this._mitAusstattung(typ);
293        if (leute.length === 1) {
294            this._einstiegReiter = null;
295            await this._akteOeffnen(leute[0].id, reiter);
296            return true;
297        }
298        // 0 oder mehrere: Board mit Auswahlband. Das Band wird in _render
299        // ueber _einstiegBand() gezeichnet.
300        this._einstiegWahl = { reiter: reiter, typ: typ, leute: leute };
301        this._einstiegReiter = null;
302        return false;
303    }
304
305    /* ================================================================
306       ai-team-einstieg-v1 — EINRICHTUNGSBAND
307
308       GEMESSENER ANLASS (17.08.2026): acht Tabellen standen auf NULL,
309       obwohl alle acht Funktionen fertig gebaut sind und im Takt laufen.
310       Firmenprofil, Ziele, Dauerauftraege, Ablaeufe, Zugaenge — nichts
311       davon war je benutzt worden. Es fehlte keine Faehigkeit, es fehlte
312       der Weg hinein.
313
314       DREI ENTSCHEIDUNGEN:
315       (a) Das Band erscheint NUR, solange wirklich etwas offen ist, und
316           jeder Punkt verschwindet einzeln, sobald er erledigt ist. Eine
317           dauerhafte "3 von 3"-Anzeige waere nach einer Woche unsichtbar.
318       (b) Jeder Punkt nennt die FOLGE statt der Taetigkeit. "Betrieb
319           beschreiben" ist eine Hausaufgabe; "Ihre Mitarbeiter wissen
320           nicht, in welchem Betrieb sie arbeiten" ist ein Grund.
321       (c) "Zu" gilt nur fuer diesen Besuch. Wer es dauerhaft wegklicken
322           kann, klickt es weg und hat wieder acht leere Tabellen.
323       ================================================================ */
324    static _einrichtungBand() {
325        const e = this._einrichtung;
326        if (!e || !(e.offen || []).length) return '';
327        if (this._einrichtungZu) return '';
328
329        const frisch = this._einrichtungFrisch;
330        const offen = e.offen || [];
331        const fertig = (e.erledigt || []).length;
332        const gesamt = Number(e.gesamt || offen.length + fertig) || offen.length;
333
334        // Nach dem Einstellen wird der Betriebspunkt namentlich: der
335        // Zusammenhang "gerade eingestellt / kennt den Laden nicht" ist
336        // genau dann greifbar und eine Minute spaeter nicht mehr.
337        const kopf = frisch
338            ? '<div class="fw-semibold">'
339              + t('{n} ist eingestellt — er kennt Ihr Unternehmen noch nicht.')
340                  .replace('{n}', esc(frisch))
341              + '</div>'
342              + '<div class="text-muted" style="font-size:.85rem">'
343              + t('Zwei Minuten jetzt sparen Ihnen die Erklärung in jedem Gespräch.')
344              + '</div>'
345            : '<div class="fw-semibold">'
346              + t('Ihr KI-Team ist noch nicht ganz eingerichtet')
347              + '</div>'
348              + '<div class="text-muted" style="font-size:.85rem">'
349              + t('{a} von {b} Schritten erledigt. Ohne sie arbeitet jeder Mitarbeiter für sich.')
350                  .replace('{a}', String(fertig)).replace('{b}', String(gesamt))
351              + '</div>';
352
353        const punkte = offen.map(p =>
354            '<div class="d-flex align-items-start gap-2 py-2 border-top">'
355            + '<i class="fas fa-circle-dot mt-1" style="color:#c2610a;font-size:.7rem"></i>'
356            + '<div class="me-auto">'
357            + '<div class="fw-semibold" style="font-size:.92rem">' + esc(p.titel)
358            + (p.dauer ? ' <span class="text-muted fw-normal">· ' + esc(p.dauer)
359                         + '</span>' : '')
360            + '</div>'
361            + '<div class="text-muted" style="font-size:.83rem">' + esc(p.warum)
362            + '</div></div>'
363            + this._einrichtungKnopf(p)
364            + '</div>').join('');
365
366        return '<div class="p-3 mb-3 rounded-3" '
367            + 'style="background:' + (frisch ? '#fff8f0' : '#f5f8ff')
368            + ';border:1px solid ' + (frisch ? '#ffd9a8' : '#c9d8f5') + '">'
369            + '<div class="d-flex align-items-start gap-3">'
370            + '<i class="fas fa-' + (frisch ? 'user-plus' : 'wand-magic-sparkles')
371            + '" style="color:#c2610a;font-size:1.2rem;margin-top:2px"></i>'
372            + '<div class="me-auto">' + kopf + '</div>'
373            + '<button class="btn btn-sm btn-link text-muted flex-shrink-0 p-0" '
374            + 'data-action="einrichtung-zu" title="' + t('Für jetzt ausblenden')
375            + '"><i class="fas fa-xmark"></i></button>'
376            + '</div>'
377            + punkte
378            + '</div>';
379    }
380
381    /* ai-team-einstieg-v1b: Der Knopf muss WIRKLICH hinfuehren.
382       "Dauerauftraege" liegen nicht auf dem Board, sondern in der
383       Personalakte unter "Verlauf" — gemessen an aiTeam.js:4516. Ein
384       Knopf, der nur in die Naehe fuehrt, verbraucht das Zutrauen
385       einmal und wird danach nicht mehr gedrueckt. */
386    static _einrichtungKnopf(p) {
387        const K = '<button class="btn btn-sm btn-outline-dark flex-shrink-0" ';
388        if (p.key === 'dauerauftraege') {
389            // Irgendein arbeitsfaehiger Mitarbeiter - Dauerauftraege haengen
390            // je Person. Sortiert wird nicht: es geht darum, den Weg zu
391            // ZEIGEN, nicht darum, den richtigen Menschen zu waehlen.
392            const wer = (this._members || [])
393                .find(m => m.status === 'aktiv' || m.status === 'probezeit');
394            if (wer) {
395                return K + 'data-action="emp-reiter" data-id="' + esc(wer.id) + '" '
396                    + 'data-reiter="auftraege">' + t('Hin') + '</button>';
397            }
398            // Ohne Mitarbeiter ist ein Dauerauftrag gegenstandslos.
399            // kiteam-recht-v1: Einstellen ist Inhabersache.
400            if (AiTeam._istMitarbeiter()) return '';
401            return K + 'data-action="wizard">' + t('Einstellen') + '</button>';
402        }
403        if (p.key === 'berichtsweg') {
404            const ziel = p.ziel_emp
405                || ((this._members || []).find(
406                        m => (m.status === 'aktiv' || m.status === 'probezeit')
407                             && !m.vorgesetzter_id) || {}).id;
408            if (ziel) {
409                return K + 'data-action="emp-reiter" data-id="' + esc(ziel) + '" '
410                    + 'data-reiter="einst">' + t('Hin') + '</button>';
411            }
412            return K + 'data-action="tab" data-id="board">' + t('Zum Team') + '</button>';
413        }
414        return K + 'data-action="tab" data-id="'
415            + esc(p.reiter || 'unternehmen') + '">' + t('Hin') + '</button>';
416    }
417
418    static _einstiegBand() {
419        const w = this._einstiegWahl;
420        if (!w) return '';
421        const label = w.reiter === 'dateien' ? t('Dateiablage') : t('Postfach');
422        if (!w.leute.length) {
423            return '<div class="alert alert-info d-flex align-items-center gap-3">'
424                + '<i class="fas fa-circle-info"></i>'
425                + '<div class="me-auto"><div class="fw-semibold">'
426                + t('Noch niemand hat') + ' ' + esc(label) + '</div>'
427                + '<div style="font-size:.85rem">'
428                + t('Öffnen Sie einen Mitarbeiter und richten Sie es unter '
429                    + '„Einstellungen“ ein.')
430                + '</div></div></div>';
431        }
432        return '<div class="alert alert-primary">'
433            + '<div class="fw-semibold mb-2">'
434            + t('Wessen') + ' ' + esc(label) + ' ' + t('möchten Sie öffnen?')
435            + '</div>'
436            + '<div class="text-muted mb-2" style="font-size:.85rem">'
437            + t('Mehrere Mitarbeiter haben eines — bitte wählen.') + '</div>'
438            + '<div class="d-flex flex-wrap gap-2">'
439            /* ai-team-einstieg-v1b: GEFUELLT statt outline - im Screenshot
440               war hellblau auf hellblauem Band praktisch unlesbar. */
441            + w.leute.map(m => '<button class="btn btn-sm btn-primary" '
442                + 'data-action="emp-reiter" data-id="' + esc(m.id) + '" '
443                + 'data-reiter="' + esc(w.reiter) + '">'
444                + esc(m.name) + '</button>').join('')
445            + '</div></div>';
446    }
447
448    /* kiteam-recht-v1 (15.09.2026): reine ANZEIGE-Entscheidung. window.
449       userData setzt app.js beim Menueaufbau; faellt das aus, gilt der
450       Nutzer als Inhaber - der Server erzwingt die Grenze ohnehin
451       (_NUR_INHABER_ENDPUNKTE in routes/ai_team.py). */
452    static _istMitarbeiter() {
453        /* kiteam-inhaber-v3: ein Inhaber mit Rolle 'employee' (Rechte-
454           Migration 23.04.) ist KEIN Mitarbeiter - Server-Urteil zuerst. */
455        const _u = window.userData || {};
456        return String(_u.role || '').toLowerCase() === 'employee'
457            && _u.kiteam_inhaber !== true;
458    }
459
460    static _geruest() {
461        const reiter = [
462            /* ai-team-unternehmen-v1: steht vorn, weil alles andere
463               darauf aufbaut. Standardreiter bleibt trotzdem 'board' —
464               wer das KI-Team oeffnet, will sein Team sehen. */
465            ['unternehmen', 'fa-building', t('Unternehmen')],
466            ['board', 'fa-users', t('Team')],
467            ['organigramm', 'fa-sitemap', t('Organigramm')],
468            ['freigaben', 'fa-check-double', t('Freigaben')],
469            ['zugaenge', 'fa-key', t('Zugänge')],
470            ['aktivitaet', 'fa-list-check', t('Aktivität')],
471            ['erweiterungen', 'fa-puzzle-piece', t('Erweiterungen')],
472            ['quellen', 'fa-database', t('Datenquellen')],
473            ['berichte', 'fa-file-lines', t('Berichte')],
474            /* aiteam-nutzung-v1: steht am Ende — man sieht hier nach,
475               wenn etwas klemmt, nicht bei jedem Öffnen. */
476            ['nutzung', 'fa-gauge-high', t('Verbrauch')],
477            /* kiteam-oberflaeche-v1: Bis heute gab es fuenf Kaufrouten und
478               keinen einzigen Knopf dafuer - niemand konnte buchen. Steht
479               ganz am Ende, weil man hier nachsieht, wenn sich etwas an der
480               Mannschaft aendert, nicht bei jedem Oeffnen. */
481            ['abrechnung', 'fa-receipt', t('Abrechnung')],
482        ];
483        // kiteam-recht-v1: Geld ist Inhabersache - Mitarbeiter mit
484        // Modulrecht sehen alles andere.
485        if (AiTeam._istMitarbeiter()) {
486            const _i = reiter.findIndex(r => r[0] === 'abrechnung');
487            if (_i >= 0) reiter.splice(_i, 1);
488        }
489        return '<div class="container-fluid py-3" id="aiTeamRoot">'
490            + '<div class="d-flex align-items-center mb-3">'
491            + '<h4 class="mb-0"><i class="fas fa-users-gear me-2"></i>' + t('Mein KI-Team') + '</h4>'
492            /* kiteam-inhaber-v3c: Schild 'Vorschau - nur fuer Administratoren
493               sichtbar' entfernt - seit 14.09. sehen Kunden diese Seite. */
494            /* feature-wunsch-v1: Michaels Auftrag - der Nutzer soll einen
495               fehlenden Baustein von hier aus melden koennen. Das Formular
496               selbst liegt in modules/featureWunsch.js und wird von drei
497               Stellen geoeffnet; hier nur der Einstieg. */
498            + '<button class="btn btn-sm btn-outline-primary ms-auto" '
499            + 'data-action="wunsch" title="' + t('Fehlt Ihnen etwas? Sagen Sie es uns.') + '">'
500            + '<i class="fas fa-lightbulb me-1"></i>' + t('Funktionswunsch') + '</button>'
501            + '</div>'
502            + '<ul class="nav nav-tabs mb-3" id="aiTeamTabs">'
503            + reiter.map(([k, ic, lbl]) =>
504                '<li class="nav-item"><a href="#" class="nav-link" data-action="tab" data-id="' + k + '">'
505                + '<i class="fas ' + ic + ' me-1"></i>' + lbl + '</a></li>').join('')
506            + '</ul>'
507            + '<div id="aiTeamInhalt"></div>'
508            + '<div id="aiTeamModal"></div>'
509            + '</div>';
510    }
511
512    static _binde(host) {
513        // EIN delegierter Listener; Werte reisen nie durch HTML-Attribute,
514        // nur IDs/Schluessel (data-id, data-arg).
515        // ai-team-dupfix-v1: Der Host (#mainContent) ueberlebt Seiten-
516        // wechsel — ein addEventListener je Besuch stapelte
517        // Listener (2 Besuche = 2 POSTs = doppelter "Max").
518        // Deshalb: alten Listener entfernen, und ueber die
519        // aiTeamRoot-Wache nie auf fremden Seiten reagieren.
520        if (this._klick) host.removeEventListener('click', this._klick);
521        this._klick = (ev) => {
522            const el = ev.target.closest('[data-action]');
523            if (!el || !host.contains(el)) return;
524            if (!el.closest('#aiTeamRoot')) return;
525            ev.preventDefault();
526            const a = el.getAttribute('data-action');
527            const id = el.getAttribute('data-id') || '';
528            const arg = el.getAttribute('data-arg') || '';
529            const fn = {
530                'tab': () => { this._tab = id; this._akteId = null; this._hire = null; this._render(); this._standPruefen(id); },
531                /* feature-wunsch-v1: das Formular ist ein globales Script
532                   (window.FeatureWunsch) - bewusst kein Import, damit es
533                   nicht an der Modul-Kette dieser Datei haengt. */
534                'wunsch': () => { if (window.FeatureWunsch) window.FeatureWunsch.oeffnen('kiteam'); },
535                /* kiteam-oberflaeche-v1 */
536                'abr-buchen': () => this._abrBuchen(),
537                'abr-vorschau': () => this._abrVorschau(),
538                'abr-anpassen': () => this._abrAnpassen(),
539                'abr-nachkauf': () => this._abrNachkauf(id),
540                'abr-neu': () => { this._abr = null; this._render(); },
541                'akte': () => this._akteOeffnen(id),
542                /* ai-team-einstieg-v1: EIGENE Aktion - 'akte-reiter' ist
543                   bereits vergeben, dort ist data-id der REITER. Hier ist
544                   data-id die Mitarbeiter-Kennung und data-reiter das Ziel. */
545                'emp-reiter': () => this._akteOeffnen(
546                    id, el.getAttribute('data-reiter') || ''),
547                /* ai-team-chat-ui-v1 */
548                'chat-senden': () => this._chatSenden(),
549                /* aiteam-abbruch-v1 */
550                'chat-abbrechen': () => this._chatAbbrechen(),
551                /* ai-team-chatanhang-v1 */
552                'chat-datei': () => this._chatDateiWaehlen(),
553                'chat-anhang-weg': () => this._chatAnhangWeg(id),
554                'chat-ablage-einrichten': () => this._chatAblageEinrichten(),
555                /* ai-team-mikro-v1 */
556                'mikro': () => this._mikroUm(id),
557                'chat-neu': () => this._chatNeu(),
558                'chat-laden': () => this._chatLaden(id),
559                /* ai-team-convtitel-ui-v1 */
560                'chat-titel-speichern': () => this._chatTitelSpeichern(),
561                'chat-suche-leeren': () => {
562                    this._chatSuche = '';
563                    this._chatWahl = [];   /* ai-team-convdel-ui-v1 */
564                    this._chatListeLaden().then(() => this._render());
565                },
566                'chat-umbenennen': () => this._chatUmbenennenFragen(id),
567                'chat-umbenennen-ja': () => this._chatUmbenennen(id),
568                /* ai-team-gfx-v1 — Aktivitaet: Filter und Aufklappen.
569                   Der Statusfilter sitzt auf den Kennzahl-Kacheln: eine
570                   Zahl soll nicht nur berichten, sondern zum naechsten
571                   Schritt fuehren. Zweiter Klick auf dieselbe Kachel
572                   nimmt den Filter zurueck. */
573                'akt-status': () => {
574                    const f = this._aktF || (this._aktF = {});
575                    f.status = (f.status === id) ? '' : id;
576                    this._render();
577                },
578                'akt-wer': () => {
579                    const f = this._aktF || (this._aktF = {});
580                    f.wer = (f.wer === id) ? '' : id;
581                    this._render();
582                },
583                /* Aufklappen aendert NUR den Elementbaum, nicht den
584                   Zustand: ein Neuaufbau der Liste wuerde das Suchfeld
585                   den Eingabefokus kosten. */
586                'akt-mehr': () => {
587                    const box = el.closest('.aiv-zeile');
588                    const txt = box && box.querySelector('.aiv-erg');
589                    if (!txt) { return; }
590                    const zu = txt.classList.toggle('is-kurz');
591                    el.textContent = zu ? t('mehr anzeigen') : t('weniger anzeigen');
592                },
593                // ai-team-einstieg-v1
594                'einrichtung-zu': () => {
595                    this._einrichtungZu = true;
596                    this._einrichtungFrisch = '';
597                    this._render();
598                },
599                // ai-team-unternehmen-v1
600                'un-speichern': () => this._unSpeichern(),
601                'un-ziel-neu': () => { this._unZiel = 'neu'; this._render(); },
602                'un-ziel-bearbeiten': () => { this._unZiel = id; this._render(); },
603                'un-ziel-abbrechen': () => { this._unZiel = null; this._render(); },
604                'un-ziel-speichern': () => this._unZielSpeichern(id),
605                'un-ziel-loeschen': () => this._unZielLoeschen(id),
606                'un-ziel-status': () => this._unZielStatus(id, arg),
607                'un-wissen-neu': () => this._unWissenNeu(),
608                'un-wissen-loeschen': () => this._unWissenLoeschen(id),
609                // wissen-rang-v1
610                'un-wissen-behalten': () => this._unWissenBehalten(id),
611                'un-wissen-kurieren': () => this._unWissenKurieren(),
612                // ai-team-pii-ui-v1
613                'un-pii-speichern': () =>
613 this._unPiiSpeichern(),
614                // ai-team-akte-v1
615                'akte-reiter': () => this._akteReiter(id),
616                // ai-team-ext-ui-v1
617                'ext-neu': () => this._extFormular(null),
618                'ext-bearbeiten': () => this._extFormular(id),
619                'ext-speichern': () => this._extSpeichern(id),
620                'ext-loeschen': () => this._extLoeschen(id),
621                'ext-testen': () => this._extTesten(id),
622                'ext-umschalten': () => this._extUmschalten(id, arg),
623                'ext-zuweisen': () => this._extZuweisen(id),
624                /* ai-team-ui-freigabe-v1 */
625                'chat-frei-ja': () => this._chatFreigabe(id, 'freigegeben'),
626                'chat-frei-nein': () => this._chatFreigabe(id, 'abgelehnt'),
627                /* ai-team-mail-ui-v1 */
628                'pf-ordner': () => this._pfOrdner(id),
629                'pf-mail': () => this._pfOeffnen(id),
630                'pf-zurueck': () => this._pfZurueck(),
631                'pf-suchen': () => this._pfSuchen(),
632                'pf-ungelesen': () => this._pfUngelesenNur(),
633                'pf-neuladen': () => this._pfLaden(false),
634                'pf-mehr': () => this._pfMehr(),
635                'pf-wahl': () => this._pfWahl(id),
636                'pf-wahl-alle': () => this._pfAlle(),
637                'pf-gelesen': () => this._pfMarkieren(true, null),
638                'pf-ungelesen-setzen': () => this._pfMarkieren(false, null),
639                'pf-loeschen': () => this._pfLoeschen(null),
640                'pf-mail-ungelesen': () => this._pfMarkieren(false, id),
641                'pf-mail-loeschen': () => this._pfLoeschen(id),
642                'pf-verfassen': () => this._pfVerfassen(),
643                'pf-anlage-waehlen': () => this._pfAnlageWaehlen(),
644                'pf-anlage-hochladen': () => this._pfAnlageHochladen(),
645                'pf-anlage-nimm': () => this._pfAnlageNimm(id),
646                'pf-anlage-weg': () => this._pfAnlageWeg(id),
647                'pf-anlage-erben': () => this._pfAnlageErben(),
648                'pf-antworten': () => this._pfAntwort('antwort'),
649                'pf-bearbeiten': () => this._pfAntwort('bearbeiten'),
650                'pf-entwurf-senden': () => this._pfEntwurfSenden(id),
651                'pf-weiter': () => this._pfAntwort('weiterleiten'),
652                'pf-send': () => this._pfSenden(false),
653                'pf-entwurf': () => this._pfSenden(true),
654                'pf-signatur': () => this._pfSignatur(),
655                'pf-sig-speichern': () => this._pfSignaturSpeichern(),
656                /* ai-team-chatdel-v1 */
657                'chat-loeschen': () => this._chatLoeschenFragen(id),
658                /* ai-team-convdel-ui-v1 */
659                'chat-wahl': () => this._chatWahlUm(id),
660                'chat-wahl-alle': () => this._chatWahlAlle(),
661                'chat-wahl-zu': () => { this._chatWahl = []; this._render(); },
662                'chat-wahl-loeschen': () => this._chatWahlLoeschenFragen(),
663                'chat-wahl-loeschen-ja': () => this._chatWahlLoeschen(),
664                'chat-loeschen-ja': () => this._chatLoeschen(id),
665                /* ai-team-download-v1: Anhaenge brauchen den Auth-Header */
666                'pf-anhang': () => this._pfAnhang(id, arg),
667                /* ai-team-ablage-ui-v1 */
668                'ab-oeffnen': () => this._abOeffnen(id),
669                /* ai-team-app-ui-v1 */
670                'app-umschalten': () => this._abAppUmschalten(id, arg),
671                'app-speicher': () => this._abAppSpeicher(id),
672                'app-kopieren': () => this._abAppKopieren(id),
673                'ab-datei': () => this._abDatei(id),
674                'ab-laden': () => this._abLaden(this._ab.pfad),
675                'ab-sort': () => this._abSortSetzen(id),   /* ablage-sort-v1 */
676                'ab-hoch': () => this._abHochWaehlen(),
677                'ab-ordner': () => this._abOrdnerFragen(),
678                'ab-ordner-anlegen': () => this._abOrdnerAnlegen(),
679                'ab-del': () => this._abLoeschenFragen(id),
680                'ab-del-ja': () => this._abLoeschen(id),
681                'ab-zurueck': () => this._abWiederherstellen(id),
682                'ab-suchen': () => this._abSuchen(),
683                'ab-suche-zu': () => { this._ab.suche = null; this._ab.wahl = [];
684                                       this._render(); },
685                'ab-einrichten': () => this._abEinrichten(),
686                'ab-runter': () => this._abRunterladen(id),
687                /* ai-team-ablage-paket-ui-v1 */
688                'ab-wahl': () => this._abWahl(id),
689                'ab-wahl-alle': () => this._abAlle(),
690                'ab-wahl-zu': () => { this._ab.wahl = []; this._render(); },
691                'ab-paket': () => this._abPaket(),
692                /* ai-team-ablage-sammelloeschen-v1 */
693                'ab-sammel-del': () => this._abSammelLoeschenFragen(),
694                'ab-sammel-del-ja': () => this._abSammelLoeschen(),
695                'ab-meldung-zu': () => { this._ab.loeschMeldung = null; this._render(); },
696                'ab-pdfansicht': () => this._abPdfAnsicht(id, arg),
697                'ab-bueroansicht': () => this._abBueroAnsicht(id, arg),
698                'ab-blatt': () => this._abBlatt(id, arg),
699                'sk-zu': () => this._skZuordnen(id, true),
700                'sk-ab': () => this._skZuordnen(id, false),
701                'sk-neu': () =>
701 this._skFormular(null),
702                'sk-bearbeiten': () => this._skFormular(id),
703                'sk-speichern': () => this._skSpeichern(),
704                'sk-loeschen': () => this._skLoeschen(id),
705                'sk-vorlage': () => this._skVorlage(id),
706                'wizard': () => this._hireStart(),
707                'wizard-rolle': () => this._hireRolle(id),
708                /* ai-team-gesicht-hire-v1 */
709                'wizard-gesicht': () => this._hireGesicht(id),
710                'wizard-zurueck': () => this._hireZurueck(),
711                'wizard-weiter': () => this._hireWeiter(),
712                'wizard-einstellen': () => this._hireEinstellen(),
713                /* kiteam-karte-v1 */
714                'wizard-karte-speichern': () => this._hireKarteSpeichern(),
715                'wizard-karte-neu': () => this._hireKarteNeu(),
716                'hire-abbrechen': () => { this._hire = null; this._render(); },
717                'modal-zu': () => this._modal(''),
718                /* ai-team-gesichter-v1 */
719                'gesicht-waehlen': () => this._gesichtDialog(id),
720                'gesicht-setzen': () => this._gesichtSetzen(arg, id),
721                'pause': () => this._statusSetzen(id, 'pausiert'),
722                'fortsetzen': () => this._statusSetzen(id, arg || 'aktiv'),
723                'probezeit-beenden': () => this._statusSetzen(id, 'aktiv', true),
724                'entlassen': () => this._entlassenFragen(id),
725                'entlassen-ja': () => this._entlassen(id),
726                'anweisung-add': () => this._anweisungAdd(id),
727                'anweisung-del': () => this._anweisungDel(id, parseInt(arg, 10)),
728                'vertrauen-speichern': () => this._vertrauenSpeichern(id),
729                'freigeben': () => this._entscheiden(parseInt(id, 10), 'freigegeben'),
730                'ablehnen': () => this._ablehnenFragen(parseInt(id, 10)),
731                'freigabe-postfach': () => this._freigabePostfach(parseInt(id, 10)),
732                /* freigabe-fehlschlag-v1 */
733                'freigabe-wiederholen': () => this._freigabeWiederholen(parseInt(id, 10)),
734                'freigabe-verwerfen': () => this._freigabeVerwerfen(parseInt(id, 10)),
735                'ablehnen-senden': () => this._ablehnenSenden(parseInt(id, 10)),
736                'memory-del': () => this._memoryDel(id, parseInt(arg, 10)),
737                // --- gedaechtnis-kuratierung-v1 ---
738                'memory-kurieren': () => this._memoryKurieren(id),
739                'memory-behalten': () => this._memoryBehalten(id, parseInt(arg, 10)),
740                // --- ai-quelle-ui-v1 ---
741                'q-neu': () => this._qForm(''),
742                'q-bearb': () => this._qForm(id),
743                'q-speichern': () => this._qSpeichern(id),
744                'q-an': () => this._qAktiv(id, arg === '1'),
745                'q-holen': () => this._qHolen(id),
746                'q-schluessel': () => this._qSchluesselFragen(id),
747                'q-schluessel-ja': () => this._qSchluesselNeu(id),
748                'q-del': () => this._qDelFragen(id),
749                'q-del-ja': () => this._qDel(id),
750                'q-adr-add': () => this._qZeile('adr'),
751                'q-feld-add': () => this._qZeile('feld'),
752                'q-zeile-del': () => { const n = document.getElementById(id); if (n) n.remove(); },
753                // --- ai-ablage-uben-ui-v1 ---
754                'ab-uben': () => this._abUbenFragen(id, arg),
755                'ab-uben-ja': () => this._abUben(id),
756                'agent-config': () => { window.location.hash = '#agents'; },
757                // --- ai-team-org-ui-v1: Ebenen & Auftraege ---
758                'filter-dep': () => { this._filterDep = (this._filterDep === id ? '' : id); this._render(); },
759                'nur-aktive': () => { this._nurAktive = !this._nurAktive; this._render(); },
760                'dep-zu': () => { this._zu[id] = !this._zu[id]; this._render(); },
761                /* aiteam-organigramm-v1: Umschalter Diagramm/Liste. Zeichnet
762                   direkt aus dem bereits geladenen Stand neu -- ein erneuter
763                   Serveraufruf nur zum Umschalten der Darstellung waere
764                   Verschwendung und wuerde flackern. */
765                'org-ansicht': () => {
766                    this._orgAnsicht = id;
767                    const w = document.getElementById('aiTeamInhalt');
768                    if (w && this._org) {
769                        w.innerHTML = this._orgHtml(this._org);
770                        requestAnimationFrame(() => this._orgEinpassen());
771                    }
772                    else { this._render(); }
773                },
774                /* aiteam-orgbreite-v1: Umschalten zwischen eingepasst und
775                   Originalgroesse. Zeichnet NICHT neu -- es ist reine
776                   Darstellung, ein Neuaufbau wuerde nur flackern. */
777                'org-fit': () => {
778                    this._orgFit = !this._orgFitIst;
779                    this._orgEinpassen();
780                },
781                'auftrag-neu': () => this._auftragFragen(id),
782                'auftrag-senden': () => this._auftragSenden(id),
783                'auftrag-status': () => this._auftragStatus(id, arg),
784                'auftrag-del': () => this._auftragDel(id),
785                // aiteam-auftrag-v2: der Rückkanal
786                'auftrag-antwort': () => this._auftragAntwort(id),
787                'auftrag-faden': () => this._auftragFaden(id),
788                'routine-neu': () => this._routineFragen(id),
789                'routine-senden': () => this._routineSenden(id),
790                'routine-an': () => this._routineToggle(id, arg === '1'),
791                'routine-del': () => this._routineDel(id),
792                'routine-vorbelegen': () => this._routineVorbelegen(id),
793                'org-speichern': () => this._orgSpeichern(id),
794                'zugriffe-speichern': () => this._zugriffeSpeichern(id),
795                'abt-neu': () => this._abtFragen(),
796                'abt-verwalten': () => this._abtVerwalten(),
797                'abt-senden': () => this._abtSenden(),
798                'abt-del': () => this._abtDel(id),
799                // --- ai-team-ausstattung-ui-v1: Betriebsmittel ---
800                'res-neu': () => this._resNeu(id),
801                'res-bearbeiten': () => this._resBearbeiten(id),
802                'res-abbrechen': () => { this._resForm = null; this._resTest = null; this._render(); },
803                'res-speichern': () => this._resSpeichern(),
804                'res-art': () => this._resArtWechsel(id),
805                'res-del': () => this._resDelFragen(id),
806                'res-del-ja': () => this._resDel(id),
807                'res-test': () => this._resTesten(id),
808                'res-import-smtp': () => this._resImportSmtp(),
809                // --- dokdesk1: Ablage durchblaettern ---
810                'dok-auf': () => this._dokAuf(id),
811                'dok-zu': () => { this._dok = null; this._modal(''); },
812                'dok-ein': () => this._dokEin(id),
813                'dok-hoch': () => this._dokHoch(),
814                'dok-datei': () => this._dokDatei(id),
815                // --- ads-ui-v1: Werbekonten ---
816                'ads-bearbeiten': () => this._adsBearbeiten(id),
817                'ads-abbrechen': () => { this._adsForm = null; this._render(); },
818                'ads-speichern': () => this._adsSpeichern(),
819                'ads-test': () => this._adsTesten(id),
820                'ads-trennen': () => this._adsTrennenFragen(id),
821                'ads-trennen-ja': () => this._adsTrennen(id),
822                // --- video-ki-v1: Videoerzeugung ---
823                'video-bearbeiten': () => this._videoBearbeiten(id),
824                'video-abbrechen': () => { this._videoForm = null; this._render(); },
825                'video-speichern': () => this._videoSpeichern(),
826                'video-test': () => this._videoTesten(id),
827                'video-trennen': () => this._videoTrennenFragen(id),
828                'video-trennen-ja': () => this._videoTrennen(id),
829                // --- depot-ki-v1: Depot & Boerse ---
830                'depot-bearbeiten': () => this._depotBearbeiten(id),
831                'depot-abbrechen': () => { this._depotForm = null;
832                                           this._depotProbe = null; this._render(); },
833                'depot-speichern': () => this._depotSpeichern(),
834                'depot-test': () => this._depotTesten(id),
835                'depot-trennen': () => this._depotTrennen(id),
836                'depot-notaus': () => this._depotNotaus(id),
837                'depot-rl-speichern': () => this._depotRichtlinieSpeichern(),
838                // ads-oauth-v1
839                'ads-oauth': () => this._adsOauthStart(),
840                'ads-wahl-ok': () => this._adsWahlBestaetigen(),
841                'ads-wahl-ab': () => { this._adsWahl = null; this._render(); },
842                'ausstattung-speichern': () => this._ausstattungSpeichern(id),
843                'titel-speichern': () => this._titelSpeichern(id),
844                'fachtext-speichern': () => this._fachtextSpeichern(id),
845                'zu-zugaengen': () => { this._tab = 'zugaenge'; this._akteId = null;
846                                        this._hire = null; this._resForm = null; this._render(); },
847                // ai-app-domain-v1
848                'dom-neu': () => { this._domForm = { domain: '', ordner: '' }; this._render(); },
849                'dom-abbrechen': () => { this._domForm = null; this._render(); },
850                'dom-speichern': () => this._domSpeichern(),
851                'dom-aktivieren': () => this._domAktivieren(id),
852                'dom-auto': () => this._domAuto(id, arg === '1'),
853                'dom-entfernen': () => { this._domWeg = id; this._render(); },
854                'dom-entfernen-ab': () => { this._domWeg = null; this._render(); },
855                'dom-entfernen-ja': () => this._domEntfernen(id),
856            }[a];
857            if (fn) fn();
858        };
859        host.addEventListener('click', this._klick);
860
861        /* aiv-liste-v1: Das Tageskontingent wird zurueckgenommen, solange die
862           gewaehlte Stufe es nicht nutzt. Der urspruengliche Weg war reines
863           CSS ueber ":has(select option[value=...]:checked)" - GEMESSEN
864           funktioniert das NICHT: der Selektor trifft nach dem Umschalten
865           (querySelector liefert das Element), der berechnete Stil bleibt
866           aber auf dem alten Wert. Deshalb eine Klasse, gesetzt beim Rendern
867           und hier umgeschaltet. Gleiche Hygiene wie oben: erst den alten
868           Listener entfernen (der Host ueberlebt Seitenwechsel), dann die
869           aiTeamRoot-Wache, damit auf fremden Seiten nichts passiert. */
870        if (this._vertWechsel) host.removeEventListener('change', this._vertWechsel);
871        this._vertWechsel = (ev) => {
872            const el = ev.target;
873            if (!el || !el.hasAttribute || !el.hasAttribute('data-vertrauen')) return;
874            if (!el.closest('#aiTeamRoot')) return;
875            const box = el.closest('.aiv-vs-ctrl');
876            if (box) box.classList.toggle('hat-limit', el.value === 'autonom_limit');
877        };
878        host.addEventListener('change', this._vertWechsel);
879
880        /* ai-team-pii-ui-v1: eigener delegierter change-Listener fuer die
881           Stufenwahl. BEWUSST NICHT ueber data-action -- der Klick-
882           Listener ruft ev.preventDefault(), und das verhindert bei
883           einem Radio das Anhaken selbst. Gleiche Hygiene wie oben:
884           erst den alten Listener entfernen (der Host ueberlebt
885           Seitenwechsel), dann die aiTeamRoot-Wache. */
886        if (this._piiWechsel) host.removeEventListener('change', this._piiWechsel);
887        this._piiWechsel = (ev) => {
888            const el = ev.target;
889            if (!el || el.name !== 'unpii') return;
890            if (!el.closest('#aiTeamRoot')) return;
891            this._unPiiStufeUm(el.value);
892        };
893        host.addEventListener('change', this._piiWechsel);
894
895        /* ai-team-fachtext-ui-v1b: eigener delegierter input-Listener fuer
896           die Zeichenzahl. Dieselbe Hygiene wie beim Klick: erst den alten
897           entfernen (der Host ueberlebt Seitenwechsel), dann die
898           aiTeamRoot-Wache, damit auf fremden Seiten nichts passiert. */
899        if (this._tippen) host.removeEventListener('input', this._tippen);
900        this._tippen = (ev) => {
901            const el = ev.target;
902            if (!el || el.id !== 'akteFachtext') return;
903            if (!el.closest('#aiTeamRoot')) return;
904            const z = document.getElementById('aiFachZahl');
905            if (!z) return;
906            const n = el.value.length;
907            const max = parseInt(el.getAttribute('maxlength') || '4000', 10);
908            z.textContent = n + ' ' + t('von') + ' ' + max + ' ' + t('Zeichen');
909            // Erst kurz vor der Grenze faerben: eine dauerhaft rote Zahl
910            // waere ein Alarm ohne Anlass.
911            z.className = n >= max ? 'small text-danger'
912                        : (n > max * 0.9 ? 'small text-warning' : 'text-muted small');
913        };
914        host.addEventListener('input', this._tippen);
915    }
916
917    static _render() {
918        const host = document.getElementById('aiTeamInhalt');
919        if (!host) return;
920        /* pf-anlagen-fix-v1: Das Verfassen-Formular wird aus p.entwurf neu
921           gebaut. Ohne diesen Schritt ist alles Getippte weg, sobald
922           irgendetwas ein Neuzeichnen ausloest — eine angehaengte Datei,
923           ein Sendefehler, eine Meldung. Nie den Render blockieren. */
924        try { this._pfFormularSichern(); } catch (e) { /* egal */ }
925        // Sequenz-Guard: ein noch laufender asynchroner Render (z. B. langsame
926        // Freigaben-Abfrage) darf einen inzwischen gewechselten Tab nicht mehr
927        // ueberschreiben. Vom eigenen Browser-Beweislauf aufgedeckt (10.08.).
928        this._seq = (this._seq || 0) + 1;
929        const inAnsicht = !this._akteId && !this._hire;
930        document.querySelectorAll('#aiTeamRoot .nav-tabs .nav-link').forEach(el => {
931            el.classList.toggle('active', el.getAttribute('data-id') === this._tab && inAnsicht);
932        });
933        // Beim Einstellen treten die Reiter optisch zurueck — es ist eine
934        // eigene Seite im Seiteninhalt, kein Overlay.
935        const tabs = document.getElementById('aiTeamTabs');
936        if (tabs) tabs.style.display = this._hire ? 'none' : '';
937        if (this._hire) return this._renderHire(host);
938        if (this._akteId) {
939            const ergebnis = this._renderAkte(host);
940            try { this._chatEingabeBinden(); } catch (e) { /* Chat ist optional */ }
941            /* aiteam-stufen-v1: eigener Aufruf - _chatEingabeBinden kehrt
942               frueh zurueck, wenn die Textflaeche fehlt oder schon
943               gebunden ist, und wuerde die Auswahlfelder mitnehmen. */
944            try { this._chatStufenBinden(); } catch (e) { /* optional */ }
945            try { this._chatSuchBinden(); } catch (e) { /* Suche ist optional */ }
946            try { this._chatTitelBinden(); } catch (e) { /* Titel ist optional */ }
947            /* ai-team-dlspin-v1: laufende Downloads nach dem Neuzeichnen */
948            if (this._dlLaeuft && this._dlLaeuft.size) {
949                try { this._dlMarken(); } catch (e) { /* optional */ }
950                Promise.resolve(ergebnis).then(() => { try { this._dlMarken(); } catch (e) { /* optional */ } });
951            }
952            return ergebnis;
953        }
954        /* kiteam-oberflaeche-v1: Ohne Abo gibt es nur einen sinnvollen
955           Inhalt. Die Reiterleiste wird ausgeblendet, statt Punkte
956           anzubieten, die allesamt in dieselbe Sperrmeldung laufen. */
957        if (this._keinAbo) {
958            const tl = document.getElementById('aiTeamTabs');
959            if (tl) tl.style.display = 'none';
960            return this._renderAbrechnung(host);
961        }
962        if (this._tab === 'abrechnung') return this._renderAbrechnung(host);
963        if (this._tab === 'unternehmen') return this._renderUnternehmen(host);
964        if (this._tab === 'board') return this._renderBoard(host);
965        if (this._tab === 'organigramm') return this._renderOrg(host);
966        if (this._tab === 'freigaben') return this._renderFreigaben(host);
967        if (this._tab === 'zugaenge') return this._renderZugaenge(host);
968        if (this._tab === 'aktivitaet') return this._renderAktivitaet(host);
969        if (this._tab === 'erweiterungen') return this._renderErweiterungen(host);
970        if (this._tab === 'quellen') return this._renderQuellen(host);
971        if (this._tab === 'berichte') return this._renderBerichte(host);
972        if (this._tab === 'nutzung') return this._renderNutzung(host);
973    }
974
975    // ---------------------------------------- Team-Board (v2 / v2b)
976    //
977    // AUFBAU VON OBEN NACH UNTEN, nach Dringlichkeit geordnet:
978    //   1. Handlungsband  — nur sichtbar, wenn wirklich etwas wartet
979    //   2. Drei Kennzahlen — Lage, ehrlich getrennt nach Art
980    //   3. Filterleiste    — eine Zeile
981    //   4. Abteilungen     — nur die BELEGTEN, als Karten
982    //   5. Leere Abteilungen — eine ruhige Zeile statt vier Ueberschriften
983    //   6. Telefon-Assistenten — zusammengefasst, nicht als 58 Karten
984
985    /** Kennzahl-Kachel: Beschriftung, Wert, Einordnung.
986     *  Bewusst OHNE Symbol-Dekoration und ohne tabellarische Ziffern —
987     *  eine grosse Zahl soll nicht auseinanderfallen. */
988    /* ================================================================
989       ai-team-board-v3 — GLIEDERUNG
990
991       Michaels Befund: "unuebersichtlich". Ursache war nicht die Optik,
992       sondern die Menge an Struktur: drei Kacheln, eine Werkzeugleiste,
993       eine Filterleiste und Abteilungs-Ueberschriften — vier Ebenen fuer
994       zwei Datensaetze. Die Seite sah verwaltet aus statt uebersichtlich.
995
996       Leitgedanke: STRUKTUR FOLGT MENGE. Unter GRUPPIEREN_AB Mitarbeitern
997       gibt es ein einziges Raster; die Abteilung steht als Etikett auf der
998       Karte statt als eigene Ueberschrift. Erst wenn genug Leute da sind,
999       dass man suchen und filtern MUSS, erscheinen Gruppen und Filter.
1000       ================================================================ */
1001
1002    /** Kopfleiste: Lage UND Handlung in einer Zeile. Drei getrennte
1003     *  Kaesten plus eine eigene Werkzeugleiste haben zusammen ueber 200px
1004     *  Hoehe verbraucht, um drei einstellige Zahlen zu zeigen. */
1005    static _kopfleiste(zahlen, knoepfe) {
1006        const zelle = (z) => '<div class="pe-4 me-2" style="border-right:1px solid #edf0f2">'
1007            + '<div class="text-muted" style="font-size:.78rem">' + z.titel + '</div>'
1008            + '<div class="fw-semibold lh-1 my-1" style="font-size:1.6rem;'
1009            + 'font-variant-numeric:proportional-nums;color:' + esc(z.farbe || '#212529')
1010            + '">' + esc(String(z.wert)) + '</div>'
1011            + '<div class="text-muted" style="font-size:.78rem">' + (z.unter || '') + '</div>'
1012            + '</div>';
1013        return '<div class="d-flex flex-wrap align-items-center gap-3 p-3 mb-4 rounded-3" '
1014            + 'style="background:#fff;border:1px solid #e9ecef">'
1015            + '<div class="d-flex flex-wrap align-items-center gap-3 me-auto">'
1016            + zahlen.map(zelle).join('') + '</div>'
1017            + '<div class="d-flex flex-wrap gap-2">' + knoepfe + '</div>'
1018            + '</div>';
1019    }
1020
1021    static _mwerte(id) {
1022        const je = ((this._stats || {}).je_mitarbeiter || {})[id] || {};
1023        return {
1024            offen: je.offen || 0,
1025            heute: je.heute || 0,
1026            erledigt: je.erledigt_7d || 0,
1027        };
1028    }
1029
1030    /** Kennzahlen einer Karte. ZEIGT NUR, WAS VON NULL VERSCHIEDEN IST —
1031     *  vier Nullen sind teurer Platz fuer die Aussage "nichts passiert". */
1032    static _mstatZeile(w) {
1033        const teile = [];
1034        if (w.auftraege) teile.push([w.auftraege, w.auftraege === 1
1035            ? t('Auftrag') : t('Aufträge')]);
1036        if (w.offen) teile.push([w.offen, w.offen === 1
1037            ? t('Freigabe') : t('Freigaben')]);
1038        if (w.heute) teile.push([w.heute, t('heute erledigt')]);
1039        if (!w.heute && w.erledigt) teile.push([w.erledigt, t('in 7 Tagen')]);
1040        if (!teile.length) return '';
1041        return '<div class="d-flex flex-wrap gap-3 mt-3 pt-2" '
1042            + 'style="border-top:1px solid #f1f3f5">'
1043            + teile.map(([n, lbl]) => '<div><span class="fw-semibold">' + n
1044                + '</span> <span class="text-muted" style="font-size:.8rem">'
1045                + esc(lbl) + '</span></div>').join('')
1046            + '</div>';
1047    }
1048
1049    /** Statuspunkt mit Wort — nie Farbe allein. */
1050    static _statusPille(status) {
1051        const [farbe, lbl] = STATUS_ANZEIGE[status] || ['#6c757d', status];
1052        const ico = STATUS_SYMBOL[status] || 'fa-circle';
1053        return '<span class="d-inline-flex align-items-center gap-1 flex-shrink-0" '
1054            + 'style="font-size:.78rem;color:' + farbe + '">'
1055            + '<i class="fas ' + ico + '"></i>' + esc(t(lbl)) + '</span>';
1056    }
1057
1058    static _renderBoard(host) {
1059        const g = (this._stats || {}).gesamt || {};
1060        const q = (this._suche || '').toLowerCase();
1061        const offeneAuftraege = Object.values(this._auftraege || {})
1062            .reduce((a, b) => a + (b || 0), 0);
1063        const freigaben = g.offene_freigaben || 0;
1064        const aktiv = this._members.filter(m => m.status === 'aktiv').length;
1065        const probe = this._members.filter(m => m.status === 'probezeit').length;
1066        const pause = this._members.filter(m => m.status === 'pausiert').length;
1067        // Ab hier entscheidet EINE Zahl ueber die halbe Seitengliederung.
1068        const gruppiert = this._members.length >= GRUPPIEREN_AB;
1069        // ai-team-board-v3b: Reihenfolge nach Erwartung, nicht nach
1070        // Anlagedatum — in einem Team sucht man die Leitung oben.
1071        const sortiert = this._sortiert();
1072
1073        // --- 1) Handlungsband: erscheint NUR, wenn etwas wartet. Eine
1074        //        dauerhaft sichtbare "0 Freigaben"-Kachel waere nach einer
1075        //        Woche unsichtbar; ein Band, das nur bei Bedarf auftaucht,
1076        //        bleibt es nicht.
1077        const einstieg = this._einstiegBand();
1078        const band = freigaben
1079            ? '<div class="d-flex align-items-center gap-3 p-3 mb-3 rounded-3" '
1080              + 'style="background:#fff8f0;border:1px solid #ffd9a8">'
1081              + '<i class="fas fa-hand-point-right" style="color:#c2610a;font-size:1.2rem"></i>'
1082              + '<div class="me-auto"><div class="fw-semibold">' + freigaben + ' '
1083              + t(freigaben === 1 ? 'Entwurf wartet auf Ihre Entscheidung'
1084                                  : 'Entwürfe warten auf Ihre Entscheidung')
1085              + '</div><div class="text-muted" style="font-size:.82rem">'
1086              + t('Nach 48 Stunden meldet die Leitung sie als liegengeblieben.')
1087              + '</div></div>'
1088              + '<button class="btn btn-warning btn-sm flex-shrink-0" '
1089              + 'data-action="tab" data-id="freigaben">'
1090              + t('Ansehen') + '</button></div>'
1091            : '';
1092
1093        // --- 1b) ai-team-einstieg-v1: Auswahl-/Hinweisband des Direkteinstiegs.
1094        // --- 2) Kopfleiste: Zahlen und Knoepfe gemeinsam in EINER Zeile.
1095        //        KI-Mitarbeiter und Telefon-Assistenten werden NICHT
1096        //        addiert (Michaels falsche "Mitarbeiter 10").
1097        const teamUnter = [
1098            aktiv ? aktiv + ' ' + t('arbeitet') : '',
1099            probe ? probe + ' ' + t('in Probezeit') : '',
1100            pause ? pause + ' ' + t('pausiert') : '',
1101        ].filter(Boolean).join(' · ') || t('noch niemand eingestellt');
1102        const kopf = this._kopfleiste([
1103            { wert: this._members.length, titel: t('Ihr KI-Team'), unter: teamUnter },
1104            { wert: offeneAuftraege, titel: t('Offene Aufträge'),
1105              unter: offeneAuftraege ? t('von Ihnen vergeben') : t('nichts offen') },
1106            { wert: g.aktionen_7d || 0, titel: t('Erledigt (7 Tage)'),
1107              unter: t('heute') + ': ' + (g.aktionen_heute || 0) },
1108        ],
1109            '<button class="btn btn-outline-secondary btn-sm" data-action="abt-verwalten">'
1110            + '<i class="fas fa-folder-tree me-1"></i>' + t('Abteilungen') + '</button>'
1111            + (AiTeam._istMitarbeiter() ? ''   /* kiteam-recht-v1 */
1112               : '<button class="btn btn-primary btn-sm" data-action="wizard">'
1113                 + '<i class="fas fa-user-plus me-1"></i>' + t('Mitarbeiter einstellen') + '</button>'));
1114
1115        // --- 3) Filter NUR bei genug Leuten. Bei zwei Mitarbeitern ist
1116        //        eine Suchleiste reine Bedienlast: man sieht ohnehin alles.
1117        const zaehlDep = (dep) => this._members
1118            .filter(m =>
1118 (m.department_id || '') === dep.id).length;
1119        const belegte = (this._departments || [])
1120            .filter(d => d.system_key !== 'telefonie' && zaehlDep(d) > 0);
1121        let filter = '';
1122        if (gruppiert) {
1123            const chip = (id, lbl, n, akt) =>
1124                '<button class="btn btn-sm ' + (akt ? 'btn-dark' : 'btn-outline-secondary')
1125                + '" data-action="filter-dep" data-id="' + esc(id) + '">' + esc(lbl)
1126                + (n ? ' <span class="opacity-75">' + n + '</span>' : '') + '</button>';
1127            filter = '<div class="d-flex flex-wrap gap-2 align-items-center mb-3">'
1128                + chip('', t('Alle'), this._members.length, !this._filterDep)
1129                + belegte.map(d => chip(d.id, d.name, zaehlDep(d),
1130                    this._filterDep === d.id)).join('')
1131                + '<div class="ms-auto d-flex flex-nowrap gap-2 align-items-center">'
1132                + '<div class="form-check form-switch mb-0 me-1">'
1133                + '<input class="form-check-input" type="checkbox" id="aiNurAktive" '
1134                + 'data-action="nur-aktive"' + (this._nurAktive ? ' checked' : '') + '>'
1135                + '<label class="form-check-label" for="aiNurAktive" '
1136                /* ai-team-board-v3c: sonst bricht das Label in der
1137                   nicht umbrechenden Leiste zu "Nur / aktive". */
1138                + 'style="font-size:.85rem;white-space:nowrap">'
1139                + t('Nur aktive') + '</label></div>'
1140                + '<input id="aiSuche" type="search" class="form-control form-control-sm" '
1141                + 'style="max-width:220px" placeholder="' + t('Suchen') + '" '
1142                + 'value="' + esc(this._suche || '') + '">'
1143                + '</div></div>';
1144        }
1145
1146        // --- 4) Der Inhalt selbst.
1147        const passt = (m) => {
1148            if (!gruppiert) return true;
1149            if (this._nurAktive && m.status !== 'aktiv') return false;
1150            if (q && !(String(m.name || '') + ' ' + String(m.titel || '') + ' '
1151                + this._rolleTitel(m.role_key)).toLowerCase().includes(q)) return false;
1152            return true;
1153        };
1154        const leer = (this._departments || [])
1155            .filter(d => d.system_key !== 'telefonie' && !zaehlDep(d));
1156
1157        let inhalt;
1158        if (!gruppiert) {
1159            // EIN Raster ueber die volle Breite. Die Abteilung wandert als
1160            // Etikett auf die Karte — dieselbe Information, eine Ebene
1161            // weniger, und keine halbleeren Spalten.
1162            const karten = sortiert.map(m => '<div class="col-12 col-md-6 col-xl-4">'
1163                + this._mitarbeiterKarte(m, true) + '</div>').join('')
1164                + this._ausbauKarte(leer);
1165            inhalt = this._members.length
1166                ? '<div class="row g-3 align-items-stretch">' + karten + '</div>'
1167                : this._leerZustand();
1168        } else {
1169            const gruppen = [];
1170            for (const d of (this._departments || [])) {
1171                if (d.system_key === 'telefonie') continue;
1172                const alle = sortiert.filter(m => (m.department_id || '') === d.id);
1173                if (!alle.length) continue;
1174                if (this._filterDep && this._filterDep !== d.id) continue;
1175                const leute = alle.filter(passt);
1176                if (!leute.length) continue;
1177                gruppen.push(this._depGruppe(d, leute));
1178            }
1179            const ohne = this._filterDep ? []
1180                : sortiert.filter(m => !m.department_id && passt(m));
1181            if (ohne.length) {
1182                gruppen.push(this._depGruppe({
1183                    id: '', name: t('Noch keiner Abteilung zugeordnet'),
1184                    farbe: '#adb5bd', icon: 'fa-folder-open',
1185                }, ohne));
1186            }
1187            inhalt = gruppen.length
1188                ? '<div class="row g-4 align-items-start">' + gruppen.join('') + '</div>'
1189                : this._leerZustand();
1190        }
1191
1192        // --- 5) Randinformationen ganz unten, bewusst leise.
1193        const telZeile = this._telefon.length
1194            ? '<div class="d-flex flex-wrap align-items-center gap-3 mt-4 p-3 rounded-3" '
1195              + 'style="background:#faf8ff;border:1px solid #e7defb">'
1196              + '<span class="d-inline-flex align-items-center justify-content-center '
1197              + 'rounded-circle flex-shrink-0" style="width:36px;height:36px;'
1198              + 'background:#6f42c122"><i class="fas fa-phone" style="color:#6f42c1"></i></span>'
1199              + '<div class="me-auto"><div class="fw-semibold">'
1200              + this._telefon.length + ' ' + t('Telefon-Assistenten') + '</div>'
1201              + '<div class="text-muted" style="font-size:.82rem">'
1202              + t('nehmen Anrufe entgegen — werden unter Assistenten verwaltet')
1203              + '</div></div>'
1204              + '<button class="btn btn-sm btn-outline-secondary flex-shrink-0" '
1205              + 'data-action="agent-config">' + t('Verwalten') + '</button>'
1206              + '</div>'
1207            : '';
1208        // Kein Kasten mehr: eine unbesetzte Abteilung ist ein Hinweis,
1209        // kein Vorgang. Zwei graue Baender untereinander waren Redundanz.
1210        // Im ungruppierten Raster steht der Ausbau bereits als Karte —
1211        // die Fussnote waere dann eine zweite Auslegung derselben Sache.
1212        const leerZeile = (gruppiert && leer.length && !this._filterDep && !q)
1213            ? '<div class="text-muted mt-3" style="font-size:.82rem">'
1214              + '<i class="fas fa-folder-open me-2 opacity-50"></i>'
1215              + t('Noch nicht besetzt') + ': ' + leer.map(d => esc(d.name)).join(' · ')
1216              + '</div>'
1217            : '';
1218
1219        // ai-team-einstieg-v1: Reihenfolge ist Absicht. Zuerst die
1220        // Auswahl, die der Mensch selbst ausgeloest hat (einstieg),
1221        // dann wartende Entscheidungen (band), erst dann die
1222        // Einrichtung — sie ist wichtig, aber nie dringend.
1223        host.innerHTML = einstieg + band + this._einrichtungBand()
1224            + kopf + filter + inhalt + telZeile + leerZeile;
1225        if (gruppiert) this._bindSuche();
1226    }
1227
1228    /** Reihenfolge: Fuehrung zuerst, dann nach Abteilungs-Reihenfolge,
1229     *  dann Name. Anlagedatum ist fuer den Leser bedeutungslos. */
1230    static _sortiert() {
1231        const rang = {};
1232        (this._departments || []).forEach((d, i) => { rang[d.id] = i; });
1233        const fuehrt = (m) => (m.role_key === 'leitung' ? 0
1234            : m.role_key === 'abteilungsleitung' ? 1 : 2);
1235        return (this._members || []).slice().sort((a, b) => {
1236            const f = fuehrt(a) - fuehrt(b);
1237            if (f) return f;
1238            const ra = rang[a.department_id] === undefined ? 999 : rang[a.department_id];
1239            const rb = rang[b.department_id] === undefined ? 999 : rang[b.department_id];
1240            if (ra !== rb) return ra - rb;
1241            return String(a.name || '').localeCompare(String(b.name || ''), 'de');
1242        });
1243    }
1244
1245    /** Der freie Platz im Raster wird nicht nur gefuellt, sondern
1246     *  beantwortet die naechstliegende Frage: "wen koennte ich noch
1247     *  gebrauchen?". Ersetzt im ungruppierten Zustand die Fussnote. */
1248    static _ausbauKarte(leer) {
1249        if (!leer || !leer.length) return '';
1250        return '<div class="col-12 col-md-6 col-xl-4">'
1251            + '<div class="h-100 d-flex flex-column justify-content-center rounded-3 p-3" '
1252            + 'style="border:1px dashed #d3d9df;background:#fbfcfd">'
1253            + '<div class="text-muted mb-2" style="font-size:.8rem">'
1254            + '<i class="fas fa-folder-open me-2 opacity-50"></i>'
1255            + t('Noch nicht besetzt') + '</div>'
1256            + '<div class="mb-3" style="font-size:.9rem">'
1257            + leer.map(d => esc(d.name)).join(' · ') + '</div>'
1258            + (AiTeam._istMitarbeiter() ? ''   /* kiteam-recht-v1 */
1259               : '<div><button class="btn btn-sm btn-outline-primary" data-action="wizard">'
1260                 + '<i class="fas fa-user-plus me-1"></i>' + t('Mitarbeiter einstellen')
1261                 + '</button></div>')
1262            + '</div></div>';
1263    }
1264
1265    static _leerZustand() {
1266        return '<div class="text-center text-muted py-5">'
1267            + '<i class="fas fa-user-slash fa-2x mb-3 opacity-25"></i><div>'
1268            + t(this._members.length ? 'Kein Treffer.' : 'Noch niemand eingestellt.')
1269            + '</div></div>';
1270    }
1271
1272    static _depGruppe(dep, leute) {
1273        const zu = !!this._zu[dep.id || '_ohne'];
1274        // ai-team-board-v3: KEIN Papierkorb mehr im Gruppenkopf. Eine
1275        // loeschende Aktion gehoert nicht neben eine Ueberschrift, die man
1276        // beim Ueberfliegen trifft — sie liegt jetzt im Abteilungs-Dialog.
1277        const kopf = '<div class="d-flex align-items-center gap-2 mb-3">'
1278            + '<span style="cursor:pointer" class="d-inline-flex align-items-center gap-2" '
1279            + 'data-action="dep-zu" data-id="' + esc(dep.id || '_ohne') + '">'
1280            + '<i class="fas ' + esc(zu ? 'fa-chevron-right' : 'fa-chevron-down')
1281            + ' text-muted" style="font-size:.75rem"></i>'
1282            + '<span class="d-inline-flex align-items-center justify-content-center rounded-2" '
1283            + 'style="width:26px;height:26px;background:' + esc(dep.farbe || '#6c757d') + '1f">'
1284            + '<i class="fas ' + esc(dep.icon || 'fa-building') + '" style="color:'
1285            + esc(dep.farbe || '#6c757d') + ';font-size:.8rem"></i></span>'
1286            + '<span class="fw-semibold">' + esc(dep.name) + '</span>'
1287            + '<span class="text-muted" style="font-size:.85rem">' + leute.length + '</span>'
1288            + '</span></div>';
1289        const spalte = (inhalt) => '<div class="col-12 col-lg-6 col-xxl-4">'
1290            + inhalt + '</div>';
1291        if (zu) return spalte(kopf);
1292        return spalte(kopf + '<div class="d-flex flex-column gap-3">'
1293            + leute.map(m => this._mitarbeiterKarte(m)).join('')
1294            + '</div>');
1295    }
1296
1297    /** @param mitEtikett  Im ungruppierten Raster traegt die Karte die
1298     *  Abteilung selbst; in einer Gruppe waere das eine Wiederholung. */
1299    static _mitarbeiterKarte(m, mitEtikett) {
1300        const chef = this._members.find(x => x.id === m.vorgesetzter_id);
1301        const auftraege = (this._auftraege || {})[m.id] || 0;
1302        const rolleTitel = this._rolleTitel(m.role_key);
1303        const fuehrung = m.role_key === 'leitung' || m.role_key === 'abteilungsleitung';
1304        const farbe = ROLLEN_FARBEN[m.role_key] || '#0d6efd';
1305        const dep = mitEtikett
1306            ? (this._departments || []).find(d => d.id === m.department_id)
1307            : null;
1308        // Zweite Zeile: der eigene Titel gewinnt, die Rolle steht als
1309        // Einordnung dahinter — sonst weiss niemand, was ein "CEO" im
1310        // System eigentlich darf.
1311        // ai-team-bugfix-20260812 (B6): die Rolle nur dann dahinter, wenn
1312        // sie etwas HINZUFUEGT. Sonst stand auf der Karte "Entwickler ·
1313        // Entwickler".
1314        const unterzeile = (m.titel_eigen && m.titel && m.titel !== rolleTitel)
1315            ? esc(m.titel) + ' <span class="text-muted">· ' + esc(rolleTitel) + '</span>'
1316            : esc(m.titel || rolleTitel);
1317        const etikett = mitEtikett
1318            ? '<div class="d-flex align-items-center gap-2 mb-2" style="font-size:.78rem">'
1319              + (dep
1320                  ? '<span class="d-inline-flex align-items-center gap-1 text-muted">'
1321                    + '<i class="fas fa-circle" style="font-size:.5rem;color:'
1322                    + esc(dep.farbe || '#adb5bd') + '"></i>' + esc(dep.name) + '</span>'
1323                  : '<span class="text-muted"><i class="fas fa-circle me-1" '
1324                    + 'style="font-size:.5rem;color:#dee2e6"></i>'
1325                    + t('Ohne Abteilung') + '</span>')
1326              + '<span class="ms-auto">' + this._statusPille(m.status) + '</span></div>'
1327            : '';
1328        return '<div class="card h-100 border-0" style="box-shadow:0 1px 3px rgba(0,0,0,.06);'
1329            + 'border-top:3px solid ' + farbe + ' !important">'
1330            + '<div class="card-body pb-2 d-flex flex-column" style="cursor:pointer" '
1331            + 'data-action="akte" data-id="' + esc(m.id) + '">'
1332            + etikett
1333            + '<div class="d-flex align-items-start gap-2 mb-1">'
1334            + this._gesichtBild(m, 38, farbe)
1335            + '<div class="me-auto" style="min-width:0">'
1336            + '<div class="fw-semibold text-truncate">' + esc(m.name)
1337            + (fuehrung ? ' <i class="fas fa-crown" style="color:' + farbe
1338                + ';font-size:.7rem" title="' + t('Führungsrolle') + '"></i>' : '')
1339            + '</div>'
1340            + '<div class="text-truncate" style="font-size:.85rem">' + unterzeile + '</div>'
1341            + '</div>'
1342            + (mitEtikett ? '' : this._statusPille(m.status))
1343            + '</div>'
1344            + '<div class="text-muted" style="font-size:.8rem">'
1345            + '<i class="fas fa-arrow-turn-up me-1" style="font-size:.7rem"></i>'
1346            + (chef ? t('berichtet an') + ' ' + esc(chef.name) : t('berichtet an Sie'))
1347            + '</div>'
1348            + this._fuehrtZeile(m)
1349            + this._laufZeile(m)
1350            + this._mstatZeile(Object.assign(this._mwerte(m.id), { auftraege: auftraege }))
1351            + this._bedarfHinweis(m.id)
1352            + '</div>'
1353            + '<div class="px-3 pb-3 pt-1 d-flex gap-2 flex-wrap">'
1354            + '<button class="btn btn-sm btn-primary flex-grow-1 text-nowrap" '
1355            + 'style="min-width:104px" '
1356            + 'data-action="akte" data-id="' + esc(m.id) + '">'
1357            + '<i class="fas fa-comments me-1"></i>' + t('Sprechen') + '</button>'
1358            + '<button class="btn btn-sm btn-outline-primary flex-grow-1 text-nowrap" '
1359            + 'style="min-width:104px" '
1360            + 'data-action="auftrag-neu" data-id="' + esc(m.id) + '">'
1361            + t('Auftrag geben') + '</button>'
1362            + this._karteSprung(m)
1363            + '</div></div>';
1364    }
1365
1366    /* aiteam-offenerlauf-v1 ------------------------------------------
1367       MICHAELS BEFUND (18.09.2026): "wenn ich einem Mitarbeiter eine
1368       Aufgabe gebe, zu einem anderen wechsle und zurueckkomme, sehe ich
1369       nicht, ob er noch arbeitet."
1370
1371       Die Karte sagte bisher nur, WAS jemand ist - nie, ob er gerade
1372       etwas tut. Die Zeile erscheint nur, solange wirklich ein Auftrag
1373       laeuft; eine dauerhafte "arbeitet nicht"-Zeile waere Rauschen. */
1374    static _laufMeta(o) {
1375        const teile = [];
1376        /* aiteam-mehrlauf-v1: Bei mehreren Laeufen zaehlt der aelteste
1377           (seit wann arbeitet er) und die Summe der Schritte - die Zahlen
1378           nur des neuesten Laufs wuerden die Arbeit kleinreden. */
1379        const alle = (o && o.alle && o.alle.length > 1) ? o.alle : null;
1380        const s = alle
1381            ? Math.max.apply(null, alle.map(x => Math.max(0, x.dauer_s || 0)))
1382            : Math.max(0, (o && o.dauer_s) || 0);
1383        teile.push(s < 60
1384            ? t('seit {n} s').replace('{n}', s)
1385            : t('seit {m}:{s} min').replace('{m}', Math.floor(s / 60))
1386                .replace('{s}', String(s % 60).padStart(2, '0')));
1387        const k = alle
1388            ? alle.reduce((a, x) => a + (x.schritte || 0), 0)
1389            : ((o && o.schritte) || 0);
1390        if (k > 0) {
1391            teile.push(k === 1 ? t('1 Arbeitsschritt')
1392                : t('{n} Arbeitsschritte').replace('{n}', k));
1393        }
1394        return teile.join(' \u00b7 ');
1395    }
1396
1397    static _laufZeile(m) {
1398        const o = (this._offeneLaeufe || {})[m.id];
1399        if (!o) return '';
1400        return '<div class="d-flex align-items-center gap-2 mt-2 px-2 py-1 '
1401            + 'rounded-2" style="background:#e7f1ff;border:1px solid #b6d4fe;'
1402            + 'color:#084298;font-size:.78rem">'
1403            + '<span class="spinner-border flex-shrink-0" '
1404            + 'style="width:.7rem;height:.7rem;border-width:.12em"></span>'
1405            + '<span class="fw-semibold">' + ((o.anzahl || 1) > 1
1406                ? esc(t('arbeitet an {n} Gesprächen').replace('{n}', o.anzahl))
1407                : t('arbeitet gerade')) + '</span>'
1408            + '<span class="ms-auto text-truncate" id="aiLaufMeta-'
1409            + esc(m.id) + '">' + esc(this._laufMeta(o)) + '</span></div>';
1410    }
1411
1412    /* Nur der Text wird nachgezogen, nicht die Karte neu gebaut: ein
1413       Neuaufbau alle paar Sekunden wuerde jede Bedienung stoeren -
1414       dieselbe Ueberlegung wie bei _chatStatusSetzen(). */
1415    static _laufMetaSetzen() {
1416        Object.keys(this._offeneLaeufe || {}).forEach((id) => {
1417            const el = document.getElementById('aiLaufMeta-' + id);
1418            if (el) el.textContent = this._laufMeta(this._offeneLaeufe[id]);
1419        });
1420    }
1421
1422    /* Der Takt fuer das Abzeichen. EIN Zeitgeber fuer die ganze Klasse.
1423       Er haelt sich an drei Grenzen: nur auf dem Board, nur wenn die
1424       Seite sichtbar ist, und nur solange die Ansicht ueberhaupt steht -
1425       sonst liefe die Abfrage weiter, nachdem der Nutzer laengst
1426       woanders ist. Neu gezeichnet wird ausserdem NUR, wenn sich die
1427       Menge der Arbeitenden aendert. */
1428    static _laeufeWache() {
1429        if (typeof window === 'undefined' || this._laeufeUhr) return;
1430        this._laeufeUhr = setInterval(() => {
1431            try { this._laeufeStand(); } catch (e) { /* egal */ }
1432        }, 15000);
1433    }
1434
1435    static async _laeufeStand() {
1436        if (typeof document === 'undefined') return;
1437        if (!document.getElementById('aiTeamInhalt')) {
1438            clearInterval(this._laeufeUhr); this._laeufeUhr = null;
1439            return;
1440        }
1441        if (document.visibilityState !== 'visible') return;
1442        /* aiteam-mehrlauf-v1: In der Akte wird NICHT neu gezeichnet - das
1443           zerschnitte eine einlaufende Antwort. Dort werden nur die
1444           Markierungen der Gespraechsliste nachgezogen. */
1445        if (this._akteId) return this._akteLaeufeStand();
1446        if (this._tab !== 'board') return;
1447        let d = null;
1448        try { d = await api('/api/ai-team/laeufe/offen'); } catch (e) { return; }
1449        const neu = d.laeufe || {};
1450        /* Die Anzahl gehoert zur Karte ("arbeitet an 2 Gespraechen") -
1451           aendert sie sich, muss die Karte neu gezeichnet werden. */
1452        const sig = (m) => Object.keys(m || {}).sort()
1453            .map(k => k + ':' + ((m[k] || {}).anzahl || 1)).join(',');
1454        const vorher = sig(this._offeneLaeufe);
1455        this._offeneLaeufe = neu;
1456        if (sig(neu) === vorher) {
1457            this._laufMetaSetzen();
1458            return;
1459        }
1460        this._render();
1461    }
1462
1463    /* aiteam-offenerlauf-v1 - den laufenden Auftrag wieder aufnehmen.
1464
1465       DER KERN VON MICHAELS BEFUND: _akteOeffnen baut den Chatzustand
1466       beim Wechsel auf einen anderen Mitarbeiter komplett neu auf. Damit
1467       war die Laufkennung weg - und der seit August vorhandene
1468       Wiederanschluss konnte nie greifen, weil er genau sie braucht.
1469       Gemessen am 18.09.2026: null Aufrufe seiner Route im gesamten
1470       Zugriffsprotokoll. Hier bekommt die Oberflaeche sie vom Server
1471       zurueck und haengt sich wieder an.
1472
1473       BEWUSST ab Ereignis 0 statt ab einem gemerkten Stand: nach dem
1474       Wechsel ist die Antwortblase leer, es muss also alles noch einmal
1475       hinein. Den Zeiger setzt das 'start'-Ereignis selbst. */
1476    static async _laufWiederaufnehmen() {
1477        const c = this._chatZustand();
1478        /* aiteam-mehrlauf-v1: Der Server meldet ALLE laufenden
1479           Gespraeche dieses Mitarbeiters. Frueher kam nur der neueste -
1480           ein zweiter Auftrag war damit unsichtbar und hing, sobald man
1481           die Akte wechselte, ohne Zuhoerer. */
1482        const liste = this._akteLaeufeSetzen(((c.info || {}).offener_lauf) || null);
1483        this._akteLaeufeWache();
1484        /* Laeuft hier schon etwas, ist es DIESES Fenster - nicht
1485           dazwischenfunken. */
1486        if (!liste.length || c.laeuft) return;
1487        /* Bevorzugt das Gespraech, das gerade offen ist - sonst das
1488           zuletzt begonnene. Die anderen stehen markiert in der Liste
1489           und werden beim Anklicken angehaengt. */
1490        /* Ist schon ein ANDERES Gespraech offen, bleibt es offen - wer
1491           gerade etwas liest, soll nicht weggerissen werden. */
1492        const o = liste.find(x => x.conversation_id && x.conversation_id === c.conv)
1493            || (c.conv ? null : liste[0]);
1494        if (!o || !o.lauf_id || c.laufId === o.lauf_id) return;
1495        if (o.conversation_id && c.conv !== o.conversation_id) {
1496            await this._chatLaden(o.conversation_id, { ohneLauf: true, stumm: true });
1497        }
1498        this._laufAnhaengen(o);
1499    }
1500
1501    /* aiteam-mehrlauf-v1 - an EINEN bestimmten Lauf anhaengen.
1502
1503       BEWUSST ab Ereignis 0 statt ab einem gemerkten Stand: nach dem
1504       Wechsel ist die Antwortblase leer, es muss also alles noch einmal
1505       hinein. Den Zeiger setzt das 'start'-Ereignis selbst. Gehoert der
1506       Lauf nicht zum offenen Gespraech, geschieht nichts - sonst liefe
1507       die Antwort des einen Auftrags in das Fenster des anderen. */
1508    static _laufAnhaengen(o) {
1509        const c = this._chatZustand();
1510        if (!o || !o.lauf_id) return;
1511        if (c.laeuft || c.laufId === o.lauf_id) return;
1512        if (o.conversation_id && c.conv !== o.conversation_id) return;
1513        /* Schon zu Ende gesehen: die Antwort steht im geladenen Verlauf -
1514           ein zweites Anhaengen zeigte sie doppelt. */
1515        if ((c.erledigt || []).indexOf(o.lauf_id) >= 0) return;
1516        /* Die Blase, in die der Wiederanschluss schreibt. Die Frage des
1517           Menschen steht bereits im geladenen Verlauf - sie wird vor dem
1518           Lauf gespeichert. */
1519        c.msgs.push({ role: 'assistant', content: '', tools: [] });
1520        c.laufId = o.lauf_id;
1521        c.conv = o.conversation_id || c.conv;
1522        c.gesehen = 0;
1523        c.schritte = 0;
1524        c.startMs = Date.now() - Math.max(0, (o.dauer_s || 0)) * 1000;
1525        c.fertig = false;
1526        c.getrennt = true;
1527        c.laeuft = true;
1528        c.leerAnschluss = 0;
1529        c.phase = 'denken'; c.phaseText = '';
1530        this._hintergrundWache();
1531        this._chatTickStart();
1532        /* OHNE await: das Anhaengen laeuft, waehrend die Akte fertig
1533           gezeichnet wird. */
1534        this._chatWiederanschluss(c);
1535    }
1536
1537    /* aiteam-mehrlauf-v1 - vom angezeigten Lauf loesen, OHNE ihn zu stoppen.
1538
1539       Gebraucht, wenn man waehrend eines Laufs ein anderes Gespraech
1540       oeffnet oder ein neues beginnt. Der Lauf arbeitet auf dem Server
1541       weiter (seit aiteam-ohnelimit-v1 ohne Zeitdeckel); nur dieses
1542       Fenster hoert ihm nicht mehr zu. Die Generation ist der Riegel:
1543       jeder Leser merkt sich die Nummer, mit der er gestartet wurde, und
1544       faengt nichts mehr an, sobald sie nicht mehr stimmt. Ohne ihn
1545       schrieb der alte Strom seinen Text in das gerade geoeffnete
1546       Gespraech - oder brach ab und haengte den alten Lauf wieder an. */
1547    static _chatLoesen(c) {
1548        c = c || this._chatZustand();
1549        if (!c) return;
1550        c.gen = (c.gen || 0) + 1;
1551        try { if (c.ctrl) c.ctrl.abort(); } catch (e) { /* egal */ }
1552        c.ctrl = null;
1553        c.laeuft = false; c.laufId = null; c.fertig = false; c.getrennt = false;
1554        c.anschlussLaeuft = false; c.leerAnschluss = 0;
1555        c.abbruchLaeuft = false; c.abbruchGewuenscht = false;
1556        c.abbruchNotbremse = false;
1557        c.phase = ''; c.phaseText = '';
1558        this._chatTickStop();
1559    }
1560
1561    /* aiteam-mehrlauf-v1 - die laufenden Gespraeche der offenen Akte. */
1562    static _akteLaeufeSetzen(o) {
1563        const liste = !o ? [] : ((o.alle && o.alle.length) ? o.alle : [o]);
1564        this._akteLaeufe = liste.filter(x => x && x.lauf_id);
1565        return this._akteLaeufe;
1566    }
1567
1568    static _laufFuerConv(convId) {
1569        if (!convId) return null;
1570        return (this._akteLaeufe || []).find(x => x.conversation_id === convId) || null;
1571    }
1572
1573    static async _akteLaeufeHolen() {
1574        const id = this._akteId;
1575        if (!id) return this._akteLaeufe || [];
1576        let d = null;
1577        try { d = await api('/api/ai-team/laeufe/offen'); } catch (e) { return this._akteLaeufe || []; }
1578        if (this._akteId !== id) return this._akteLaeufe || [];
1579        const alle = (d && d.laeufe) || {};
1580        this._offeneLaeufe = alle;
1581        return this._akteLaeufeSetzen(alle[id] || null);
1582    }
1583
1584    /* Markierungen in der Gespraechsliste AM PLATZ setzen - kein
1585       Neuaufbau, der das Eingabefeld oder eine einlaufende Antwort
1586       zerschnitte. */
1587    static _convLaufMarkenSetzen() {
1588        if (typeof document === 'undefined') return;
1589        document.querySelectorAll('[data-conv-lauf]').forEach(el => {
1590            const an = !!this._laufFuerConv(el.getAttribute('data-conv-lauf'));
1591            el.style.display = an ? 'flex' : 'none';   /* aiteam-spin-v1 */
1592        });
1593    }
1594
1595    static _convLaufMarke(convId) {
1596        const an = !!this._laufFuerConv(convId);
1597        /* aiteam-spin-v1: KEIN d-flex - Bootstraps display:flex !important
1598           schlug das display:none, der Kreisel stand bei JEDEM Gespraech. */
1599        return '<div class="small" data-conv-lauf="'
1600            + esc(convId) + '" style="display:' + (an ? 'flex' : 'none')
1601            + ';align-items:center;gap:.25rem">'
1602            + '<span class="spinner-border flex-shrink-0" '
1603            + 'style="width:.6rem;height:.6rem;border-width:.12em"></span>'
1604            + '<span>' + esc(t('arbeitet gerade')) + '</span></div>';
1605    }
1606
1607    static _akteLaeufeWache() {
1608        if (typeof window === 'undefined' || this._akteLaeufeUhr) return;
1609        this._akteLaeufeUhr = setInterval(() => {
1610            try { this._akteLaeufeStand(); } catch (e) { /* egal */ }
1611        }, 15000);
1612    }
1613
1614    static async _akteLaeufeStand() {
1615        if (typeof document === 'undefined') return;
1616        if (!document.getElementById('aiTeamInhalt') || !this._akteId) {
1617            clearInterval(this._akteLaeufeUhr); this._akteLaeufeUhr = null;
1618            return;
1619        }
1620        if (document.visibilityState !== 'visible') return;
1621        const vorher = (this._akteLaeufe || []).map(x => x.lauf_id).sort().join(',');
1622        const liste = await this._akteLaeufeHolen();
1623        this._convLaufMarkenSetzen();
1624        const jetzt = (liste || []).map(x => x.lauf_id).sort().join(',');
1625        if (jetzt === vorher) return;
1626        const c = this._chatZustand();
1627        if (c.laeuft) return;
1628        /* Ein Lauf ist fertig geworden oder neu dazugekommen (zweiter
1629           Tab, Handy): Nachrichtenzahlen der Liste nachziehen. */
1630        this._chatListeAuffrischen();
1631        /* Laeuft jetzt etwas im OFFENEN Gespraech, das dieses Fenster
1632           nicht selbst gestartet hat - nur anhaengen, wenn gerade nichts
1633           getippt wird, sonst ginge der Entwurf beim Neuladen verloren. */
1634        const feld = document.getElementById('aiChatEingabe');
1635        if (this._laufFuerConv(c.conv) && !(feld && feld.value)
1636            && (this._akteTab || 'chat') === 'chat') {
1637            this._chatLaden(c.conv);
1638        }
1639    }
1640
1641    /* aiteam-organigramm-v1 -------------------------------------------
1642       Die Karte sagte bisher nur, an WEN jemand berichtet -- nie, WEN er
1643       fuehrt. Genau das war Michaels Befund am Team-Reiter (12.09.2026):
1644       bei einer Fuehrungskraft ist die Mannschaft die Information, die
1645       fehlt. Zeigt hoechstens drei Namen; danach eine Zahl, sonst
1646       sprengt eine grosse Abteilung die Karte. */
1647    static _fuehrtZeile(m) {
1648        const kinder = (this._members || []).filter(x => x.vorgesetzter_id === m.id);
1649        if (!kinder.length) return '';
1650        const namen = kinder.map(x => x.name);
1651        const kurz = namen.length > 3
1652            ? namen.slice(0, 3).join(', ') + ' +' + (namen.length - 3)
1653            : namen.join(', ');
1654        return '<div class="text-muted text-truncate" style="font-size:.8rem" '
1655            + 'title="' + esc(namen.join(', ')) + '">'
1656            + '<i class="fas fa-users me-1" style="font-size:.7rem"></i>'
1657            + t('führt') + ' ' + kinder.length + ': ' + esc(kurz) + '</div>';
1658    }
1659
1660    /* ai-team-einstieg-v1: Postfach und Dateien direkt von der Karte aus.
1661       Nur was zugewiesen ist - ein Knopf, der zu einem leeren Reiter fuehrt,
1662       ist schlechter als keiner. */
1663    static _karteSprung(m) {
1664        const aus = ((m.config || {}).ausstattung) || {};
1665        let h = '';
1666        if (aus.postfach) {
1667            h += '<button class="btn btn-sm btn-outline-secondary flex-shrink-0" '
1668               + 'data-action="emp-reiter" data-id="' + esc(m.id) + '" '
1669               + 'data-reiter="postfach" title="' + t('Postfach') + '">'
1670               + '<i class="fas fa-envelope"></i></button>';
1671        }
1672        if (aus.ablage) {
1673            h += '<button class="btn btn-sm btn-outline-secondary flex-shrink-0" '
1674               + 'data-action="emp-reiter" data-id="' + esc(m.id) + '" '
1675               + 'data-reiter="dateien" title="' + t('Dateien') + '">'
1676               + '<i class="fas fa-folder-open"></i></button>';
1677        }
1678        // aiteam-auftragwort-v1d: als Block mit ms-auto. Passt alles in eine
1679        // Zeile, stehen die Symbole rechts; wird es eng, bricht der GANZE
1680        // Block in die zweite Zeile statt ein einzelnes Symbol - gemessen
1681        // bei 4 Knoepfen: 386px noetig, 344px vorhanden.
1682        return h ? '<div class="d-flex gap-2 ms-auto flex-shrink-0">' + h + '</div>' : '';
1683    }
1684
1685    /* ================================================================
1686       ai-team-chat-ui-v1c — MIT DEM MITARBEITER SPRECHEN
1687
1688       Waehrend der Antwort wird BEWUSST NICHT neu gerendert: ein
1689       Neuaufbau der Akte wuerde das Eingabefeld ersetzen und Fokus samt
1690       Cursor verlieren. Der Text laeuft stattdessen direkt in einen
1691       Textknoten (textContent, nie innerHTML) - damit ist Modelltext
1692       zugleich strukturell nicht als HTML ausfuehrbar.
1693       ================================================================ */
1694    /* chatzustand-v1 — EINE QUELLE FUER DEN ANFANGSZUSTAND.
1695       Der Zustand wurde an ZWEI Stellen aufgebaut: hier und in
1696       _akteOeffnen beim Wechsel auf einen ANDEREN Mitarbeiter. Die zweite
1697       war eine Kurzfassung mit fuenf von vierzehn Feldern. Folge: nach
1698       einem Mitarbeiterwechsel war 'anhaenge' undefined, und der erste
1699       Dateianhang scheiterte mit "Cannot read properties of undefined
1700       (reading 'push')". Die Datei war zu dem Zeitpunkt bereits
1701       hochgeladen - nur das Einhaengen in die Liste schlug fehl, was den
1702       Fehler besonders irrefuehrend machte. Wer hier ein Feld ergaenzt,
1703       ergaenzt es damit fuer BEIDE Wege. */
1704    static _chatAnfangszustand(empId) {
1705        const z = { empId: empId, conv: null, msgs: [],
1706                           laeuft: false, info: null, titel: '',
1707                           /* ai-team-chatanhang-v1: 'anhaenge' sind bereits
1708                              ABGELEGTE Dateien (Pfad in der Ablage), nicht
1709                              die Dateiobjekte des Browsers - hochgeladen
1710                              wird beim Auswaehlen, nicht beim Senden.
1711                              'entwurf' haelt den getippten Text: die Akte
1712                              baut sich bei jeder Aktion neu auf, und ohne
1713                              ihn waere der Satz weg, sobald man eine Datei
1714                              anhaengt. */
1715                           anhaenge: [], hochlaeuft: false, anhMeldung: '',
1716                           anhGrund: '', anhOk: '', entwurf: '',
1717                           /* aiteam-stufen-v1: Modell- und Tiefenwahl fuer
1718                              DIESES Gespraech. Leer heisst "noch nichts
1719                              gewaehlt" - dann gilt, was der Server als
1720                              aktiv meldet. Im Zustand gehalten, weil die
1721                              Akte sich bei jeder Aktion neu aufbaut und die
1722                              Auswahl sonst bei jedem Anhang zuruecksprang
1723                              (dieselbe Falle wie beim Entwurfstext). */
1724                           modellStufe: '', tiefeStufe: '' };
1725        return z;
1726    }
1727
1728    static _chatZustand() {
1729        if (!this._chat) {
1730            /* ai-team-convtitel-ui-v1: 'titel' haelt den Namen des
1731               Gespraechs - auch BEVOR es existiert. Michaels Wunsch, schon
1732               beim Anlegen einen Titel vergeben zu koennen: er wird mit der
1733               ersten Nachricht mitgeschickt und ersetzt dort die Vorgabe
1734               aus den ersten 50 Zeichen der Frage. */
1735            this._chat = this._chatAnfangszustand(this._akteId);
1736        }
1737        return this._chat;
1738    }
1739
1740    static _akteChat(m) {
1741        const c = this._chatZustand();
1742        const info = c.info || {};
1743        const gesperrt = m.status === 'entlassen';
1744        const verlauf = c.msgs.length
1745            ? c.msgs.map((x, i) =>
1745 this._chatBlase(x, i, m)).join('')
1746            : '<div class="text-muted small text-center py-4">'
1747              + esc(t('Schreiben Sie ihm — so wie einem Menschen. Er kennt seine Stelle, seine Rechte und was heute ansteht.'))
1748              + '</div>';
1749
1750        const werkzeugzahl = (info.werkzeuge || []).length;
1751        const kont = info.kontingent || {};
1752        /* aiteam-stufen-v1: Michaels Vorgabe - Modell und Tiefe je
1753           Gespraech waehlbar. Bewusst KLARTEXT statt Modellkennung: die
1754           Modellzeile wurde in ai-team-modellweg-v1 genau deshalb
1755           entfernt, dass eine technische Zeichenkette dem Inhaber nichts
1756           sagt. Bezeichnungen, Hinweise und Verbrauchsfaktoren kommen aus
1757           dem Dienst (stufen_auskunft), damit Anzeige und Lauf nicht
1758           auseinanderlaufen koennen. */
1759        const stf = info.stufen || {};
1760        const msAkt = c.modellStufe || stf.modell_stufe || '';
1761        const tsAkt = c.tiefeStufe || stf.tiefe_stufe || '';
1762        const optM = (stf.modelle || []).map(o =>
1763            '<option value="' + esc(o.schluessel) + '"'
1764            + (o.schluessel === msAkt ? ' selected' : '') + '>'
1765            + esc(o.label + '  ·  ' + o.faktor) + '</option>').join('');
1766        const optT = (stf.tiefen || []).map(o =>
1767            '<option value="' + esc(o.schluessel) + '"'
1768            + (o.schluessel === tsAkt ? ' selected' : '') + '>'
1769            + esc(o.label) + '</option>').join('');
1770        const infoM = (stf.modelle || []).find(o => o.schluessel === msAkt) || {};
1771        const infoT = (stf.tiefen || []).find(o => o.schluessel === tsAkt) || {};
1772        /* aiteam-stufen-v1b: Der Auswahlpfeil muss INLINE und mit
1773           !important gesetzt werden. GEMESSEN am echten Element greift
1774           hausweit '.v2-layout .form-select { background: ... !important }'
1775           - eine Kurzschreibweise, die background-image mit auf "none"
1776           zuruecksetzt. Ohne Pfeil sahen beide Felder aus wie
1777           Beschriftungen: kein Rahmen (das ist Hausgestaltung, Felder
1778           werden ueber die Fuellung gekennzeichnet) und kein Zeichen,
1779           dass man sie aufklappen kann. Vom eigenen Screenshot gefunden -
1780           meine Messung hatte auf den RAHMEN geprueft und damit das
1781           Falsche verlangt. */
1782        const pfeil = 'background-image:url(data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%3e%3cpath%20fill=%27none%27%20stroke=%27%23495057%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%272%27%20d=%27m2%205%206%206%206-6%27/%3e%3c/svg%3e)!important;'
1783            + 'background-repeat:no-repeat!important;'
1784            + 'background-position:right .55rem center!important;'
1785            + 'background-size:12px!important;padding-right:1.8rem!important;'
1786            + 'cursor:pointer;';
1787        const stufenZeile = (stf.modelle || []).length
1788            ? '<div class="mt-2">'
1789              + '<div class="d-flex flex-wrap align-items-center gap-2">'
1790              + '<label class="small text-muted mb-0" for="aiChatModellStufe">'
1791              + '<i class="fas fa-microchip me-1"></i>' + esc(t('Modell')) + '</label>'
1792              + '<select id="aiChatModellStufe" class="form-select form-select-sm" '
1793              + 'style="width:auto;min-width:150px;' + pfeil + '"'
1794              + (gesperrt ? ' disabled' : '') + '>' + optM + '</select>'
1795              + '<label class="small text-muted mb-0 ms-2" for="aiChatTiefeStufe">'
1796              + '<i class="fas fa-brain me-1"></i>' + esc(t('Tiefe')) + '</label>'
1797              + '<select id="aiChatTiefeStufe" class="form-select form-select-sm" '
1798              + 'style="width:auto;min-width:130px;' + pfeil + '"'
1799              + (gesperrt ? ' disabled' : '') + '>' + optT + '</select>'
1800              + '</div>'
1801              /* Hinweis auf EIGENER Zeile: neben den Feldern brach er auf
1802                 drei Zeilen um und quetschte die Auswahl zusammen. */
1803              + '<div class="small text-muted mt-1" id="aiChatStufenHinweis">'
1804              + esc([infoM.hinweis, infoT.hinweis].filter(Boolean).join(' '))
1805              + '</div>'
1806              + '</div>'
1807            : '';
1808
1809        const fuss = '<div class="d-flex flex-wrap gap-3 small text-muted mt-2">'
1810            + '<span><i class="fas fa-toolbox me-1"></i>'
1811            + esc(werkzeugzahl + ' ' + t('Werkzeuge')) + '</span>'
1812            + (kont.grenze
1813                ? '<span><i class="fas fa-gauge me-1"></i>'
1814                  + esc(kont.verbraucht + ' / ' + kont.grenze + ' ' + t('Aktionen diesen Monat'))
1815                  + '</span>'
1816                : '<span><i class="fas fa-key me-1"></i>' + esc(t('eigener Schlüssel')) + '</span>')
1817            /* ai-team-modellweg-v1: Die Modellzeile in der Chat-Fusszeile ist
1818               entfernt. Der technische Modellname ist fuer den Inhaber keine
1819               Information, die er braucht oder einordnen kann - er sieht nur
1820               eine Zeichenkette, die sich bei jedem Modellwechsel aendert.
1821               Das selbst eingestellte Modell einer eigenen LLM-Ressource
1822               bleibt sichtbar (_resBeschreibung) - das hat er selbst gesetzt. */
1823            + '</div>';
1824
1825        return '<div class="card mb-3 border-primary"><div class="card-body">'
1826            + '<div class="d-flex align-items-center flex-wrap gap-2 mb-2">'
1827            + '<h6 class="mb-0"><i class="fas fa-comments me-2 text-primary"></i>'
1828            + esc(t('Mit') + ' ' + m.name + ' ' + t('sprechen')) + '</h6>'
1829            /* ai-team-convtitel-ui-v1: Der Titel steht IMMER hier und ist
1830               immer aenderbar - auch bei einem noch leeren Gespraech. Damit
1831               ist "beim Anlegen einen Titel vergeben" derselbe Handgriff
1832               wie spaeteres Umbenennen; ein eigener Dialog vor jedem neuen
1833               Gespraech waere eine Huerde bei jedem einzelnen Chat. */
1834            + '<div class="input-group input-group-sm ms-auto" style="max-width:340px">'
1835            + '<span class="input-group-text"><i class="fas fa-tag"></i></span>'
1836            + '<input id="aiChatTitel" class="form-control" maxlength="120" '
1837            + 'placeholder="' + esc(t('Titel (optional)')) + '" '
1838            + 'value="' + esc(c.titel || '') + '">'
1839            + '<button class="btn btn-outline-secondary" data-action="chat-titel-speichern" '
1840            + 'title="' + esc(t('Titel speichern')) + '"><i class="fas fa-check"></i></button>'
1841            + '</div>'
1842            /* ai-team-akte-v1: die beiden Knoepfe "Fruehere Gespraeche" und
1843               "Neues Gespraech" sind in die Spalte links gewandert. Ein
1844               Knopf, der einen Dialog oeffnet, den man auch dauerhaft
1845               danebenstellen kann, ist ein Umweg. */
1846            + '</div>'
1847            + (m.status === 'probezeit'
1848                ? '<div class="alert alert-info py-1 px-2 small mb-2">'
1849                  + esc(t('In der Probezeit bereitet er alles nur vor — ausgeführt wird nichts.'))
1850                  + '</div>' : '')
1851            + (m.status === 'pausiert'
1852                ? '<div class="alert alert-warning py-1 px-2 small mb-2">'
1853                  + esc(t('Er ist pausiert. Er kann antworten, aber weder etwas nachsehen noch etwas tun.'))
1854                  + '</div>' : '')
1855            + ((info.luecken || []).length
1856                ? '<div class="alert alert-warning py-1 px-2 small mb-2">'
1857                  + esc(t('Hierfür hat er Zugriff, aber noch kein Werkzeug:') + ' '
1858                        + (info.luecken || []).join(', ')) + '</div>' : '')
1859            /* ai-team-akte-v1b: MINDESThoehe, nicht nur eine Hoechstgrenze.
1860               Ohne sie faellt die Flaeche bei leerem Verlauf auf die Hoehe
1861               des Hinweistextes zusammen - der Chat sah dadurch schmaler
1862               aus als die Gespraechsliste daneben und wirkte wie ein
1863               Nebenschauplatz. Vom eigenen Screenshot gefunden. */
1864            + '<div id="aiChatVerlauf" style="min-height:38vh;max-height:52vh;'
1865            + 'overflow:auto;display:flex;flex-direction:column;'
1866            + (c.msgs.length ? '' : 'justify-content:center;')
1867            + 'background:#f8f9fa;border-radius:.5rem;padding:.75rem">' + verlauf + '</div>'
1868            /* ai-team-chatanhang-v1: die Anlagenzeile steht ZWISCHEN Verlauf
1869               und Eingabe - sie gehoert zur Nachricht, die gerade entsteht,
1870               nicht zum Gesagten. */
1871            + this._chatAnhangLeiste()
1872            /* aiteam-fortschritt-v1: Die Fortschrittszeile steht
1873               DIREKT ueber der Eingabe und damit neben dem Stopp-
1874               Knopf - wer liest, dass gearbeitet wird, sieht in
1875               derselben Zeile, wie er eingreift. Im Verlauf waere
1876               sie weggescrollt. */
1877            + this._chatStatusZeile()
1878            + '<div class="d-flex gap-2 mt-2">'
1879            + '<textarea id="aiChatEingabe" class="form-control" rows="2" maxlength="50000" '
1880            + 'placeholder="' + esc(t('Frage oder Auftrag …')) + '"'
1881            + (gesperrt ? ' disabled' : '') + '>' + esc(c.entwurf || '') + '</textarea>'
1882            + this._chatKlammerKnopf(gesperrt)
1883            /* ai-team-mikro-v1: Diktiertaste. align-self-end, damit sie auf
1884               der Grundlinie des Senden-Knopfs sitzt und nicht mit der
1885               zweizeiligen Textflaeche mitwaechst. */
1886            + this._mikroKnopf('aiChatEingabe', 'btn-outline-primary align-self-end')
1887            /* ai-team-sendeknopf-v1: nur das Symbol, damit der Knopf zur
1888               Diktiertaste daneben passt. Der frueher noetige Text (v1c)
1889               ist durch _symbolKnopfStil ersetzt: die festen Masse halten
1890               die Flaeche AUCH ohne Symbolschrift, und die Bedeutung
1891               steckt jetzt in title/aria-label statt im sichtbaren Wort.
1892               Ohne feste Masse mass der Knopf mit Text 81x55 und stand
1893               damit 17px hoeher als die Diktiertaste - die Zeile war
1894               sichtbar schief. */
1895            /* aiteam-abbruch-v1: Waehrend der Lauf laeuft, wird aus dem
1896               Senden-Knopf ein Stopp-Knopf. Michaels Vorgabe. Bewusst
1897               DERSELBE Platz statt eines dritten Knopfes neben Klammer
1898               und Mikrofon: der Senden-Knopf ist waehrend des Laufs
1899               ohnehin ohne Funktion, und eine vierte Schaltflaeche
1900               haette die Zeile gesprengt. Der rote Stopp ersetzt zugleich
1901               den bisherigen Kreisel - er zeigt genauso, dass gearbeitet
1902               wird, sagt aber zusaetzlich, dass man eingreifen kann. */
1903            + (c.laeuft
1904                ? ('<button class="btn btn-danger align-self-end flex-shrink-0" '
1905                   + this._symbolKnopfStil()
1906                   + 'id="aiChatSenden" data-action="chat-abbrechen" '
1907                   + 'title="' + esc(t('Abbrechen')) + '" '
1908                   + 'aria-label="' + esc(t('Abbrechen')) + '">'
1909                   + '<i class="fas fa-stop"></i>'
1910                   + '</button>')
1911                : ('<button class="btn btn-primary align-self-end flex-shrink-0" '
1912                   + this._symbolKnopfStil()
1913                   + 'id="aiChatSenden" data-action="chat-senden" '
1914                   + 'title="' + esc(t('Senden')) + '" '
1915                   + 'aria-label="' + esc(t('Senden')) + '"'
1916                   + (gesperrt ? ' disabled' : '') + '>'
1917                   + '<i class="fas fa-paper-plane"></i>'
1918                   + '</button>'))
1919            + '</div>'
1920            /* ai-team-mikro-v1: eigene Meldezeile (Mikro gesperrt, nichts
1921               gehoert). _hinweis() taugt hier nicht - es schreibt in
1922               #aiChatTitelHinweis, das es am Auftrag gar nicht gibt. */
1923            + '<div id="aiMikMsg-aiChatEingabe" class="form-text text-warning d-none mt-1"></div>'
1924            /* chatlaenge-v1: Zeichenzahl ab 90 % und Hinweis, wenn
1925               eingefuegter Text an der Grenze abgeschnitten wurde. */
1926            + '<div id="aiChatZahl" class="form-text d-none mt-1"></div>'
1927            + stufenZeile
1928            + fuss
1929            + '</div></div>';
1930    }
1931
1932    static _chatBlase(x, i, m) {
1933        const eigen = x.role === 'user';
1934        /* aiteam-fortschritt-v1: Solange noch kein Wort da ist,
1935           stand hier eine LEERE weisse Blase - das sieht aus, als
1936           waere etwas kaputtgegangen. Die Punkte zeigen, dass die
1937           Antwort GENAU HIER entsteht. Sie verschwinden von selbst:
1938           setzeText() schreibt mit textContent in dieselbe Stelle
1939           und ueberschreibt sie mit dem ersten Wort. */
1940        const cz = this._chatZustand();
1941        const wartet = !eigen && cz.laeuft && !(x.content || '')
1942            && i === (cz.msgs || []).length - 1;
1943        const wz = (x.tools || []).map(tt => {
1944            /* ai-team-spuren-v1: Der Zusatz haing bisher NUR am Modus.
1945               Eine laengst entschiedene Freigabe trug deshalb weiter das
1946               Abzeichen "wartet auf Freigabe" - waehrend eine Zeile
1947               darunter "Veröffentlicht." samt Adresse stand. Der Inhaber
1948               liest das Abzeichen zuerst; genau das meldete Michael als
1949               "es bleibt ewig stehen bei App veröffentlichen". Solange
1950               'modus' beim Speichern verlorenging, fiel es nicht auf -
1951               mit der Reparatur waere der Widerspruch sichtbar geworden.
1952               Vom eigenen Beweislauf gefunden (K6). */
1953            const offen = tt.modus === 'freigabe' && !tt.entschieden;
1954            const farbe = tt.modus === 'autonom' ? 'success'
1955                : (offen ? 'warning'
1956                   : (tt.modus === 'freigabe'
1957                      ? (tt.entschieden === 'freigegeben' ? 'success' : 'secondary')
1958                      : (tt.modus === 'entwurf' ? 'info' : 'secondary')));
1959            const zusatz = offen ? ' — ' + t('wartet auf Freigabe')
1960                : (tt.modus === 'entwurf' ? ' — ' + t('nur Entwurf')
1961                   : (tt.modus === 'gesperrt' ? ' — ' + t('nicht erlaubt') : ''));
1962            let zeile = '<span class="badge bg-' + farbe + ' me-1 mb-1">'
1963                + esc((tt.label || tt.tool) + zusatz) + '</span>';
1964            // ai-team-ui-freigabe-v1: Die Freigabe wird dort erteilt, wo sie
1965            // entsteht. Michael musste bisher den Reiter wechseln - im
1966            // Gespraech wiederholte er stattdessen "versende sie".
1967            if (offen && tt.approval_id) {
1968                zeile += '<div class="mt-2 d-flex gap-2 flex-wrap">'
1969                    + '<button class="btn btn-sm btn-success" data-action="chat-frei-ja" '
1970                    + 'data-id="' + tt.approval_id + '">'
1971                    + '<i class="fas fa-check me-1"></i>' + t('Jetzt ausführen') + '</button>'
1972                    + '<button class="btn btn-sm btn-outline-danger" data-action="chat-frei-nein" '
1973                    + 'data-id="' + tt.approval_id + '">'
1974                    + '<i class="fas fa-xmark me-1"></i>' + t('Ablehnen') + '</button>'
1975                    + '</div>';
1976            } else if (tt.entschieden === '...') {
1977                zeile += '<div class="small mt-1 text-muted">'
1978                    + '<span class="spinner-border spinner-border-sm me-1"></span>'
1979                    + t('Wird ausgeführt …') + '</div>';
1980            } else if (tt.entschieden) {
1981                zeile += '<div class="small mt-1 '
1982                    + (tt.entschieden === 'freigegeben' ? 'text-success' : 'text-danger')
1983                    + '"><i class="fas '
1984                    + (tt.entschieden === 'freigegeben' ? 'fa-check' : 'fa-xmark')
1985                    + ' me-1"></i>'
1986                    /* ai-team-spuren-v1: 'wirkung' entsteht nur im
1987                       laufenden Fenster (_chatFreigabe). Nach einem
1988                       Neuladen stand deshalb "Ausgeführt." ueber einer
1989                       Adresse - der Vorgang sah unfertig aus, obwohl die
1990                       Anwendung live war. Aus der Adresse ist die Wirkung
1991                       ableitbar; nur der Screenshot hat es gezeigt. */
1992                    + esc(tt.entschieden === 'freigegeben'
1993                          ? (tt.wirkung
1994                             || (tt.adresse ? t('Veröffentlicht.') : t('Ausgeführt.')))
1995                          : t('Abgelehnt.')) + '</div>'
1996                    /* ai-team-appurl-ui-v1: Bei manchen Werkzeugen IST das
1997                       Ergebnis die Nutzlast. Beim Veroeffentlichen einer
1998                       Anwendung ist das die Adresse - der Mitarbeiter kann
1999                       sie NICHT nennen, weil nach der Freigabe kein
2000                       Modelldurchlauf mehr laeuft. Sie muss deshalb hier
2001                       stehen, sonst erfaehrt der Inhaber sie nie.
2002                       href nur bei https:// - die Adresse stammt zwar aus
2003                       unserer eigenen Tabelle, aber ein Verweis, dessen
2004                       Ziel aus Daten kommt, wird grundsaetzlich geprueft. */
2005                    + (tt.entschieden === 'freigegeben' && tt.adresse
2006                       && String(tt.adresse).startsWith('https://')
2007                       ? '<div class="small mt-1 d-flex align-items-center '
2008                         + 'flex-wrap gap-2">'
2009                         + '<a href="' + esc(tt.adresse) + '" target="_blank" '
2010                         + 'rel="noopener noreferrer" class="text-break">'
2011                         + '<i class="fas fa-arrow-up-right-from-square me-1">'
2012                         + '</i>' + esc(tt.adresse) + '</a>'
2013                         + '<button class="btn btn-sm btn-outline-secondary py-0" '
2014                         + 'data-action="app-kopieren" '
2015                         + 'data-id="' + esc(tt.adresse) + '">'
2016                         + t('Adresse kopieren') + '</button>'
2017                         + '</div>'
2018                       : '');
2019            }
2020            return zeile;
2021        }).join('');
2022        /* ai-team-gesichter-v1: Das Gesicht steht an SEINEN Nachrichten,
2023           nicht an den eigenen - sonst sieht es aus, als spraechen zwei
2024           Fremde. Rein schmueckend, deshalb aria-hidden (der Name steht
2025           im Kopf der Akte). */
2026        return '<div class="d-flex mb-2 align-items-end gap-2 '
2027            + (eigen ? 'justify-content-end' : '') + '">'
2028            + (eigen ? '' : this._gesichtBild(m, 28))
2029            + '<div class="px-3 py-2" style="max-width:80%;border-radius:.75rem;'
2030            + (eigen ? 'background:#0d6efd;color:#fff' : 'background:#fff;border:1px solid #dee2e6')
2031            + '">'
2032            + (wz ? '<div class="mb-1">' + wz + '</div>' : '')
2033            + '<div style="white-space:pre-wrap" id="aiChatBlase' + i + '">'
2034            + (wartet ? this._chatPunkte() : esc(x.content || '')) + '</div>'
2035            /* ai-team-chatanhang-v1: Was mitgegangen ist, steht an der
2036               Nachricht - nicht als Fussnote darunter. Nach einem Neuladen
2037               kommt dieselbe Angabe aus dem gespeicherten Text (der Block,
2038               den der Server anhaengt); doppelt ist sie nie, weil diese
2039               Liste nur im laufenden Fenster existiert. */
2040            + ((x.anhaenge || []).length
2041                ? '<div class="mt-2 pt-2 small" style="border-top:1px solid '
2042                  + (eigen ? 'rgba(255,255,255,.35)' : '#dee2e6') + '">'
2043                  + x.anhaenge.map(a =>
2044                      '<div class="d-flex align-items-center gap-2">'
2045                      + '<i class="fas fa-paperclip"></i>'
2046                      + '<span class="text-break">' + esc(a.name || '') + '</span>'
2047                      + '<span style="opacity:.75">'
2048                      + esc(this._abGroesse(a.groesse)) + '</span></div>').join('')
2049                  + '</div>'
2050                : '')
2051            + '</div></div>';
2052    }
2053
2054    static async _chatFreigabe(approvalId, entscheidung) {
2055        // Ruft die BESTEHENDE Entscheidungs-Route - dieselbe, die der
2056        // Freigaben-Reiter nutzt. Keine zweite Zustaendigkeit.
2057        if (!approvalId) return;
2058        let grund = '';
2059        if (entscheidung === 'abgelehnt') {
2060            grund = (window.prompt(t('Warum lehnst du ab? Der Grund ist die Einarbeitung deines Mitarbeiters.')) || '').trim();
2061            if (!grund) return;            // Abbruch ist kein Ablehnen
2062        }
2063        const c = this._chatZustand();
2064        const treffer = [];
2065        (c.msgs || []).forEach(function (nachricht) {
2066            (nachricht.tools || []).forEach(function (tt) {
2067                if (String(tt.approval_id) === String(approvalId)) treffer.push(tt);
2068            });
2069        });
2070        treffer.forEach(function (tt) { tt.entschieden = '...'; });
2071        this._render();
2072        try {
2073            const r = await api('/api/ai-team/approvals/' + encodeURIComponent(approvalId)
2074                                + '/decide', 'POST',
2075                                { entscheidung: entscheidung, begruendung: grund });
2076            const a = (r || {}).ausfuehrung || {};
2077            let wirkung = t('Ausgeführt.');
2078            if (entscheidung === 'freigegeben' && a && a.ausgefuehrt === false) {
2079                // Ehrlich bleiben: freigegeben, aber nicht gewirkt.
2080                wirkung = t('Freigegeben, aber nicht ausgeführt') + ': '
2081                    + (a.grund || t('unbekannter Grund'));
2082            }
2083            /* ai-team-appurl-ui-v1: Das echte Werkzeug-Ergebnis kam schon
2084               immer mit (res.ausfuehrung.ergebnis) und wurde hier
2085               verworfen - genau deshalb bekam der Inhaber nie eine
2086               Adresse, obwohl die Anwendung laengst live war. */
2087            const adr = (a.ergebnis && (a.ergebnis.adresse || a.ergebnis.url))
2088                        || null;
2089            if (adr) wirkung = t('Veröffentlicht.');
2090            treffer.forEach(function (tt) {
2091                tt.entschieden = entscheidung; tt.wirkung = wirkung;
2092                if (adr) tt.adresse = adr;
2093            });
2094            this._render();
2095            // ai-team-stand-v1: nicht nur das Menue-Zeichen, sondern der
2096            // ganze Zahlenstand - das Handlungsband liest
2097            // stats.offene_freigaben und behielte sonst die alte Zahl.
2098            try { await this._zahlenStill(); this._render(); }
2099            catch (e2) { /* Nebenaufruf darf nichts kaputt machen */ }
2100        } catch (e) {
2101            treffer.forEach(function (tt) { tt.entschieden = null; });
2102            this._render();
2103            alert(t('Das hat nicht geklappt') + ': ' + (e.message || e));
2104        }
2105    }
2106
2107    // ---------- Reiter: Postfach ----------
2108    // =================================================================
2109    // ai-team-mail-ui-v1 — Postfach als vollwertiger Mail-Client
2110    //
2111    // Die Vorstufe war eine reine Leseliste. Verlangt war ein echtes
2112    // Postfach: Ordner, Suche, Nur-Ungelesen, Sammelaktionen, Verfassen,
2113    // Antworten, Weiterleiten, Signatur, Anhaenge, Seitenweise nachladen.
2114    //
2115    // WICHTIG ZUR GELESEN-MARKIERUNG: sie ist die gemeinsame Arbeitsliste
2116    // von Mensch und KI-Mitarbeiter — der Takt bearbeitet ungelesene
2117    // Nachrichten. Wer hier eine oeffnet, nimmt sie damit aus dessen
2118    // Liste. Das steht ausdruecklich in der Oberflaeche, und ueber
2119    // "als ungelesen" geht es zurueck.
2120    // =================================================================
2121
2122    static _pfZustand() {
2123        if (!this._pf) {
2124            this._pf = {
2125                ordner: 'posteingang', label: 'Posteingang', konto: '',
2126                mails: [], gesamt: 0, ungelesen: 0, offset: 0, limit: 30,
2127                /* pf-ordnerwechsel-v1: Die Zahl am Posteingang-Reiter MUSS
2128                   aus einem Posteingangs-Abruf stammen. Vorher zeigte sie
2129                   die Ungelesen-Zahl des GERADE geladenen Ordners - wer in
2130                   den Entwuerfen stand, sah dort die Zahl der ungelesenen
2131                   Entwuerfe (gemessen 15.09.: Reiter zeigte 3, im
2132                   Posteingang lagen 4). */
2133                ungelesenPost: 0,
2134                mehr: false, nurUngelesen: false, suche: '', imText: false,
2135                mail: null, auswahl: [], laden: false, fehler: null,
2136                ansicht: 'liste', entwurf: null, signatur: null, melden: null,
2137                /* ai-team-anlagenwahl-ui-v1 */
2138                anlagen: [], ablageListe: null, ablageFehler: null,
2139                ablageLaden: false, ablageSuche: '',
2140                /* ai-team-anlagen-weiter-ui-v1: Anlagen, die aus der
2141                   Ursprungsnachricht mitkommen. erbeAnlagen ist der
2142                   Schalter dafuer - wer sie wegnimmt, soll sie nicht
2143                   trotzdem mitschicken. */
2144                erbeAnlagen: true
2145            };
2146        }
2147        return this._pf;
2148    }
2149
2150    /* pf-frisch-v2: Selbststoppender Takt. Das Postfach wird GETEILT -
2151       der Inhaber loescht und sendet parallel in seinem eigenen Mailprogramm,
2152       der KI-Mitarbeiter legt im Hintergrund Entwuerfe an. Ohne Auffrischen
2153       zeigt die Liste den Stand von vor Minuten.
2154
2155       Der Takt haelt KEINEN eigenen Merker ausser der Kennung: er prueft bei
2156       jedem Schlag selbst, ob er noch laufen darf, und stoppt sich sonst. So
2157       kann er nicht zurueckbleiben, wenn jemand die Akte schliesst - genau die
2158       Falle, die im Softphone zu einem nie gestoppten Takt gefuehrt hat. */
2159    static _pfTaktStarten() {
2160        if (this._pfTakt) return;
2161        this._pfTakt = setInterval(() => {
2162            const weiter = this._pfTaktDarf();
2163            if (!weiter) { clearInterval(this._pfTakt); this._pfTakt = null; return; }
2164            this._pfLaden(false);
2165        }, 45000);
2166    }
2167
2168    static _pfTaktDarf() {
2169        /* Nur wenn die Liste WIRKLICH sichtbar ist: das Suchfeld gibt es nur
2170           in der Listenansicht des Postfachs. */
2171        if (!document.getElementById('pfSuche')) return false;
2172        if (this._akteTab !== 'postfach') return false;
2173        if (document.visibilityState === 'hidden') return false;
2174        const p = this._pf;
2175        if (!p || p.ansicht !== 'liste') return false;
2176        if (p.laden) return false;
2177        /* Nicht dazwischenfunken: eine getroffene Auswahl oder ein offenes
2178           Formular wuerde durch das Neuzeichnen verlorengehen. */
2179        if ((p.auswahl || []).length) return false;
2180        if (p.entwurf || p.signatur) return false;
2181        if (p.offset) return false;   /* nachgeladene Seiten nicht wegwerfen */
2182        return true;
2183    }
2184
2185    /* pf-ordnerwechsel-v1: "frisch" heisst: es wird ein ANDERER Bestand
2186       geholt (Ordner, Suche, Filter). Dann darf die alte Liste nicht
2187       stehenbleiben - sie gehoert einem anderen Ordner, und ihre Kennungen
2188       (uid) gelten dort auch. IMAP vergibt uids JE ORDNER: ein Klick auf
2189       eine stehengebliebene Posteingangs-Zeile, waehrend der Reiter schon
2190       "Entwuerfe" sagt, oeffnet oder loescht einen voellig anderen Eintrag.
2191       Beim Auffrischen desselben Ordners (Takt, "Neu laden") wird NICHT
2192       geleert - das waere nur Flackern. */
2193    static async _pfLaden(anhaengen, frisch) {
2194        const p = this._pfZustand();
2195        p.laden = true; p.fehler = null;
2196        if (!anhaengen) { p.offset = 0; p.auswahl = []; }
2197        if (frisch && !anhaengen) {
2198            p.mails = []; p.gesamt = 0; p.ungelesen = 0; p.mehr = false;
2199            p.hinweis = null;
2200        }
2201        this._render();
2202        const q = '?ordner=' + encodeURIComponent(p.ordner)
2203            + '&limit=' + p.limit + '&offset=' + p.offset
2204            + (p.nurUngelesen ? '&ungelesen=1' : '')
2205            + (p.suche ? '&suche=' + encodeURIComponent(p.suche) : '')
2206            + (p.imText ? '&im_text=1' : '');
2207        try {
2208            const d = await api('/api/ai-team/employees/'
2209                + encodeURIComponent(this._akteId) + '/mail/liste' + q);
2210            if (d && d.error) { p.fehler = d.error; p.mails = anhaengen ? p.mails : []; }
2211            else {
2212                p.mails = anhaengen ? p.mails.concat(d.mails || []) : (d.mails || []);
2213                p.gesamt = d.gesamt || 0; p.ungelesen = d.ungelesen || 0;
2214                /* pf-ordnerwechsel-v1 */
2215                if (p.ordner === 'posteingang') p.ungelesenPost = p.ungelesen;
2216                p.mehr = !!d.mehr; p.label = d.label || ''; p.konto = d.konto || '';
2217                p.hinweis = d.hinweis || null;
2218            }
2219        } catch (e) {
2220            p.fehler = 'Das Postfach ließ sich nicht laden.';
2221        }
2222        p.laden = false;
2223        this._render();
2224    }
2225
2226    static _pfOrdner(key) {
2227        const p = this._pfZustand();
2228        if (!this._pfEntwurfVerwerfen(true)) return;   /* pf-anlagen-fix-v1 */
2229        /* ai-team-pf-frisch-v1: Ein Klick auf den Ordner, in dem man schon
2230           steht, ist die natuerliche Geste fuer "nochmal nachsehen" -
2231           bisher passierte dabei gar nichts. Jetzt laedt er neu. */
2232        if (p.ordner === key && p.ansicht === 'liste') {
2233            if (!p.laden) this._pfLaden(false);
2234            return;
2235        }
2236        p.ordner = key; p.mail = null; p.ansicht = 'liste';
2237        p.suche = ''; p.offset = 0;
2238        this._pfLaden(false, true);   /* pf-ordnerwechsel-v1 */
2239    }
2240
2241    static _pfSuchen() {
2242        const p = this._pfZustand();
2243        if (!this._pfEntwurfVerwerfen(true)) return;   /* pf-anlagen-fix-v1 */
2244        const f = document.getElementById('pfSuche');
2245        const t = document.getElementById('pfImText');
2246        p.suche = f ? (f.value || '').trim() : '';
2247        p.imText = !!(t && t.checked);
2248        p.mail = null; p.ansicht = 'liste';
2249        this._pfLaden(false, true);   /* pf-ordnerwechsel-v1 */
2250    }
2251
2252    static _pfUngelesenNur() {
2253        const p = this._pfZustand();
2254        if (!this._pfEntwurfVerwerfen(true)) return;   /* pf-anlagen-fix-v1 */
2255        const c = document.getElementById('pfNurUngelesen');
2256        p.nurUngelesen = !!(c && c.checked);
2257        p.mail = null; p.ansicht = 'liste';
2258        this._pfLaden(false, true);   /* pf-ordnerwechsel-v1 */
2259    }
2260
2261    static _pfMehr() {
2262        const p = this._pfZustand();
2263        p.offset = p.offset + p.limit;
2264        this._pfLaden(true);
2265    }
2266
2267    static async _pfOeffnen(uid) {
2268        const p = this._pfZustand();
2269        p.laden = true; p.ansicht = 'mail'; p.melden = null;
2270        this._render();
2271        try {
2272            const d = await api('/api/ai-team/employees/'
2273                + encodeURIComponent(this._akteId) + '/mail/nachricht?ordner='
2274                + encodeURIComponent(p.ordner) + '&uid=' + encodeURIComponent(uid));
2275            if (d && d.error) {
2276                p.fehler = d.error; p.mail = null;
2277                /* pf-frisch-v2: Gibt es die Nachricht nicht mehr, reicht eine
2278                   Meldung nicht - der Eintrag blieb bisher in der Liste stehen
2279                   und liess sich weiter anklicken (gemessen am 27.08.: drei
2280                   Klicks auf denselben verschwundenen Entwurf). Also raus aus
2281                   der Liste, zurueck in die Uebersicht und frisch nachladen.
2282                   NUR beim ausdruecklichen Code 'weg' - bei einem Netzfehler
2283                   waere das Entfernen falsch. */
2284                if (d.code === 'weg') {
2285                    p.mails = (p.mails || []).filter(x => String(x.uid) !== String(uid));
2286                    p.auswahl = (p.auswahl || []).filter(x => String(x) !== String(uid));
2287                    p.ansicht = 'liste';
2288                    p.laden = false;
2289                    this._render();
2290                    this._pfLaden(false);
2291                    return;
2292                }
2293            }
2294            else {
2295                p.mail = d.mail || null;
2296                // Die Liste zieht mit: die Nachricht ist jetzt gelesen.
2297                const z = p.mails.find(x => String(x.uid) === String(uid));
2298                if (z && p.mail && p.mail.war_ungelesen) {
2299                    z.ungelesen = false;
2300                    p.ungelesen = Math.max(0, p.ungelesen - 1);
2301                    /* pf-ordnerwechsel-v1 */
2302                    if (p.ordner === 'posteingang') {
2303                        p.ungelesenPost = Math.max(0, p.ungelesenPost - 1);
2304                    }
2305                }
2306            }
2307        } catch (e) { p.fehler = 'Die Nachricht ließ sich nicht laden.'; }
2308        p.laden = false;
2309        this._render();
2310    }
2311
2312    static _pfZurueck() {
2313        const p = this._pfZustand();
2314        /* pf-anlagen-fix-v1: ohne Rueckfrage — der Knopf heisst "Verwerfen".
2315           Die gewaehlten Anlagen muessen aber mit weg, sonst haengen sie an
2316           der naechsten Nachricht. */
2317        this._pfEntwurfVerwerfen(false);
2318        p.mail = null; p.entwurf = null; p.signatur = null; p.ansicht = 'liste';
2319        this._render();
2320        /* pf-frisch-v2: Beim Zurueckgehen in die Liste frisch nachsehen.
2321           Zwischen dem Oeffnen einer Nachricht und der Rueckkehr koennen
2322           Minuten liegen; in dieser Zeit legt der KI-Mitarbeiter Entwuerfe an
2323           und raeumt sie weg, und der Inhaber arbeitet im selben Postfach mit
2324           seinem eigenen Mailprogramm. */
2325        if (!p.laden) this._pfLaden(false);
2326    }
2327
2328    static _pfWahl(uid) {
2329        const p = this._pfZustand();
2330        const i = p.auswahl.indexOf(String(uid));
2331        if (i >= 0) p.auswahl.splice(i, 1); else p.auswahl.push(String(uid));
2332        this._render();
2333    }
2334
2335    static _pfAlle() {
2336        const p = this._pfZustand();
2337        p.auswahl = p.auswahl.length === p.mails.length
2338            ? [] : p.mails.map(x => String(x.uid));
2339        this._render();
2340    }
2341
2342    static async _pfMarkieren(gelesen, einzeln) {
2343        const p = this._pfZustand();
2344        const uids = einzeln ? [String(einzeln)] : p.auswahl.slice();
2345        if (!uids.length) return;
2346        try {
2347            const d = await api('/api/ai-team/employees/'
2348                + encodeURIComponent(this._akteId) + '/mail/markieren', 'POST',
2349                { ordner: p.ordner, uids: uids, gelesen: !!gelesen });
2350            if (d && d.error) { p.melden = d.error; this._render(); return; }
2351            p.melden = gelesen
2352                ? uids.length + (uids.length === 1 ? ' Nachricht als gelesen markiert.'
2353                    : ' Nachrichten als gelesen markiert.')
2354                : uids.length + (uids.length === 1
2355                    ? ' Nachricht ist wieder ungelesen — ' + (this._akteName() || 'der Mitarbeiter') + ' nimmt sie sich wieder vor.'
2356                    : ' Nachrichten sind wieder ungelesen — ' + (this._akteName() || 'der Mitarbeiter') + ' nimmt sie sich wieder vor.');
2357            p.auswahl = [];
2358            if (einzeln) { p.mail = null; p.ansicht = 'liste'; }
2359            this._pfLaden(false);
2360        } catch (e) { p.melden = 'Die Markierung ließ sich nicht setzen.'; this._render(); }
2361    }
2362
2363    static async _pfLoeschen(einzeln) {
2364        const p = this._pfZustand();
2365        const uids = einzeln ? [String(einzeln)] : p.auswahl.slice();
2366        if (!uids.length) return;
2367        const wort = uids.length === 1 ? 'diese Nachricht' : uids.length + ' Nachrichten';
2368        if (!confirm('Sollen ' + wort + ' in den Papierkorb verschoben werden?')) return;
2369        try {
2370            const d = await api('/api/ai-team/employees/'
2371                + encodeURIComponent(this._akteId) + '/mail/loeschen', 'POST',
2372                { ordner: p.ordner, uids: uids });
2373            if (d && d.error) { p.melden = d.error; this._render(); return; }
2374            p.melden = (d.hinweis ? d.hinweis + ' ' : '')
2375                + (d.anzahl || 0) + ' in den Papierkorb verschoben.';
2376            p.auswahl = []; p.mail = null; p.ansicht = 'liste';
2377            this._pfLaden(false);
2378        } catch (e) { p.melden = 'Das Löschen wurde abgelehnt.'; this._render(); }
2379    }
2380
2381    static _pfVerfassen() {
2382        const p = this._pfZustand();
2383        /* pf-anlagen-fix-v1: Der Knopf ist auch waehrend des Verfassens da.
2384           Ohne Rueckfrage waere die halb getippte Nachricht weg. */
2385        if (!this._pfEntwurfVerwerfen(true)) return;
2386        this._pfEntwurfSetzen({ an: '', kopie: '', betreff: '', text: '', in_reply_to: null });
2387        p.ansicht = 'verfassen'; p.melden = null;
2388        this._render();
2389    }
2390
2391    static async _pfAntwort(art) {
2392        const p = this._pfZustand();
2393        if (!p.mail) return;
2394        /* pf-leerschutz-v1: Ein laufender Entwurf wurde hier ohne Rueckfrage
2395           ueberschrieben — anders als beim Knopf "Verfassen", der schon
2396           seit pf-anlagen-fix-v1 fragt. Gleiche Lage, gleiche Behandlung. */
2397        if (!this._pfEntwurfVerwerfen(true)) return;
2398        p.laden = true; this._render();
2399        try {
2400            const d = await api('/api/ai-team/employees/'
2401                + encodeURIComponent(this._akteId) + '/mail/vorlage?ordner='
2402                + encodeURIComponent(p.ordner) + '&uid=' + encodeURIComponent(p.mail.uid)
2403                + '&art=' + encodeURIComponent(art));
2404            if (d && d.error) { p.melden = d.error; }
2405            else {
2406                /* Zustands-Hygiene: sonst haengt die naechste Antwort die
2407                   Anlagen der vorigen mit an. pf-anlagen-fix-v1: das
2408                   erledigt jetzt _pfEntwurfSetzen an EINER Stelle. */
2409                this._pfEntwurfSetzen(d);
2410                p.ansicht = 'verfassen';
2411            }
2412        } catch (e) { p.melden = 'Die Vorlage ließ sich nicht laden.'; }
2413        p.laden = false;
2414        this._render();
2415    }
2416
2417    /* ═══ ai-team-anlagenwahl-ui-v1 / -v1b — Anlagen selbst waehlen ═══════════
2418       Zwei Wege, weil es zwei Situationen gibt: die Datei liegt schon in
2419       der Ablage (waehlen) oder sie liegt auf dem Rechner (hochladen).
2420       Der zweite legt sie in der Ablage ab und waehlt sie dann — so gibt
2421       es nur EINEN Ort, an dem Anlagen herkommen, und die Datei ist beim
2422       naechsten Mal wieder da. */
2423    static _pfAnlagenBlock(p) {
2424        const gewaehlt = p.anlagen || [];
2425        let h = '<div class="mb-3 p-2" style="background:#f7f9fc;'
2426            + 'border:1px solid #e3e6ec;border-radius:.4rem">'
2427            + '<div class="d-flex align-items-center gap-2 mb-1">'
2428            + '<span class="small fw-semibold"><i class="fas fa-paperclip me-1"></i>'
2429            + 'Anlagen</span>'
2430            + '<button class="btn btn-sm btn-outline-secondary ms-auto" '
2431            + 'data-action="pf-anlage-waehlen" type="button">Aus der Ablage</button>'
2432            + '<button class="btn btn-sm btn-outline-secondary" '
2433            + 'data-action="pf-anlage-hochladen" type="button">Datei hochladen</button>'
2434            + '</div>';
2435        /* ai-team-anlagen-weiter-ui-v1: Was aus der Ursprungsnachricht
2436           mitkommt, MUSS sichtbar sein. Sonst leitet jemand eine Mail
2437           weiter und weiss nicht, dass eine Rechnung mitgeht - oder er
2438           erwartet sie und sie fehlt. */
2439        const geerbt = (p.entwurf && p.entwurf.anhaenge) || [];
2440        if (geerbt.length) {
2441            /* ai-team-anlagen-weiter-ui-v1b: eigener, abgesetzter Kasten.
2442               Sonst steht der Abwaehl-Link unmittelbar ueber den SELBST
2443               gewaehlten Dateien und sieht aus, als gelte er auch fuer sie. */
2444            h += '<div class="mb-2 p-2" style="background:#fff;'
2445                + 'border:1px solid #e3e6ec;border-radius:.3rem">'
2446                + '<div class="text-muted mb-1" style="font-size:.72rem;'
2447                + 'text-transform:uppercase;letter-spacing:.03em">'
2448                + t(p.entwurf && p.entwurf.anhaenge_von
2449                    ? 'Aus der weitergeleiteten Nachricht'
2450                    : 'Aus dem bearbeiteten Entwurf') + '</div>'
2451                + '<ul class="list-unstyled mb-1 small">'
2452                + geerbt.map(a => '<li class="d-flex align-items-center gap-2 py-1'
2453                    + (p.erbeAnlagen ? '' : ' text-muted') + '">'
2454                    + '<i class="fas fa-file-import text-muted flex-shrink-0"></i>'
2455                    + '<span class="text-truncate" style="min-width:0'
2456                    + (p.erbeAnlagen ? '' : ';text-decoration:line-through') + '">'
2457                    + esc(a.name || 'Anlage') + '</span>'
2458                    + '<span class="text-muted flex-shrink-0" style="white-space:nowrap">'
2459                    + this._pfGroesse(a.groesse) + '</span>'
2460                    + '<span class="badge border flex-shrink-0 '
2461                    + (p.erbeAnlagen ? 'text-secondary' : 'text-danger') + '" '
2462                    + 'style="font-size:.66rem;background:#eef2f7">'
2463                    + t(p.erbeAnlagen ? 'geht mit' : 'geht NICHT mit') + '</span>'
2464                    + '<span class="ms-auto"></span></li>').join('')
2465                + '</ul>'
2466                + '<div class="mb-0"><button class="btn btn-sm btn-link p-0" '
2467                + 'style="font-size:.78rem" '
2468                + 'data-action="pf-anlage-erben" type="button">'
2469                + (p.erbeAnlagen
2470                    ? '<i class="fas fa-xmark me-1"></i>'
2471                      + t('Diese nicht mitschicken')
2472                    : '<i class="fas fa-rotate-left me-1"></i>'
2473                      + t('Doch mitschicken'))
2474                + '</button></div></div>';
2475        }
2476        if (gewaehlt.length) {
2477            h += '<ul class="list-unstyled mb-0 small">'
2478                /* Der Entfernen-Knopf gehoert an die Datei, nicht an den
2479                   rechten Fensterrand — sonst ist bei mehreren Anlagen nicht
2480                   zu sehen, welche er meint. */
2481                + gewaehlt.map((a, i) => '<li class="d-flex align-items-center gap-2 py-1">'
2482                    + '<i class="fas fa-file text-muted flex-shrink-0"></i>'
2483                    + '<span class="text-truncate" style="min-width:0" '
2484                    + 'title="' + esc(a.pfad || '') + '">'
2485                    + esc(a.name || a.pfad) + '</span>'
2486                    + '<span class="text-muted flex-shrink-0" style="white-space:nowrap">'
2487                    + this._pfGroesse(a.groesse) + '</span>'
2488                    + '<button class="btn btn-sm btn-link text-danger p-0 flex-shrink-0" '
2489                    + 'data-action="pf-anlage-weg" data-id="' + i + '" '
2490                    + 'type="button" title="Entfernen"><i class="fas fa-xmark"></i>'
2491                    + '</button>'
2492                    + '<span class="ms-auto"></span></li>').join('')
2493                + '</ul>';
2494        } else if (!geerbt.length) {
2495            h += '<div class="text-muted" style="font-size:.78rem">'
2496                + 'Noch keine Anlage. Angehängte Dateien liegen in der Ablage '
2497                + 'dieses Mitarbeiters.</div>';
2498        }
2499        return h + '</div>';
2500    }
2501
2502    /* ai-team-anlagen-weiter-ui-v1 */
2503    static _pfAnlageErben() {
2504        const p = this._pfZustand();
2505        p.erbeAnlagen = !p.erbeAnlagen;
2506        this._render();
2507    }
2508
2509    static _pfAnlageWeg(i) {
2510        const p = this._pfZustand();
2511        p.anlagen.splice(Number(i), 1);
2512        this._render();
2513    }
2514
2515    static async _pfAnlageWaehlen() {
2516        const p = this._pfZustand();
2517        p.ablageLaden = true; p.ablageFehler = null; p.ablageSuche = '';
2518        this._pfAnlagenDialog();
2519        try {
2520            const d = await api('/api/ai-team/employees/'
2521                + encodeURIComponent(this._akteId) + '/ablage/uebersicht');
2522            if (d && d.error) { p.ablageFehler = d.error; p.ablageListe = []; }
2523            else { p.ablageListe = d.dateien || []; p.ablageGekappt = !!d.gekappt; }
2524        } catch (e) {
2525            p.ablageFehler = 'Die Ablage ließ sich nicht laden.';
2526            p.ablageListe = [];
2527        }
2528        p.ablageLaden = false;
2529        this._pfAnlagenDialog();
2530    }
2531
2532    static _pfAnlagenDialog() {
2533        const p = this._pfZustand();
2534        const q = (p.ablageSuche || '').toLowerCase();
2535        const alle = p.ablageListe || [];
2536        const schon = new Set((p.anlagen || []).map(a => a.pfad));
2537        const treffer = q ? alle.filter(d => d.pfad.toLowerCase().includes(q)) : alle;
2538        let koerper;
2539        if (p.ablageLaden) {
2540            koerper = '<p class="text-muted mb-0">Einen Moment …</p>';
2541        } else if (p.ablageFehler) {
2542            koerper = '<div class="alert alert-warning mb-0">'
2543                + esc(p.ablageFehler) + '</div>';
2544        } else if (!alle.length) {
2545            koerper = '<p class="text-muted mb-0">In der Ablage liegt noch keine '
2546                + 'Datei. Legen Sie eine im Reiter „Dateien“ ab oder laden Sie '
2547                + 'hier eine hoch.</p>';
2548        } else {
2549            koerper = '<input class="form-control form-control-sm mb-2" '
2550                + 'id="pfAnlageSuche" placeholder="Datei suchen …" value="'
2551                + esc(p.ablageSuche || '') + '">'
2552                + (treffer.length
2553                    ? '<div style="max-height:46vh;overflow:auto">'
2554                      + '<ul class="list-unstyled mb-0">'
2555                      /* ai-team-anlagenwahl-ui-v1b: SCHRUMPFEN darf nur der
2556                         PFAD (min-width:0 + ellipsis). Ohne das schob ein
2557                         Pfad aus einem Unterordner den Anhaengen-Knopf aus
2558                         dem Dialog — genau die Dateien, fuer die diese
2559                         flache Liste gebaut wurde, waren nicht waehlbar. */
2560                      + treffer.map(d => '<li class="d-flex align-items-center gap-2 '
2561                          + 'py-1 border-bottom">'
2562                          + '<i class="fas fa-file text-muted flex-shrink-0"></i>'
2563                          + '<span class="small text-truncate" style="min-width:0" '
2564                          + 'title="' + esc(d.pfad) + '">' + esc(d.pfad) + '</span>'
2565                          + '<span class="small text-muted flex-shrink-0 ms-auto" '
2566                          + 'style="white-space:nowrap">'
2567                          + this._pfGroesse(d.groesse) + '</span>'
2568                          + (schon.has(d.pfad)
2569                              ? '<span class="badge bg-secondary flex-shrink-0">gewählt</span>'
2570                              : '<button class="btn btn-sm btn-outline-primary flex-shrink-0" '
2571                                + 'data-action="pf-anlage-nimm" data-id="'
2572                                + esc(d.pfad) + '" type="button">Anhängen</button>')
2573                          + '</li>').join('')
2574                      + '</ul></div>'
2575                    : '<p class="text-muted mb-0">Nichts gefunden.</p>')
2576                + (p.ablageGekappt
2577                    ? '<p class="text-muted mt-2 mb-0" style="font-size:.75rem">'
2578                      + 'Es werden die ersten 300 Dateien gezeigt.</p>' : '');
2579        }
2580        this._modal('<div class="modal-header"><h5 class="modal-title">'
2581            + 'Anlage aus der Ablage</h5>'
2582            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
2583            + '<div class="modal-body">' + koerper + '</div>'
2584            + '<div class="modal-footer">'
2585            + '<button class="btn btn-outline-secondary" '
2586            + 'data-action="pf-anlage-hochladen" type="button">Datei hochladen</button>'
2587            + '<button class="btn btn-secondary" data-action="modal-zu">Fertig</button>'
2588            + '</div>');
2589        const feld = document.getElementById('pfAnlageSuche');
2590        if (feld) {
2591            feld.oninput = () => {
2592                p.ablageSuche = feld.value;
2593                const pos = feld.selectionStart;
2594                this._pfAnlagenDialog();
2595                const neu = document.getElementById('pfAnlageSuche');
2596                if (neu) { neu.focus(); neu.setSelectionRange(pos, pos); }
2597            };
2598        }
2599    }
2600
2601    static _pfAnlageNimm(pfad) {
2602        const p = this._pfZustand();
2603        const d = (p.ablageListe || []).find(x => x.pfad === pfad);
2604        if (!d) return;
2605        if ((p.anlagen || []).some(a => a.pfad === pfad)) return;
2606        p.anlagen.push({ pfad: d.pfad, groesse: d.groesse,
2607                         name: d.pfad.split('/').pop() });
2608        /* pf-anlagen-fix-v1 — DER GEMELDETE FEHLER (09.09.): hier wurde nur
2609           der Dialog neu gezeichnet. Das Formular DAHINTER blieb, wie es
2610           war, und "Fertig" schliesst den Dialog ohne Neuzeichnen. Die
2611           angehaengte Datei war damit nirgends zu sehen — sie ging zwar
2612           mit, aber niemand konnte das wissen. */
2613        this._render();
2614        this._pfAnlagenDialog();
2615    }
2616
2617    /* Der Weg vom Rechner: erst in die Ablage, dann waehlen. Damit liegt
2618       jede versendete Anlage auch spaeter noch dort, wo man sie sucht. */
2619    static _pfAnlageHochladen() {
2620        const inp = document.createElement('input');
2621        inp.type = 'file'; inp.multiple = true;
2622        inp.onchange = () => this._pfAnlageUpload(Array.from(inp.files || []));
2623        inp.click();
2624    }
2625
2626    static async _pfAnlageUpload(dateien) {
2627        if (!dateien.length) return;
2628        const p = this._pfZustand();
2629        const fehler = [];
2630        for (const f of dateien) {
2631            const fd = new FormData();
2632            fd.append('datei', f);
2633            fd.append('ordner', 'Anlagen');
2634            const o = { method: 'POST', body: fd, headers: {} };
2635            try {
2636                const tok = await _token();
2637                if (tok) o.headers['Authorization'] = 'Bearer ' + tok;
2638            } catch (e) { /* egal */ }
2639            try {
2640                const r = await fetch('/api/ai-team/employees/'
2641                    + encodeURIComponent(this._akteId) + '/ablage/upload', o);
2642                const d = await r.json();
2643                if (!r.ok || !d.pfad) { fehler.push(f.name + ': ' + (d.error || r.status)); }
2644                else if (!(p.anlagen || []).some(a => a.pfad === d.pfad)) {
2645                    /* pf-anlagen-fix-v1: dieselbe Datei zweimal hochgeladen
2646                       haengte sie zweimal an — die Wahl aus der Ablage
2647                       schuetzt davor, dieser Weg nicht. */
2648                    p.anlagen.push({ pfad: d.pfad, name: d.name || f.name,
2649                                     groesse: f.size });
2650                }
2651            } catch (e) { fehler.push(f.name + ': ' + e.message); }
2652        }
2653        if (fehler.length) {
2654            p.melden = 'Nicht alles konnte angehängt werden: ' + fehler.join('; ');
2655        }
2656        /* Liste neu holen, damit die frisch abgelegte Datei auch im Dialog
2657           als „gewählt“ erscheint statt doppelt anhängbar zu sein. */
2658        p.ablageListe = null;
2659        this._modal('');
2660        this._render();
2661    }
2662
2663    /* ═══ pf-anlagen-fix-v1 — Formularzustand ════════════════════════════
2664       Das Verfassen-Formular haelt seinen Inhalt im DOM, der Rest des
2665       Postfachs im Zustandsobjekt. Jedes Neuzeichnen baut das Formular aus
2666       p.entwurf neu — wer vorher nicht sichert, wirft die Eingabe weg.
2667       Der Vorgaenger dieser Funktion (_pfEntwurfLesen) war genau dafuer
2668       gedacht, wurde aber nie aufgerufen.
2669
2670       Die laufende Nummer ist der Riegel dagegen, dass ein NEUER Entwurf
2671       mit den Werten des alten Formulars ueberschrieben wird: das DOM
2672       traegt die Nummer, zu der es gehoert. */
2673    static _pfFormularSichern() {
2674        const p = this._pf;
2675        if (!p || p.ansicht !== 'verfassen' || !p.entwurf) return;
2676        const f = document.getElementById('pfText');
2677        if (!f) return;                       /* Formular ist nicht im DOM */
2678        if (String(f.getAttribute('data-nr') || '') !== String(p.entwurfNr || 0)) return;
2679        const w = id => { const e = document.getElementById(id); return e ? e.value : null; };
2680        const an = w('pfAn'), kopie = w('pfKopie'), betreff = w('pfBetreff');
2681        if (an !== null) p.entwurf.an = an;
2682        if (kopie !== null) p.entwurf.kopie = kopie;
2683        if (betreff !== null) p.entwurf.betreff = betreff;
2684        p.entwurf.text = f.value;
2685    }
2686
2687    /* Ein Entwurf wird IMMER hierueber gesetzt: nur so werden die selbst
2688       gewaehlten Anlagen der vorigen Nachricht zuverlaessig mit ihm
2689       weggeraeumt und die Nummer weitergezaehlt. */
2690    static _pfEntwurfSetzen(e) {
2691        const p = this._pfZustand();
2692        p.entwurf = e || null;
2693        p.entwurfNr = (p.entwurfNr || 0) + 1;
2694        p.anlagen = [];
2695        p.erbeAnlagen = true;
2696    }
2697
2698    /* Ordnerwechsel, Suche und Filter haben ein halb getipptes Formular
2699       bisher STILL weggeworfen — und p.entwurf dabei stehenlassen, wodurch
2700       die 45-Sekunden-Auffrischung fuer den Rest der Sitzung ausblieb
2701       (_pfTaktDarf steigt bei gesetztem Entwurf aus). Beides hier behoben. */
2702    static _pfEntwurfVerwerfen(fragen) {
2703        const p = this._pfZustand();
2704        if (!p.entwurf) return true;
2705        this._pfFormularSichern();
2706        const e = p.entwurf;
2707        const belegt = !!(String(e.an || '').trim() || String(e.betreff || '').trim()
2708            || String(e.text || '').trim() || (p.anlagen || []).length);
2709        if (belegt && fragen
2710            && !confirm('Der begonnene Entwurf geht dabei verloren.\n\nTrotzdem fortfahren?')) {
2711            return false;
2712        }
2713        p.entwurf = null;
2714        p.anlagen = [];
2715        p.erbeAnlagen = true;
2716        return true;
2717    }
2718
2719    static async _pfSenden(alsEntwurf) {
2720        const p = this._pfZustand();
2721        /* pf-leerschutz-v1 (09.09.) — Fehlt ein Feld im DOM, wurde bisher
2722           STILL '' gesendet bzw. gespeichert; beim Ersetzen loeschte das
2723           den alten Entwurf gleich mit. Der letzte bekannte Stand aus
2724           p.entwurf ist der ehrlichere Rueckfall als Leere. */
2725        const q = p.entwurf || {};
2726        const e = (id, feld) => {
2727            const x = document.getElementById(id);
2728            if (x) return x.value;
2729            const w = feld ? q[feld] : null;
2730            return (w === undefined || w === null) ? '' : String(w);
2731        };
2732        const daten = {
2733            an: (e('pfAn', 'an') || '').trim(), kopie: (e('pfKopie', 'kopie') || '').trim(),
2734            betreff: (e('pfBetreff', 'betreff') || '').trim(), text: e('pfText', 'text') || '',
2735            in_reply_to: (p.entwurf && p.entwurf.in_reply_to) || null
2736        };
2737        /* Wird ein bestehender Entwurf bearbeitet, muss der alte weichen
2738           (sonst liegen zwei da) und seine Anlagen muessen mitkommen. */
2739        /* ai-team-anlagenwahl-ui-v1: selbst gewaehlte Anlagen als Ablage-
2740           Pfade mitgeben. Der Server laedt sie ueber dieselbe Pruefung wie
2741           fuer den KI-Mitarbeiter. */
2742        if (p.anlagen && p.anlagen.length) {
2743            daten.anhaenge = p.anlagen.map(a => a.pfad);
2744        }
2745        /* ai-team-anlagen-weiter-ui-v1: Beim WEITERLEITEN nennt die Vorlage
2746           die Ursprungsnachricht. Uebertragen wird die Herkunft, nicht die
2747           Datei - der Server holt sie frisch und prueft dabei die Grenzen. */
2748        const erbe = (p.entwurf && p.entwurf.anhaenge_von) || null;
2749        if (erbe && p.erbeAnlagen && (p.entwurf.anhaenge || []).length) {
2750            daten.anhaenge_von = erbe;
2751        }
2752        const alt_uid = (p.entwurf && p.entwurf.ersetzt_uid) || null;
2753        if (alt_uid) {
2754            /* pf-anlagen-fix-v1: "Diese nicht mitschicken" galt hier NICHT.
2755               Beim bearbeiteten Entwurf wurde die Herkunft bedingungslos
2756               gesetzt — die Anlage ging raus, obwohl die Oberflaeche sie
2757               durchgestrichen zeigte. Jetzt entscheidet derselbe Schalter
2758               wie beim Weiterleiten. entwurf_uid/ersetzt_uid bleiben davon
2759               unberuehrt: der alte Entwurf muss in jedem Fall weichen. */
2760            if (p.erbeAnlagen && ((p.entwurf.anhaenge || []).length)) {
2761                daten.anhaenge_von = alt_uid;
2762            } else if (daten.anhaenge_von === erbe) {
2763                delete daten.anhaenge_von;
2764            }
2765            if (alsEntwurf) daten.ersetzt_uid = alt_uid;
2766            else daten.entwurf_uid = alt_uid;
2767        }
2768        /* pf-leerschutz-v1 — DER DATENVERLUST VOM 09.09.: Eine Nachricht ohne
2769           Text und ohne Anlage ist fast immer ein Verlust, kein Wunsch. Beim
2770           Ablegen ersetzt sie einen vorhandenen Entwurf, und der alte wird
2771           serverseitig hart geloescht (IMAP \\Deleted + expunge) — genau so
2772           ist ein fertig geschriebener Entwurf spurlos verschwunden. */
2773        const leerInhalt = !String(daten.text || '').trim()
2774            && !((daten.anhaenge || []).length) && !daten.anhaenge_von;
2775        if (leerInhalt) {
2776            const frage = alsEntwurf
2777                ? (daten.ersetzt_uid
2778                    ? 'Dieser Entwurf hat weder Text noch Anlage.\n\nBeim Ablegen '
2779                      + 'wird der bisherige Entwurf ersetzt — sein Text ist dann '
2780                      + 'unwiderruflich weg.\n\nTrotzdem ablegen?'
2781                    : 'Dieser Entwurf hat weder Text noch Anlage.\n\nTrotzdem ablegen?')
2782                : 'Diese Nachricht hat weder Text noch Anlage.\n\nWirklich so versenden?';
2783            if (!confirm(frage)) { this._render(); return; }
2784        }
2785        if (!alsEntwurf && !daten.an) { p.melden = 'Es fehlt der Empfänger.'; this._render(); return; }
2786        if (!alsEntwurf && !confirm('Die Nachricht geht jetzt wirklich raus an: '
2787            + daten.an + '\n\nSenden?')) return;
2788        p.laden = true; p.melden = null; this._render();
2789        try {
2790            const weg = alsEntwurf ? 'entwurf' : 'senden';
2791            const d = await api('/api/ai-team/employees/'
2792                + encodeURIComponent(this._akteId) + '/mail/' + weg, 'POST', daten);
2793            if (d && d.error) { p.melden = d.error; }
2794            else {
2795                p.melden = alsEntwurf
2796                    ? 'Der Entwurf liegt im Entwürfe-Ordner.'
2797                    : 'Die Nachricht ist raus.' + (d.hinweis ? ' ' + d.hinweis : '');
2798                p.entwurf = null; p.mail = null; p.ansicht = 'liste';
2799                /* pf-anlagen-fix-v1: Ohne das Raeumen haengt die naechste
2800                   neu verfasste Nachricht die Anlage der vorigen an — an
2801                   einen ANDEREN Empfaenger. */
2802                p.anlagen = []; p.erbeAnlagen = true;
2803                p.ordner = alsEntwurf ? 'entwuerfe' : 'gesendet';
2804                p.laden = false;
2805                this._pfLaden(false, true);   /* pf-ordnerwechsel-v1 */
2806                return;
2807            }
2808        } catch (e2) { p.melden = 'Der Versand wurde abgelehnt.'; }
2809        p.laden = false;
2810        this._render();
2811    }
2812
2813    /* Schickt den Entwurf UNVERAENDERT ab — mit allen Anlagen. Der Weg
2814       ueber das Formular wuerde die Nachricht neu bauen und dabei jede
2815       PDF verlieren. */
2816    static async _pfEntwurfSenden(uid) {
2817        const p = this._pfZustand();
2818        const ziel = (p.mail && p.mail.an) || '';
2819        if (!confirm('Der Entwurf geht jetzt wirklich raus'
2820            + (ziel ? ' an: ' + ziel : '') + '.\n\nSenden?')) return;
2821        p.laden = true; p.melden = null; this._render();
2822        try {
2823            const d = await api('/api/ai-team/employees/'
2824                + encodeURIComponent(this._akteId) + '/mail/entwurf-senden',
2825                'POST', { uid: String(uid) });
2826            if (d && d.error) { p.melden = d.error; }
2827            else {
2828                p.melden = 'Die Nachricht ist raus'
2829                    + (d.anhaenge ? ' — mit ' + d.anhaenge + ' Anlage'
2830                                    + (d.anhaenge > 1 ? 'n' : '') : '')
2831                    + '.' + (d.hinweis ? ' ' + d.hinweis : '');
2832                p.mail = null; p.ansicht = 'liste'; p.ordner = 'gesendet';
2833                p.laden = false;
2834                this._pfLaden(false, true);   /* pf-ordnerwechsel-v1 */
2835                return;
2836            }
2837        } catch (e) { p.melden = 'Der Versand wurde abgelehnt.'; }
2838        p.laden = false;
2839        this._render();
2840    }
2841
2842    static async _pfSignatur() {
2843        const p = this._pfZustand();
2844        p.laden = true; this._render();
2845        try {
2846            const d = await api('/api/ai-team/employees/'
2847                + encodeURIComponent(this._akteId) + '/mail/signatur');
2848            p.signatur = (d && d.signatur) || '';
2849            p.ansicht = 'signatur';
2850        } catch (e) { p.melden = 'Die Signatur ließ sich nicht laden.'; }
2851        p.laden = false;
2852        this._render();
2853    }
2854
2855    static async _pfSignaturSpeichern() {
2856        const p = this._pfZustand();
2857        const f = document.getElementById('pfSigText');
2858        try {
2859            const d = await api('/api/ai-team/employees/'
2860                + encodeURIComponent(this._akteId) + '/mail/signatur', 'POST',
2861                { signatur: f ? f.value : '' });
2862            p.melden = (d && d.error) ? d.error : 'Die Signatur ist gespeichert.';
2863            if (!(d && d.error)) { p.signatur = null; p.ansicht = 'liste'; }
2864        } catch (e) { p.melden = 'Die Signatur ließ sich nicht speichern.'; }
2865        this._render();
2866    }
2867
2868    static _akteName() {
2869        const m = this._akteDaten && this._akteDaten.member;
2870        return m ? (m.name || '') : '';
2871    }
2872
2873    // ------------------------------------------------------------- Darstellung
2874
2875    static _pfFarbe(text) {
2876        const paare = [['#60a5fa', '#a78bfa'], ['#34d399', '#22d3ee'],
2877                       ['#fbbf24', '#f472b6'], ['#f472b6', '#8b5cf6'],
2878                       ['#22d3ee', '#3b82f6'], ['#a78bfa', '#f472b6'],
2879                       ['#8b5cf6', '#22d3ee']];
2880        let s = 0;
2881        const t = String(text || '');
2882        for (let i = 0; i < t.length; i++) s = (s + t.charCodeAt(i)) % 997;
2883        return paare[s % paare.length];
2884    }
2885
2886    static _pfInitialen(name) {
2887        const t = String(name || '').replace(/[^A-Za-zÄÖÜäöüß0-9 ]/g, ' ').trim();
2888        if (!t) return '?';
2889        const teile = t.split(/\s+/).filter(Boolean);
2890        if (teile.length >= 2) return (teile[0][0] + teile[1][0]).toUpperCase();
2891        return t.slice(0, 2).toUpperCase();
2892    }
2893
2894    static _pfAvatar(name) {
2895        const f = this._pfFarbe(name);
2896        return '<div class="ait-mav" style="background:linear-gradient(135deg,'
2897            + f[0] + ',' + f[1] + ')">' + esc(this._pfInitialen(name)) + '</div>';
2898    }
2899
2900    static _pfDatumLang(s) {
2901        if (!s) return '';
2902        try {
2903            const d = new Date(s);
2904            if (isNaN(d.getTime())) return String(s).slice(0, 40);
2905            return d.toLocaleString('de-DE', {
2906                weekday: 'short', day: '2-digit', month: '2-digit',
2907                year: 'numeric', hour: '2-digit', minute: '2-digit'
2908            }) + ' Uhr';
2909        } catch (e) { return String(s).slice(0, 40); }
2910    }
2911
2912    static _pfDatum(s) {
2913        if (!s) return '';
2914        try {
2915            const d = new Date(s);
2916            if (isNaN(d.getTime())) return String(s).slice(0, 16);
2917            const heute = new Date();
2918            const gleich = d.toDateString() === heute.toDateString();
2919            return gleich
2920                ? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
2921                : d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
2922        } catch (e) { return String(s).slice(0, 16); }
2923    }
2924
2925    static _pfGroesse(n) {
2926        const b = Number(n || 0);
2927        if (b < 1024) return b + ' B';
2928        if (b < 1024 * 1024) return Math.round(b / 1024) + ' KB';
2929        return (b / 1048576).toFixed(1).replace('.', ',') + ' MB';
2930    }
2931
2932    static _pfLeiste(p) {
2933        const tab = (k, t) => '<button class="btn btn-sm '
2934            + (p.ordner === k && p.ansicht !== 'verfassen' && p.ansicht !== 'signatur'
2935                ? 'btn-primary' : 'btn-outline-secondary')
2936            + '" data-action="pf-ordner" data-id="' + k + '">' + t
2937            + (k === 'posteingang' && p.ungelesenPost
2938                ? ' <span class="badge bg-light text-dark ms-1">' + p.ungelesenPost + '</span>' : '')
2939            + '</button>';
2940        return '<div class="d-flex flex-wrap align-items-center gap-2 mb-2">'
2941            + '<div class="btn-group btn-group-sm">'
2942            + tab('posteingang', 'Posteingang') + tab('entwuerfe', 'Entwürfe')
2943            + tab('gesendet', 'Gesendet') + '</div>'
2944            + '<div class="form-check form-check-inline mb-0 ms-1">'
2945            + '<input class="form-check-input" type="checkbox" id="pfNurUngelesen"'
2946            + (p.nurUngelesen ? ' checked' : '') + ' data-action="pf-ungelesen">'
2947            + '<label class="form-check-label small" for="pfNurUngelesen" '
2948            + 'style="white-space:nowrap">nur ungelesen</label></div>'
2949            + '<div class="ms-auto d-flex flex-wrap align-items-center gap-2">'
2950            + '<div class="input-group input-group-sm" style="width:260px">'
2951            + '<input type="search" class="form-control" id="pfSuche" placeholder="Suchen …" '
2952            + 'value="' + esc(p.suche || '') + '">'
2953            + '<button class="btn btn-outline-secondary" data-action="pf-suchen" '
2954            + 'title="Suchen"><i class="fas fa-search"></i></button></div>'
2955            + '<div class="form-check form-check-inline mb-0">'
2956            + '<input class="form-check-input" type="checkbox" id="pfImText"'
2957            + (p.imText ? ' checked' : '') + '>'
2958            + '<label class="form-check-label small" for="pfImText" '
2959            + 'style="white-space:nowrap" title="Auch im Nachrichtentext suchen (langsamer)">im Text</label></div>'
2960            + '<button class="btn btn-sm btn-outline-secondary" data-action="pf-signatur">'
2961            + '<i class="fas fa-pen me-1"></i>Signatur</button>'
2962            + '<button class="btn btn-sm btn-outline-secondary" data-action="pf-neuladen" '
2963            + 'title="Neu laden"><i class="fas fa-rotate"></i></button>'
2964            + '<button class="btn btn-sm btn-primary" data-action="pf-verfassen">'
2965            + '<i class="fas fa-feather me-1"></i>Verfassen</button>'
2966            + '</div></div>';
2967    }
2968
2969    static _pfSammelleiste(p) {
2970        if (!p.auswahl.length) return '';
2971        return '<div class="d-flex flex-wrap align-items-center gap-2 p-2 mb-2 rounded-3" '
2972            + 'style="background:#eef4ff;border:1px solid #cddbff">'
2973            + '<span class="fw-semibold small">' + p.auswahl.length
2974            + (p.auswahl.length === 1 ? ' Nachricht' : ' Nachrichten') + ' ausgewählt</span>'
2975            + '<button class="btn btn-sm btn-outline-secondary" data-action="pf-gelesen">'
2976            + 'Als gelesen</button>'
2977            + '<button class="btn btn-sm btn-outline-secondary" data-action="pf-ungelesen-setzen">'
2978            + 'Als ungelesen</button>'
2979            + '<button class="btn btn-sm btn-outline-danger" data-action="pf-loeschen">'
2980            + '<i class="fas fa-trash me-1"></i>Löschen</button>'
2981            + '<button class="btn btn-sm btn-link ms-auto" data-action="pf-wahl-alle">'
2982            + (p.auswahl.length === p.mails.length ? 'Auswahl aufheben' : 'Alle auswählen')
2983            + '</button></div>';
2984    }
2985
2986    static _pfListe(p) {
2987        if (!p.mails.length) {
2988            return '<div class="text-center text-muted py-5">'
2989                + (p.laden ? 'Wird geladen …'
2990                    : (p.suche ? 'Keine Treffer für „' + esc(p.suche) + '“.'
2991                        : (p.nurUngelesen ? 'Keine ungelesenen Nachrichten.'
2992                            : 'Keine Nachrichten in diesem Ordner.'))) + '</div>';
2993        }
2994        const self = this;
2995        const zeigtEmpfaenger = (p.ordner !== 'posteingang');
2996        const zeilen = p.mails.map(function (x) {
2997            const wer = zeigtEmpfaenger ? (x.an_name || x.an || '(kein Empfänger)')
2998                                        : (x.von_name || x.von || '');
2999            const gewaehlt = p.auswahl.indexOf(String(x.uid)) >= 0;
3000            return '<div class="ait-mrow' + (x.ungelesen ? ' ait-mrow-neu' : '')
3001                + (p.mail && String(p.mail.uid) === String(x.uid) ? ' ait-mrow-aktiv' : '')
3002                + '">'
3003                + '<input type="checkbox" class="form-check-input ait-mcb"'
3004                + (gewaehlt ? ' checked' : '') + ' data-action="pf-wahl" data-id="'
3005                + esc(x.uid) + '" title="Auswählen">'
3006                + '<div class="ait-mklick" data-action="pf-mail" data-id="' + esc(x.uid) + '">'
3007                + self._pfAvatar(wer)
3008                + '<div class="ait-mtext">'
3009                + '<div class="d-flex align-items-baseline gap-2">'
3010                + '<span class="ait-mwer' + (x.ungelesen ? ' fw-bold' : '') + '">'
3011                + esc(wer) + '</span>'
3012                + '<span class="ait-mdat ms-auto">' + esc(self._pfDatum(x.datum)) + '</span></div>'
3013                + '<div class="ait-mbet' + (x.ungelesen ? ' fw-semibold' : '') + '">'
3014                + (x.ungelesen ? '<span class="ait-mpunkt"></span>' : '')
3015                + (x.anhang ? '<i class="fas fa-paperclip text-muted me-1" '
3016                    + 'style="font-size:.75rem"></i>' : '')
3017                + esc(x.betreff) + '</div></div></div></div>';
3018        }).join('');
3019        const mehr = p.mehr
3020            ? '<div class="text-center py-2"><button class="btn btn-sm btn-outline-secondary" '
3021              + 'data-action="pf-mehr"' + (p.laden ? ' disabled' : '') + '>'
3022              + (p.laden ? 'Lädt …' : 'Mehr laden') + '</button>'
3023              + '<div class="text-muted mt-1" style="font-size:.78rem">'
3024              + p.mails.length + ' von ' + p.gesamt + '</div></div>'
3025            : '<div class="text-center text-muted py-2" style="font-size:.78rem">'
3026              + p.mails.length + ' von ' + p.gesamt + '</div>';
3027        return '<div class="ait-mliste">' + zeilen + mehr + '</div>';
3028    }
3029
3030    static _pfLeser(p) {
3031        if (p.ansicht === 'signatur') {
3032            return '<div class="p-3">'
3033                + '<div class="d-flex align-items-center mb-2">'
3034                + '<h6 class="mb-0">Signatur</h6>'
3035                + '<button class="btn btn-sm btn-link ms-auto" data-action="pf-zurueck">'
3036                + 'Schließen</button></div>'
3037                + '<p class="text-muted small">Wird an jede Nachricht angehängt, die von '
3038                + 'hier aus rausgeht — auch an die Entwürfe.</p>'
3039                + '<textarea class="form-control" id="pfSigText" rows="6">'
3040                + esc(p.signatur || '') + '</textarea>'
3041                + '<button class="btn btn-primary btn-sm mt-2" '
3042                + 'data-action="pf-sig-speichern">Speichern</button></div>';
3043        }
3044        if (p.ansicht === 'verfassen') {
3045            const e = p.entwurf || {};
3046            return '<div class="p-3">'
3047                + '<div class="d-flex align-items-center mb-2">'
3048                + '<h6 class="mb-0">' + (e.in_reply_to ? 'Antwort' : 'Neue Nachricht') + '</h6>'
3049                + '<button class="btn btn-sm btn-link ms-auto" data-action="pf-zurueck">'
3050                + 'Verwerfen</button></div>'
3051                + '<div class="mb-2"><label class="form-label small mb-1">An</label>'
3052                + '<input class="form-control form-control-sm" id="pfAn" value="'
3053                + esc(e.an || '') + '" placeholder="[email protected]"></div>'
3054                + '<div class="mb-2"><label class="form-label small mb-1">Kopie</label>'
3055                + '<input class="form-control form-control-sm" id="pfKopie" value="'
3056                + esc(e.kopie || '') + '" placeholder="optional"></div>'
3057                + '<div class="mb-2"><label class="form-label small mb-1">Betreff</label>'
3058                + '<input class="form-control form-control-sm" id="pfBetreff" value="'
3059                + esc(e.betreff || '') + '"></div>'
3060                + '<div class="mb-2"><label class="form-label small mb-1">Nachricht</label>'
3061                + '<textarea class="form-control" id="pfText" rows="12" '
3062                + 'data-nr="' + (p.entwurfNr || 0) + '">'
3063                + esc(e.text || '') + '</textarea></div>'
3064                /* pf-anlagen-fix-v1: Hier stand eine zweite Anzeige derselben
3065                   Dateien mit dem festen Text "bleibt erhalten" — auch dann,
3066                   wenn sie im Kasten darunter gerade durchgestrichen als
3067                   abgewaehlt standen. Zwei gegenteilige Aussagen ueber
3068                   dieselbe Anlage. Der Kasten zeigt beide Zustaende. */
3069                + this._pfAnlagenBlock(p)
3070                + '<div class="d-flex gap-2">'
3071                + '<button class="btn btn-primary btn-sm" data-action="pf-send"'
3072                + (p.laden ? ' disabled' : '') + '>'
3073                + '<i class="fas fa-paper-plane me-1"></i>Senden</button>'
3074                + '<button class="btn btn-outline-secondary btn-sm" data-action="pf-entwurf"'
3075                + (p.laden ? ' disabled' : '') + '>Als Entwurf ablegen</button></div>'
3076                + '<p class="text-muted mt-2 mb-0" style="font-size:.78rem">'
3077                + 'Die Nachricht geht unter der Adresse des Postfachs raus — '
3078                + 'nicht über eine Freigabe. Sie entscheiden hier selbst.</p></div>';
3079        }
3080        const m = p.mail;
3081        if (!m) {
3082            return '<div class="d-flex align-items-center justify-content-center '
3083                + 'text-muted h-100 p-4 text-center">'
3084                + (p.laden ? 'Wird geladen …' : 'Wählen Sie links eine Nachricht aus.')
3085                + '</div>';
3086        }
3087        /* ai-team-download-v1 — FEHLERBEHEBUNG: das war ein <a href>. Ein
3088           Verweis sendet keinen Authorization-Header, require_auth
3089           antwortet mit 401, der Anhang liess sich nicht oeffnen. Jetzt
3090           ueber fetch mit Anmeldung und Blob-Download. */
3091        const anh = (m.anhaenge || []).map(a =>
3092            '<button class="btn btn-sm btn-outline-secondary me-1 mb-1" '
3093            + 'data-action="pf-anhang" data-id="' + esc(String(a.teil))
3094            + '" data-arg="' + esc(a.name) + '">'
3095            + '<i class="fas fa-paperclip me-1"></i>' + esc(a.name)
3096            + ' <span class="text-muted">(' + this._pfGroesse(a.groesse) + ')</span></button>').join('');
3097        // HTML-Nachrichten laufen in einem abgeschotteten Rahmen (sandbox=""):
3098        // damit kann fremdes Skript aus einer Mail nichts in unserer App tun.
3099        // ai-team-mailhtml-v1: Traegt der Textteil selbst Auszeichnung (Absender
3100        // hat HTML als Klartext verschickt), zeigen wir sie gerendert statt roh.
3101        const rohHtml = m.html || (m.text_ist_html ? m.text : '');
3102        const koerper = rohHtml
3103            ? '<iframe class="ait-mhtml" sandbox="" srcdoc="' + esc(rohHtml) + '"></iframe>'
3104            : '<pre class="ait-mtxt">' + esc(m.text || '(kein Text)') + '</pre>';
3105        return '<div class="ait-mleser">'
3106            + '<div class="ait-mkopf">'
3107            + '<div class="d-flex align-items-start gap-2 mb-2">'
3108            + '<h6 class="mb-0 flex-grow-1">' + esc(m.betreff) + '</h6>'
3109            + '<button class="btn btn-sm btn-link p-0" data-action="pf-zurueck" '
3110            + 'title="Schließen"><i class="fas fa-xmark"></i></button></div>'
3111            + '<div class="small text-muted">'
3112            + '<div><strong>' + esc(m.von_name || m.von) + '</strong> '
3113            + (m.von_adresse ? '&lt;' + esc(m.von_adresse) + '&gt;' : '') + '</div>'
3114            + '<div>An: ' + esc(m.an || '—') + '</div>'
3115            + (m.kopie ? '<div>Kopie: ' + esc(m.kopie) + '</div>' : '')
3116            + '<div>' + esc(this._pfDatumLang(m.datum)) + '</div></div>'
3117            + (anh ? '<div class="mt-2">' + anh + '</div>' : '')
3118            /* ai-team-mailanhang-ui-v1 — FEHLERBEHEBUNG: In der Leseansicht
3119               standen immer dieselben Knoepfe. Bei einem ENTWURF hiess das:
3120               "Antworten" auf den eigenen, noch nicht verschickten Text —
3121               und kein Weg, ihn abzuschicken. Genau der gemeldete Fehler.
3122               Ein Entwurf bekommt deshalb seine eigenen Handlungen. */
3123            + '<div class="d-flex flex-wrap gap-2 mt-2">'
3124            + (p.ordner === 'entwuerfe'
3125                ? '<button class="btn btn-sm btn-primary" '
3126                  + 'data-action="pf-entwurf-senden" data-id="' + esc(m.uid) + '">'
3127                  + '<i class="fas fa-paper-plane me-1"></i>Jetzt senden</button>'
3128                  + '<button class="btn btn-sm btn-outline-secondary" '
3129                  + 'data-action="pf-bearbeiten">'
3130                  + '<i class="fas fa-pen me-1"></i>Bearbeiten</button>'
3131                : '<button class="btn btn-sm btn-primary" data-action="pf-antworten">'
3132                  + '<i class="fas fa-reply me-1"></i>Antworten</button>'
3133                  + '<button class="btn btn-sm btn-outline-secondary" data-action="pf-weiter">'
3134                  + '<i class="fas fa-share me-1"></i>Weiterleiten</button>'
3135                  + '<button class="btn btn-sm btn-outline-secondary" '
3136                  + 'data-action="pf-mail-ungelesen" '
3137                  + 'data-id="' + esc(m.uid) + '" title="Zurück in die Liste des Mitarbeiter
3137s">'
3138                  + 'Als ungelesen</button>')
3139            + '<button class="btn btn-sm btn-outline-danger ms-auto" '
3140            + 'data-action="pf-mail-loeschen" data-id="' + esc(m.uid) + '">'
3141            + '<i class="fas fa-trash me-1"></i>Löschen</button></div></div>'
3142            + '<div class="ait-mkoerper">' + koerper + '</div></div>';
3143    }
3144
3145    // Das Desktop-Modul arbeitet sonst mit Inline-Stilen. Fuer eine
3146    // zweispaltige Liste mit Bildlauf reicht das nicht — der Block wird
3147    // deshalb genau einmal eingehaengt und traegt einen eigenen
3148    // Namensraum (ait-m*), damit er nichts Bestehendes umfaerbt.
3149    static _pfStil() {
3150        if (document.getElementById('aitMailStil')) return;
3151        const s = document.createElement('style');
3152        s.id = 'aitMailStil';
3153        s.textContent = [
3154            '.ait-mbody{display:grid;grid-template-columns:minmax(320px,44%) 1fr;',  /* ai-team-mail-ui-v1b */
3155            'border:1px solid #e3e6ec;border-radius:.5rem;overflow:hidden;height:62vh;min-height:380px}',
3156            '.ait-mlinks{overflow:auto;border-right:1px solid #e3e6ec;background:#fafbfd}',
3157            '.ait-mrechts{overflow:auto;background:#fff}',
3158            '.ait-mrow{display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;',
3159            'border-bottom:1px solid #eef0f4}',
3160            '.ait-mrow:hover{background:#f1f4f9}',
3161            '.ait-mrow-neu{background:#fff}',
3162            '.ait-mrow-aktiv{background:#e8f0ff}',
3163            '.ait-mcb{flex-shrink:0;margin:0}',
3164            '.ait-ccb{flex-shrink:0;margin:0;cursor:pointer}',  /* ai-team-convdel-ui-v1 */
3165            '.ait-mklick{display:flex;align-items:center;gap:.55rem;flex:1;min-width:0;cursor:pointer}',
3166            '.ait-mav{width:34px;height:34px;border-radius:50%;color:#fff;display:flex;',
3167            'align-items:center;justify-content:center;font-size:.74rem;font-weight:700;flex-shrink:0}',
3168            '.ait-mtext{min-width:0;flex:1}',
3169            '.ait-mwer{font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}',
3170            '.ait-mdat{font-size:.73rem;color:#6c757d;flex-shrink:0;white-space:nowrap}',
3171            '.ait-mbet{font-size:.81rem;color:#4a5060;overflow:hidden;min-width:0;',
3172            'text-overflow:ellipsis;white-space:nowrap;display:flex;align-items:center}',
3173            '.ait-mpunkt{display:inline-block;width:7px;height:7px;border-radius:50%;',
3174            'background:#0d6efd;margin-right:.35rem;flex-shrink:0}',
3175            '.ait-mleser{display:flex;flex-direction:column;height:100%}',
3176            '.ait-mkopf{padding:.75rem .9rem;border-bottom:1px solid #eef0f4}',
3177            '.ait-mkoerper{padding:.9rem;overflow:auto;flex:1}',
3178            '.ait-mhtml{width:100%;height:46vh;border:0;background:#fff}',
3179            '.ait-mtxt{white-space:pre-wrap;word-break:break-word;font-family:inherit;',
3180            'font-size:.86rem;margin:0;color:#212529}',
3181            '@media (max-width:991px){.ait-mbody{grid-template-columns:1fr;height:auto}',
3182            '.ait-mlinks{border-right:0;border-bottom:1px solid #e3e6ec;max-height:52vh}',
3183            '.ait-m-leser .ait-mlinks{display:none}',
3184            '.ait-mbody:not(.ait-m-leser) .ait-mrechts{display:none}}'
3185        ].join('');
3186        document.head.appendChild(s);
3187        // Enter im Suchfeld. Haengt an derselben einmaligen Einrichtung wie
3188        // der Stil, damit kein zweiter Zuhoerer je Neuzeichnen entsteht
3189        // (die Stapel-Falle vom 10.08.).
3190        document.addEventListener('keydown', (e) => {
3191            if (!e || !e.target || e.target.id !== 'pfSuche') return;
3192            if (e.key !== 'Enter') return;
3193            e.preventDefault();
3194            this._pfSuchen();
3195        });
3196    }
3197
3198    static _akteTabPostfach(m) {
3199        this._pfStil();
3200        const p = this._pfZustand();
3201        const aus = (m.config || {}).ausstattung || {};
3202        if (!aus.postfach) {
3203            return '<div class="card"><div class="card-body text-center py-5">'
3204                + '<i class="fas fa-envelope text-muted mb-2" style="font-size:1.6rem"></i>'
3205                + '<p class="mb-1 fw-semibold">Mir ist noch kein Postfach zugewiesen.</p>'
3206                + '<p class="text-muted small mb-3">Richten Sie unter „Zugänge“ eines ein '
3207                + 'und weisen Sie es in den Einstellungen zu.</p>'
3208                + '<button class="btn btn-sm btn-primary" data-action="akte-reiter" '
3209                + 'data-id="einst">Zu den Einstellungen</button></div></div>';
3210        }
3211        if (!this._pfGeladen) { this._pfGeladen = true; setTimeout(() => this._pfLaden(false), 0); }
3212        this._pfTaktStarten();   /* pf-frisch-v2 */
3213        const melden = p.melden
3214            ? '<div class="alert alert-info py-2 px-3 small mb-2">' + esc(p.melden) + '</div>' : '';
3215        const fehler = p.fehler
3216            ? '<div class="alert alert-warning py-2 px-3 small mb-2">' + esc(p.fehler) + '</div>' : '';
3217        const hinweis = p.hinweis
3218            ? '<div class="alert alert-secondary py-2 px-3 small mb-2">' + esc(p.hinweis) + '</div>' : '';
3219        return '<div class="card"><div class="card-body">'
3220            + '<div class="d-flex align-items-baseline mb-2">'
3221            + '<h6 class="mb-0">Postfach</h6>'
3222            + '<span class="text-muted ms-2 small">' + esc(p.konto || '') + '</span></div>'
3223            + '<p class="text-muted mb-2" style="font-size:.8rem">'
3224            + 'Ungelesene Nachrichten sind die gemeinsame Arbeitsliste: '
3225            + esc(m.name || 'der Mitarbeiter')
3226            + ' bereitet nur zu ihnen Antworten vor. Wer hier eine öffnet, nimmt sie '
3227            + 'aus dieser Liste — über „Als ungelesen“ geht sie zurück.</p>'
3228            + melden + fehler + hinweis
3229            + this._pfLeiste(p)
3230            + this._pfSammelleiste(p)
3231            + '<div class="ait-mbody' + (p.ansicht !== 'liste' ? ' ait-m-leser' : '') + '">'
3232            + '<div class="ait-mlinks">' + this._pfListe(p) + '</div>'
3233            + '<div class="ait-mrechts">' + this._pfLeser(p) + '</div>'
3234            + '</div></div></div>';
3235    }
3236
3237    /* ================= aiteam-fortschritt-v1 — Fortschrittsanzeige =========
3238
3239       Michaels Report: "man versteht nicht richtig dass die KI noch
3240       arbeitet". Das stimmte, und der Grund war meiner: der Kreisel im
3241       Senden-Knopf war beim Einbau des Stopp-Knopfes (aiteam-abbruch-v1)
3242       ersatzlos entfallen. Statt ihn nur zurueckzuholen, sagt die Anzeige
3243       jetzt auch, WORAN gearbeitet wird - ein Kreisel allein beantwortet
3244       bei einem Lauf ueber Minuten nicht die eigentliche Frage, ob es
3245       vorangeht.
3246
3247       Alle Angaben stammen aus dem Datenstrom, KEINE ist geschaetzt:
3248       'start' -> denkt nach, 'tool_start' -> arbeitet mit <Werkzeug>,
3249       'tool_done' -> ein Arbeitsschritt mehr, 'text' -> schreibt. */
3250    static _chatPunkte() {
3251        /* Bootstrap-eigene Klassen statt eigener Keyframes: aiteam.css
3252           wird nur ueber index_v2.html geladen, Bootstrap ebenso - aber
3253           spinner-grow ist im Haus vielfach erprobt, eine selbst gebaute
3254           Animation waere die naechste Stelle, die ohne ihr CSS still
3255           unsichtbar bleibt (Lehre aus toast-headless-v1). */
3256        let h = '<span class="d-inline-flex align-items-center gap-1 text-muted">';
3257        for (let k = 0; k < 3; k++) {
3258            h += '<span class="spinner-grow spinner-grow-sm" '
3259               + 'style="width:.5rem;height:.5rem;animation-delay:'
3260               + (k * 0.18) + 's"></span>';
3261        }
3262        return h + '</span>';
3263    }
3264
3265    /* Was gerade passiert - in einem Satz, den ein Nicht-Techniker liest. */
3266    static _chatPhaseText(c) {
3267        if (c.abbruchLaeuft) return t('Wird abgebrochen …');
3268        /* aiteam-hintergrund-v1: Ehrlich sagen, was los ist. Ohne diese
3269           Zeile saehe ein weiterlaufender Durchgang wie ein Haenger aus. */
3270        if (c.getrennt) return t('Verbindung unterbrochen — arbeitet weiter …');
3271        if (c.phase === 'werkzeug') {
3272            return t('Arbeitet: {was}').replace('{was}', c.phaseText || t('Werkzeug'));
3273        }
3274        if (c.phase === 'schreiben') return t('Schreibt die Antwort …');
3275        if (c.phase === 'denken') return t('Denkt nach …');
3276        return t('Verbindung wird aufgebaut …');
3277    }
3278
3279    /* Dauer und Arbeitsschritte. Die Dauer laeuft NUR, solange der
3280       Datenstrom offen ist - reisst er ab, endet der Lauf und die Zeile
3281       verschwindet. Eine Uhr, die nach einem Abriss weiterliefe, waere
3282       eine Behauptung statt einer Messung. */
3283    static _chatStatusMeta(c) {
3284        const teile = [];
3285        if (c.startMs) {
3286            const s = Math.max(0, Math.round((Date.now() - c.startMs) / 1000));
3287            teile.push(s < 60
3288                ? t('seit {n} s').replace('{n}', s)
3289                : t('seit {m}:{s} min').replace('{m}', Math.floor(s / 60))
3290                    .replace('{s}', String(s % 60).padStart(2, '0')));
3291        }
3292        const n = c.schritte || 0;
3293        if (n > 0) {
3294            teile.push(n === 1 ? t('1 Arbeitsschritt')
3295                : t('{n} Arbeitsschritte').replace('{n}', n));
3296        }
3297        return teile.join(' · ');
3298    }
3299
3300    static _chatStatusZeile() {
3301        const c = this._chatZustand();
3302        if (!c.laeuft) return '';
3303        return '<div id="aiChatStatus" class="d-flex align-items-center gap-2 mt-2 '
3304            + 'px-3 py-2" role="status" aria-live="polite" '
3305            + 'style="background:#e7f1ff;border:1px solid #b6d4fe;'
3306            + 'border-radius:.5rem;color:#084298">'
3307            + '<span class="spinner-border spinner-border-sm flex-shrink-0"></span>'
3308            + '<span class="fw-semibold text-truncate" id="aiChatStatusText">'
3309            + esc(this._chatPhaseText(c)) + '</span>'
3310            + '<span class="ms-auto small flex-shrink-0" id="aiChatStatusMeta" '
3311            + 'style="opacity:.85">' + esc(this._chatStatusMeta(c)) + '</span>'
3312            + '</div>';
3313    }
3314
3315    /* IN PLACE, nicht ueber _render(): ein Neuaufbau waehrend des Laufs
3316       wuerde die einlaufende Antwort zerschneiden und den Entwurfstext
3317       im Eingabefeld anfassen. Findet sich die Stelle nicht (Ansicht
3318       gewechselt), passiert schweigend nichts - wie bei setzeText(). */
3319    static _chatStatusSetzen() {
3320        const c = this._chatZustand();
3321        const a = document.getElementById('aiChatStatusText');
3322        if (a) a.textContent = this._chatPhaseText(c);
3323        const b = document.getElementById('aiChatStatusMeta');
3324        if (b) b.textContent = this._chatStatusMeta(c);
3325    }
3326
3327    /* Die Uhr. EIN Zeitgeber fuer die ganze Klasse, vor dem Start immer
3328       erst der alte gestoppt: es kann nur ein sichtbares Gespraech geben,
3329       zwei Zeitgeber wuerden in dieselbe Stelle schreiben. */
3330    static _chatTickStart() {
3331        this._chatTickStop();
3332        this._chatTick = setInterval(() => {
3333            try { this._chatStatusSetzen(); } catch (e) { this._chatTickStop(); }
3334        }, 1000);
3335    }
3336
3337    static _chatTickStop() {
3338        if (this._chatTick) { clearInterval(this._chatTick); this._chatTick = null; }
3339    }
3340
3341    static _chatScroll() {
3342        const v = document.getElementById('aiChatVerlauf');
3343        if (v) v.scrollTop = v.scrollHeight;
3344    }
3345
3346    static _chatNeu() {
3347        const c = this._chatZustand();
3348        /* aiteam-mehrlauf-v1: Ein laufender Auftrag arbeitet weiter - nur
3349           dieses Fenster hoert ihm nicht mehr zu. Frueher blieb sein
3350           Strom angeschlossen und schrieb in das neue Gespraech. */
3351        this._chatLoesen(c);
3352        // Titel mit zuruecksetzen: sonst traegt das neue Gespraech den
3353        // Namen des vorigen und zwei Gespraeche heissen gleich.
3354        c.conv = null; c.msgs = []; c.titel = '';
3355        this._render();
3356        this._convLaufMarkenSetzen();
3357    }
3358
3359    // ---------- Titel (ai-team-convtitel-ui-v1) ----------
3360
3361    static _chatTitelFeld() {
3362        const f = document.getElementById('aiChatTitel');
3363        return f ? String(f.value || '').trim().slice(0, 120) : '';
3364    }
3365
3366    /** Speichert den Titel. Existiert das Gespraech noch nicht, wird er nur
3367        gemerkt und mit der ersten Nachricht mitgeschickt - ein leeres
3368        Gespraech gibt es serverseitig nicht, das anzulegen waere eine
3369        Karteileiche fuer jeden abgebrochenen Versuch. */
3370    static async _chatTitelSpeichern() {
3371        const c = this._chatZustand();
3372        const neu = this._chatTitelFeld();
3373        c.titel = neu;
3374        if (!c.conv) {
3375            this._hinweis(neu
3376                ? t('Der Titel wird mit der ersten Nachricht gespeichert.')
3377                : t('Ohne Titel bekommt das Gespräch den Anfang Ihrer ersten Frage als Namen.'));
3378            return;
3379        }
3380        if (!neu) { return this._hinweis(t('Bitte einen Titel angeben.')); }
3381        try {
3382            await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
3383                + '/conversations/' + encodeURIComponent(c.conv),
3384                { method: 'PUT', body: JSON.stringify({ titel: neu }) });
3385            const z = (this._chatListe || []).find(x => x.id === c.conv);
3386            if (z) z.titel = neu;
3387            this._render();
3388        } catch (e) { this._modalFehler(e); }
3389    }
3390
3391    static _hinweis(text) {
3392        const el = document.getElementById('aiChatTitelHinweis');
3393        if (el) { el.textContent = text; el.classList.remove('d-none'); }
3394    }
3395
3396    static _chatUmbenennenFragen(convId) {
3397        const x = (this._chatListe || []).find(c => c.id === convId) || {};
3398        this._modal('<div class="modal-header"><h5 class="modal-title">'
3399            + t('Gespräch umbenennen') + '</h5>'
3400            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
3401            + '<div class="modal-body">'
3402            + '<label class="form-label small">' + t('Titel') + '</label>'
3403            + '<input id="aiUmbenennen" class="form-control" maxlength="120" value="'
3404            + esc(x.titel || '') + '">'
3405            + '<div class="form-text">'
3406            + t('Ein sprechender Name macht das Gespräch später wiederauffindbar.')
3407            + '</div></div>'
3408            + '<div class="modal-footer"><button class="btn btn-secondary" '
3409            + 'data-action="modal-zu">' + t('Abbrechen') + '</button>'
3410            + '<button class="btn btn-primary" data-action="chat-umbenennen-ja" '
3411            + 'data-id="' + esc(convId) + '">' + t('Speichern') + '</button></div>');
3412        setTimeout(() => {
3413            const f = document.getElementById('aiUmbenennen');
3414            if (f) { try { f.focus(); f.select(); } catch (e) { /* egal */ } }
3415        }, 150);
3416    }
3417
3418    static async _chatUmbenennen(convId) {
3419        const f = document.getElementById('aiUmbenennen');
3420        const neu = f ? String(f.value || '').trim().slice(0, 120) : '';
3421        if (!neu) { return; }
3422        try {
3423            await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
3424                + '/conversations/' + encodeURIComponent(convId),
3425                { method: 'PUT', body: JSON.stringify({ titel: neu }) });
3426            const z = (this._chatListe || []).find(x => x.id === convId);
3427            if (z) z.titel = neu;
3428            const c = this._chatZustand();
3429            if (c.conv === convId) c.titel = neu;
3430            this._modal('');
3431            this._render();
3432        } catch (e) { this._modalFehler(e); }
3433    }
3434
3435    static async _chatLaden(convId, opt) {
3436        const id = this._akteId;
3437        opt = opt || {};
3438        /* aiteam-mehrlauf-v1: Laeuft das angeklickte Gespraech bereits in
3439           diesem Fenster, ist es schon zu sehen. Laeuft ein ANDERES, wird
3440           dieses Fenster davon geloest - der Lauf selbst arbeitet weiter. */
3441        const c0 = this._chatZustand();
3442        if (c0.laeuft && c0.conv === convId && !opt.ohneLauf) return;
3443        if (c0.laeuft || c0.laufId) this._chatLoesen(c0);
3444        try {
3445            const d = await api('/api/ai-team/employees/' + encodeURIComponent(id)
3446                + '/conversations/' + encodeURIComponent(convId));
3447            const c = this._chatZustand();
3448            if (this._akteId !== id) return;
3449            c.conv = convId;
3450            // Titel aus der Liste uebernehmen, damit die Titelzeile den
3451            // Namen des GEOEFFNETEN Gespraechs zeigt und nicht den alten.
3452            const z = (this._chatListe || []).find(x => x.id === convId);
3453            c.titel = (z && z.titel) || '';
3454            /* aiteam-stufen-v1: Die am Gespraech gespeicherte Wahl
3455               wiederherstellen. Ohne das zeigten die Auswahlfelder den
3456               Vorgabewert, waehrend der Lauf die gespeicherte Stufe
3457               benutzt - die Anzeige haette gelogen. */
3458            c.modellStufe = d.modell_stufe || '';
3459            c.tiefeStufe = d.tiefe_stufe || '';
3460            c.msgs = (d.messages || []).map(x => ({
3461                role: x.role, content: x.content,
3462                tools: (x.tool_calls || []).map(tc => ({
3463                    tool: tc.tool, label: tc.label || tc.tool, modus: tc.modus,
3464                    approval_id: tc.approval_id,
3465                    /* ai-team-convstatus-ui-v1: Stand aus ai_approvals */
3466                    entschieden: tc.entschieden,
3467                    /* ai-team-appurl-ui-v1: damit die Adresse einer
3468                       veroeffentlichten Anwendung ein Neuladen ueberlebt */
3469                    adresse: tc.adresse }))
3470            }));
3471            /* aiteam-mehrlauf-v1: Arbeitet der Mitarbeiter gerade an
3472               DIESEM Gespraech, gleich anhaengen - frisch vom Server
3473               gefragt, die Markierung in der Liste kann 15 s alt sein. */
3474            if (!opt.ohneLauf) {
3475                await this._akteLaeufeHolen();
3476                if (this._akteId !== id || c.conv !== convId) return;
3477                const o = this._laufFuerConv(convId);
3478                if (o) this._laufAnhaengen(o);
3479            }
3480            if (opt.stumm) return;
3481            this._modal('');
3482            this._render();
3483            this._chatScroll();
3484        } catch (e) { this._modalFehler(e); }
3485    }
3486
3487    // ai-team-akte-v1: nach dem ersten Wortwechsel bekommt das Gespraech
3488    // serverseitig einen Titel — ohne Auffrischen bliebe die Spalte links
3489    // leer, obwohl das Gespraech laengst gespeichert ist.
3490    /* aiteam-hintergrund-v1 — die Ereignisse eines Laufs lesen.
3491
3492       EINE Leseschleife fuer BEIDE Wege: den frisch gestarteten Lauf
3493       und den Wiederanschluss, nachdem der Reiter im Hintergrund war.
3494       Zwei getrennte Leser waeren zwei Auslegungen davon, wie ein
3495       Gespraech aussieht - und der Wiederanschluss ist der Weg, den
3496       niemand taeglich benutzt und an dem eine Abweichung deshalb
3497       lange unbemerkt bliebe. */
3498    static async _chatStromLesen(c, idx, body, setzeText) {
3499            const gen = c.gen || 0;      /* aiteam-mehrlauf-v1 */
3500            const leser = body.getReader();
3501            const dec = new TextDecoder();
3502            let rest = '';
3503            for (;;) {
3504                const stueck = await leser.read();
3505                /* aiteam-mehrlauf-v1: Das Fenster hat sich von diesem
3506                   Lauf geloest - nichts mehr anfassen. */
3507                if ((c.gen || 0) !== gen) {
3508                    try { leser.cancel(); } catch (err) { /* egal */ }
3509                    return;
3510                }
3511                if (stueck.done) break;
3512                rest += dec.decode(stueck.value, { stream: true });
3513                const zeilen = rest.split('\n');
3514                rest = zeilen.pop();
3515                for (const z of zeilen) {
3516                    if (z.indexOf('data: ') !== 0) continue;
3517                    let e;
3518                    try { e = JSON.parse(z.slice(6)); } catch (err) { continue; }
3519                    /* aiteam-hintergrund-v1: Jedes Ereignis ist EINE
3520                       Zeile in der Ereignisdatei des Laufs. Der
3521                       Zaehler ist damit die Stelle, ab der nach
3522                       einem Fensterwechsel weitergelesen wird. */
3523                    c.gesehen = (c.gesehen || 0) + 1;
3524                    if (e.type === 'start') {
3525                        c.conv = e.conversation_id || c.conv;
3526                        /* aiteam-abbruch-v1: Erst mit dieser Kennung kann
3527                           der Server den richtigen Durchlauf anhalten. Wer
3528                           vorher auf Stopp gedrueckt hat, wird JETZT
3529                           bedient - sonst liefe der Wunsch ins Leere. */
3530                        if (e.lauf_id) {
3531                            c.laufId = e.lauf_id;
3532                            /* aiteam-laufzaehler-v1: DER ZEIGER GEHOERT ZUM LAUF.
3533                               Das 'start' ist per Konstruktion Zeile 0 der
3534                               Ereignisdatei DIESES Laufs (hg.melden schreibt es
3535                               als erstes) - nach dem Lesen steht der Zeiger also
3536                               auf 1, unabhaengig davon, wie viele Ereignisse
3537                               fruehere Laeufe DESSELBEN Gespraechs hatten.
3538                               OHNE DIESE ZEILE zaehlte c.gesehen ueber alle
3539                               Laeufe eines Gespraechs weiter. Gemessen am
3540                               07.09.2026: Lauf A 23 Ereignisse + 6 aus Lauf B
3541                               = 29, waehrend Lauf B nur 10 Zeilen hat. Der
3542                               Wiederanschluss fragte ab Zeile 29 - die es in
3543                               dieser Datei nie geben wird. Ergebnis: leerer
3544                               Strom, das 'done' wurde nie gelesen, und die
3545                               Anzeige stand 16 Minuten auf "arbeitet weiter",
3546                               obwohl die Antwort laengst gespeichert war. */
3547                            c.gesehen = 1;
3548                            if (c.abbruchGewuenscht) {
3549                                /* aiteam-mehrlauf-v1: 'id' gab es in
3550                                   dieser Funktion nie - der fruehe
3551                                   Stopp-Wunsch warf deshalb einen
3552                                   ReferenceError. */
3553                                this._chatAbbruchSenden(this._akteId, c);
3554                            }
3555                        }
3556                        /* aiteam-stufen-v1: Der Server meldet, welche Stufen
3557                           er TATSAECHLICH benutzt. Das ist die einzige
3558                           Angabe, der man trauen kann - die Anzeige folgt
3559                           ihr, nicht umgekehrt. */
3560                        if (e.modell_stufe) c.modellStufe = e.modell_stufe;
3561                        if (e.tiefe_stufe) c.tiefeStufe = e.tiefe_stufe;
3562                        /* aiteam-fortschritt-v1: Ab jetzt laeuft der
3563                           Mitarbeiter wirklich - vorher stand die
3564                           Verbindung noch nicht. */
3565                        c.phase = 'denken';
3566                        this._chatStatusSetzen();
3567                    } else if (e.type === 'text') {
3568                        c.msgs[idx].content += (e.content || '');
3569                        setzeText(c.msgs[idx].content);
3570                        if (c.phase !== 'schreiben') {
3571                            c.phase = 'schreiben';
3572                            this._chatStatusSetzen();
3573                        }
3574                        this._chatScroll();
3575                    } else if (e.type === 'tool_start') {
3576                        /* aiteam-fortschritt-v1: Der Werkzeugname
3577                           stand bisher IM Antworttext und wurde beim
3578                           tool_done sofort wieder herausgeloescht -
3579                           er flackerte also. Jetzt steht er in der
3580                           Fortschrittszeile, wo er stehen bleibt,
3581                           solange das Werkzeug laeuft. */
3582                        c.phase = 'werkzeug';
3583                        c.phaseText = e.label || e.tool || '';
3584                        this._chatStatusSetzen();
3585                    } else if (e.type === 'tool_done') {
3586                        // ai-team-ui-freigabe-v1: approval_id wurde hier
3587                        // bisher verworfen - ohne sie kann die Oberflaeche
3588                        // nicht wissen, welche Freigabe gemeint ist.
3589                        c.msgs[idx].tools.push({ tool: e.tool, label: e.label,
3590                                                 modus: e.modus,
3591                                                 approval_id: e.approval_id });
3592                        setzeText(c.msgs[idx].content);
3593                        /* Ein erledigter Schritt ist der einzige harte
3594                           Beleg fuer Fortschritt - deshalb wird er
3595                           gezaehlt und angezeigt. Danach denkt das
3596                           Modell weiter. */
3597                        c.schritte = (c.schritte || 0) + 1;
3598                        c.phase = 'denken'; c.phaseText = '';
3599                        this._chatStatusSetzen();
3600                    } else if (e.type === 'done' || e.type === 'abgebrochen') {
3601                        c.fertig = true;
3602                    } else if (e.type === 'error') {
3603                        c.fertig = true;
3604                        c.msgs[idx].content += (c.msgs[idx].content ? '\n\n' : '')
3605                            + (e.content || '');
3606                        setzeText(c.msgs[idx].content);
3607                    }
3608                }
3609            }
3610    }
3611
3612    /* aiteam-hintergrund-v1 — nach einem Fensterwechsel wieder anhaengen.
3613
3614       Der Lauf arbeitet serverseitig weiter, auch wenn die Leitung weg
3615       ist. Hier holen wir ab, was in der Zwischenzeit geschehen ist -
3616       ab c.gesehen, damit nichts doppelt ankommt. */
3617    static async _chatWiederanschluss(c) {
3618        if (!c || !c.laufId || !c.getrennt || c.fertig) return;
3619        if (c.anschlussLaeuft) return;   /* nie zwei Leitungen zugleich */
3620        c.anschlussLaeuft = true;
3621        const gen = c.gen || 0;          /* aiteam-mehrlauf-v1 */
3622        const id = this._akteId;
3623        const kopf = {};
3624        try {
3625            const tok = await _token();
3626            if (tok) kopf['Authorization'] = 'Bearer ' + tok;
3627        } catch (err) { /* Anmeldung uebernimmt die App */ }
3628        try {
3629            const ctrl = (typeof AbortController !== 'undefined')
3630                ? new AbortController() : null;
3631            c.ctrl = ctrl;
3632            const r = await fetch('/api/ai-team/employees/'
3633                + encodeURIComponent(id) + '/chat/anschluss?lauf_id='
3634                + encodeURIComponent(c.laufId) + '&ab=' + (c.gesehen || 0),
3635                { headers: kopf, signal: ctrl ? ctrl.signal : undefined });
3636            if ((c.gen || 0) !== gen) return;
3637            if (r.status === 404) {
3638                /* Der Lauf liegt laenger zurueck als die Nachfrist und
3639                   ist abgeraeumt. Die Antwort steht dann im Verlauf; ein
3640                   Neuladen holt sie. Hier bleibt nur, ehrlich zu sein. */
3641                c.getrennt = false; c.fertig = true; c.laeuft = false;
3642                c.laufId = null; c.phase = ''; c.phaseText = '';
3643                this._chatTickStop();
3644                this._render();
3645                return;
3646            }
3647            if (!r.ok || !r.body) return;
3648            c.getrennt = false;
3649            this._chatStatusSetzen();
3650            const idx = c.msgs.length - 1;
3651            /* Dieselbe Schreibweise wie im Erstlauf: die Blase traegt
3652               die Nummer der Nachricht, textContent statt innerHTML. */
3653            const setzeText = (s) => {
3654                const z = document.getElementById('aiChatBlase' + idx);
3655                if (z) z.textContent = s;
3656            };
3657            const vorher = c.gesehen || 0;
3658            const t0 = Date.now();
3659            await this._chatStromLesen(c, idx, r.body, setzeText);
3660            if ((c.gen || 0) !== gen) return;
3661            if (c.fertig) {
3662                (c.erledigt = c.erledigt || []).push(c.laufId);
3663                c.laeuft = false; c.laufId = null;
3664                c.phase = ''; c.phaseText = '';
3665                this._chatTickStop();
3666                this._render();          /* Werkzeug-Abzeichen */
3667                this._chatScroll();
3668                this._akteLaeufeStand();  /* aiteam-mehrlauf-v1: Markierung weg */
3669            } else {
3670                c.getrennt = true;   /* wieder abgerissen - naechstes Mal */
3671                /* aiteam-ohnelimit-v1: Der Server gibt die Leitung nach
3672                   einer festen Zeit frei (gunicorn-Grenze). Der Strom
3673                   endet dann ohne Schlusszeichen - sofort neu anhaengen.
3674                   Die Sperre gegen Dauerschleifen: endet er FUENFMAL in
3675                   Folge sofort und ohne neues Ereignis, uebernimmt der
3676                   bisherige Ausloeser (Fenster wieder sichtbar). */
3677                c.leerAnschluss = (c.gesehen !== vorher || Date.now() - t0 > 60000)
3678                    ? 0 : (c.leerAnschluss || 0) + 1;
3679                if (c.leerAnschluss < 5) {
3680                    setTimeout(() => {
3681                        if ((c.gen || 0) === gen && c.laeuft && c.getrennt) {
3682                            this._chatWiederanschluss(c);
3683                        }
3684                    }, 400);
3685                }
3686            }
3687        } catch (err) {
3688            if ((c.gen || 0) === gen) c.getrennt = true;
3689        } finally {
3690            if ((c.gen || 0) === gen) {
3691                c.anschlussLaeuft = false;
3692                c.ctrl = null;
3693            }
3694        }
3695    }
3696
3697    /* aiteam-hintergrund-v1 — der Ausloeser fuer den Wiederanschluss.
3698
3699       Reiter gewechselt, Rechner zugeklappt, Netz kurz weg: kommt die
3700       Ansicht zurueck, hoeren wir wieder zu. Der Puls, den das Zuhoeren
3701       setzt, haelt zugleich den Deckel zurueck.
3702
3703       Der Merker am window verhindert, dass bei jedem Senden ein
3704       weiterer Empfaenger dazukommt. */
3705    static _hintergrundWache() {
3706        if (typeof window === 'undefined' || window.__aitHintergrundWache) return;
3707        window.__aitHintergrundWache = true;
3708        const wieder = () => {
3709            let c = null;
3710            try { c = this._chatZustand(); } catch (err) { return; }
3711            if (c && c.laeuft && c.getrennt) this._chatWiederanschluss(c);
3712        };
3713        document.addEventListener('visibilitychange', () => {
3714            if (document.visibilityState === 'visible') wieder();
3715        });
3716        window.addEventListener('online', wieder);
3717        window.addEventListener('focus', wieder);
3718    }
3719
3720    static async _chatSenden() {
3721        this._hintergrundWache();   // aiteam-hintergrund-v1
3722        const c = this._chatZustand();
3723        if (c.laeuft) return;
3724        const feld = document.getElementById('aiChatEingabe');
3725        const text = ((feld && feld.value) || '').trim();
3726        if (!text) return;
3727        const id = this._akteId;
3728        // Was im Titelfeld steht, gilt - auch wenn nicht auf Haken geklickt
3729        // wurde. Sonst geht ein eingetippter Titel beim Senden verloren.
3730        if (!c.conv) { c.titel = this._chatTitelFeld() || c.titel; }
3731
3732        /* ai-team-chatanhang-v1: Die Anlagen wandern JETZT aus dem
3733           Vorrat an die Nachricht. Nicht zuruecklegen, wenn das Senden
3734           scheitert: die Dateien liegen bereits in der Ablage, und ein
3735           zweites Mal anhaengen kostet einen Klick - sie doppelt im Bild
3736           zu haben, kostet Vertrauen. */
3737        const anh = (c.anhaenge || []).slice();
3738        c.anhaenge = [];
3739        c.anhMeldung = '';
3740        c.anhOk = '';   /* ablage-knopf-v1 */
3741        c.entwurf = '';
3742        c.msgs.push({ role: 'user', content: text, anhaenge: anh });
3743        c.msgs.push({ role: 'assistant', content: '', tools: [] });
3744        c.laeuft = true;
3745        /* aiteam-fortschritt-v1: Zustand der Anzeige zuruecksetzen,
3746           BEVOR gezeichnet wird - sonst traegt der neue Lauf die
3747           Schrittzahl des vorigen. */
3748        c.phase = 'verbinden'; c.phaseText = ''; c.schritte = 0;
3749        c.startMs = Date.now();
3750        this._render();
3751        this._chatScroll();
3752        // Erst NACH dem Zeichnen: vorher gibt es die Stelle nicht.
3753        this._chatTickStart();
3754
3755        const idx = c.msgs.length - 1;
3756        /* aiteam-abbruch-v1: Das Ziel wird bei JEDEM Schreiben neu
3757           gesucht statt einmal gemerkt. Sobald sich waehrend des Laufs
3758           irgendetwas neu aufbaut, waere das gemerkte Element abgehaengt
3759           und der Text liefe ins Leere - sichtbar als "die Antwort
3760           bleibt leer". */
3761        const setzeText = (s) => {
3762            const z = document.getElementById('aiChatBlase' + idx);
3763            if (z) z.textContent = s;
3764        };
3765
3766        /* aiteam-stufen-v1: VOR dem Neuzeichnen lesen waere falsch - die
3767           Felder existieren dann noch. Nach _render() sind sie neu
3768           aufgebaut und tragen den Zustandswert, also ist beides gleich;
3769           der Zustand ist der stabilere Weg. */
3770        const msSend = (document.getElementById('aiChatModellStufe') || {}).value
3771            || c.modellStufe || '';
3772        const tsSend = (document.getElementById('aiChatTiefeStufe') || {}).value
3773            || c.tiefeStufe || '';
3774
3775        /* aiteam-abbruch-v1: Der Abbruch geht in erster Linie an den
3776           SERVER - nur dort laesst sich der Durchlauf wirklich anhalten.
3777           Diese Notbremse ist die zweite Schicht fuer den Fall, dass der
3778           Abbruch-Aufruf selbst nicht durchkommt: dann wird wenigstens
3779           die Oberflaeche wieder frei. */
3780        /* aiteam-neustart-v1: fertig/getrennt gehoeren zum VORIGEN Lauf.
3781           Blieben sie stehen, schlug beim zweiten Senden die Pruefung
3782           "laufId && !fertig" fehl - die Oberflaeche zeigte wieder
3783           "Bitte erneut versuchen", obwohl die Antwort in Wahrheit
3784           entstand und gespeichert wurde, und der Wiederanschluss stieg
3785           sofort aus. Genau Michaels Fall vom 24.08. 16:15. */
3786        c.fertig = false;
3787        c.getrennt = false;
3788        c.laufId = null;
3789        c.abbruchLaeuft = false;
3790        c.abbruchGewuenscht = false;
3791        c.abbruchNotbremse = false;
3792        c.leerAnschluss = 0;
3793        const gen = c.gen || 0;      /* aiteam-mehrlauf-v1 */
3794        const ctrl = (typeof AbortController !== 'undefined')
3795            ? new AbortController() : null;
3796        c.ctrl = ctrl;
3797
3798        let tok = null;
3799        try { tok = await _token(); } catch (e) { /* App uebernimmt Anmeldung */ }
3800        const kopf = { 'Content-Type': 'application/json' };
3801        if (tok) kopf['Authorization'] = 'Bearer ' + tok;
3802
3803        try {
3804            const r = await fetch('/api/ai-team/employees/' + encodeURIComponent(id) + '/chat', {
3805                method: 'POST', headers: kopf,
3806                signal: ctrl ? ctrl.signal : undefined,
3807                body: JSON.stringify({
3808                    message: text,
3809                    conversation_id: c.conv || undefined,
3810                    // ai-team-chatanhang-v1: nur die PFADE - die Dateien
3811                    // liegen schon in der Ablage.
3812                    anhaenge: anh.length ? anh.map(a => a.pfad) : undefined,
3813                    // Nur beim ERSTEN Zug wirksam (Server ignoriert ihn sonst).
3814                    titel: (!c.conv && c.titel) ? c.titel : undefined,
3815                    /* aiteam-stufen-v1: Am Absendezeitpunkt ist das
3816                       Auswahlfeld die Wahrheit - auch wenn das
3817                       change-Ereignis nie feuerte (Tastaturbedienung,
3818                       Wiederherstellung durch den Browser). Wird nichts
3819                       gefunden, geht NICHTS mit: der Server behaelt dann
3820                       die am Gespraech gespeicherte Stufe. */
3821                    modell_stufe: msSend || undefined,
3822                    tiefe_stufe: tsSend || undefined })
3823            });
3824            if (!r.ok || !r.body) {
3825                let msg = t('Der Server hat unerwartet geantwortet.');
3826                try { const dd = await r.json(); if (dd && dd.error) msg = dd.error; }
3827                catch (err) { /* keine JSON-Antwort */ }
3828                if ((c.gen || 0) !== gen) return;
3829                c.msgs[idx].content = msg;
3830                c.laeuft = false; this._render(); return;
3831            }
3832            await this._chatStromLesen(c, idx, r.body, setzeText);
3833            /* aiteam-ohnelimit-v1: Endet der Strom ohne Schlusszeichen,
3834               hat der Server die Leitung freigegeben - der Lauf arbeitet
3835               weiter. Frueher kam das nicht vor und haette hier als
3836               "fertig" gegolten. */
3837            if ((c.gen || 0) === gen && c.laufId && !c.fertig) c.getrennt = true;
3838        } catch (e) {
3839            /* aiteam-mehrlauf-v1: Abgeloest (anderes Gespraech geoeffnet)
3840               ist kein Fehler - und dieser Lauf gehoert nicht mehr hierher. */
3841            if ((c.gen || 0) !== gen) return;
3842            /* aiteam-abbruch-v1: Die Notbremse ist KEINE Stoerung - sie
3843               sagt aber ehrlich, dass der Mitarbeiter im Hintergrund
3844               noch zu Ende arbeiten koennte. Alles andere waere eine
3845               Zusage, die wir nicht halten koennen. */
3846            if (c.abbruchNotbremse) {
3847                c.msgs[idx].content += (c.msgs[idx].content ? '\n\n' : '')
3848                    + t('Abgebrochen. Der Mitarbeiter arbeitet im Hintergrund '
3849                        + 'unter Umständen noch zu Ende.');
3850            } else if (c.laufId && !c.fertig) {
3851                /* aiteam-hintergrund-v1: KEIN Fehler. Der Mitarbeiter
3852                   arbeitet serverseitig weiter - nur unsere Leitung ist
3853                   weg. Frueher stand hier "Bitte erneut versuchen",
3854                   waehrend die Antwort in Wahrheit noch entstand und
3855                   danach spurlos verschwand. */
3856                c.getrennt = true;
3857            } else {
3858                c.msgs[idx].content = c.msgs[idx].content
3859                    || t('Die Verbindung wurde unterbrochen. Bitte erneut versuchen.');
3860            }
3861        }
3862        if ((c.gen || 0) !== gen) return;   /* aiteam-mehrlauf-v1 */
3863        if (c.getrennt) {
3864            /* Der Lauf ist NICHT zu Ende: der Knopf bleibt ein
3865               Stopp-Knopf, die Uhr laeuft weiter. Es fehlt nur die
3866               Leitung - und die holen wir uns sofort zurueck. */
3867            c.ctrl = null;
3868            this._chatStatusSetzen();
3869            this._chatWiederanschluss(c);
3870            return;
3871        }
3872        if (c.laufId) (c.erledigt = c.erledigt || []).push(c.laufId);
3873        c.laeuft = false;
3874        c.laufId = null;
3875        c.ctrl = null;
3876        c.abbruchLaeuft = false;
3877        c.abbruchGewuenscht = false;
3878        c.abbruchNotbremse = false;
3879        // aiteam-fortschritt-v1: Die Uhr endet mit dem Lauf - in JEDEM
3880        // Ausgang, auch nach Fehler und Abbruch (steht hinter try/catch).
3881        this._chatTickStop();
3882        c.phase = ''; c.phaseText = '';
3883        /* Erst JETZT neu rendern: die Werkzeug-Abzeichen sollen erscheinen
3884           und der Senden-Knopf wieder frei werden. Waehrend des Laufs
3885           haette ein Neuaufbau die Ausgabe zerschnitten. */
3886        this._render();
3887        this._chatScroll();
3888        /* ai-team-akte-v1: die Spalte links kennt ein frisch begonnenes
3889           Gespraech noch nicht - serverseitig bekommt es erst nach dem
3890           ersten Wortwechsel einen Titel. Bewusst OHNE await davor: der
3891           Nutzer soll nicht auf einen Nebenaufruf warten. */
3892        this._chatListeAuffrischen();
3893    }
3894
3895    /* aiteam-abbruch-v1 — den laufenden Durchlauf anhalten.
3896
3897       Michaels Vorgabe: "wenn der Senden-Knopf laeuft, muessen wir das
3898       abbrechen koennen".
3899
3900       WARUM NICHT EINFACH DIE VERBINDUNG WEGWERFEN: Der Mitarbeiter
3901       laeuft auf dem Server in einem eigenen Prozess. Wirft der Browser
3902       nur die Leitung weg, arbeitet dieser Prozess weiter, verbraucht
3903       Kontingent und kann noch Werkzeuge ausfuehren - eine E-Mail waere
3904       also raus, obwohl hier "abgebrochen" steht. Deshalb geht der
3905       Abbruch an den Server, und der halbe Satz bleibt stehen. */
3906    static async _chatAbbrechen() {
3907        const c = this._chatZustand();
3908        if (!c.laeuft || c.abbruchLaeuft) return;
3909        c.abbruchLaeuft = true;
3910        // aiteam-fortschritt-v1: Die Zeile sagt es mit - der Knopf
3911        // allein waere ein zweiter Ort fuer dieselbe Auskunft.
3912        this._chatStatusSetzen();
3913        // Rueckmeldung SOFORT und AM PLATZ - ein voller Neuaufbau wuerde
3914        // die einlaufende Antwort zerschneiden.
3915        this._chatKnopfBricht();
3916        if (!c.laufId) {
3917            // Der Lauf hat sich noch nicht gemeldet: es gibt drueben
3918            // noch nichts zu treffen. Der Wunsch wird gemerkt und
3919            // ausgefuehrt, sobald die Kennung eintrifft.
3920            c.abbruchGewuenscht = true;
3921            return;
3922        }
3923        await this._chatAbbruchSenden(this._akteId, c);
3924    }
3925
3926    static async _chatAbbruchSenden(id, c) {
3927        const lauf = c.laufId;
3928        c.abbruchGewuenscht = false;
3929        if (!lauf) return;
3930        let tok = null;
3931        try { tok = await _token(); } catch (e) { /* App uebernimmt */ }
3932        const kopf = { 'Content-Type': 'application/json' };
3933        if (tok) kopf['Authorization'] = 'Bearer ' + tok;
3934        try {
3935            const r = await fetch('/api/ai-team/employees/'
3936                                  + encodeURIComponent(id) + '/chat/abbruch', {
3937                method: 'POST', headers: kopf,
3938                body: JSON.stringify({ lauf_id: lauf })
3939            });
3940            if (!r.ok) throw new Error('HTTP ' + r.status);
3941            // Der Datenstrom endet gleich von selbst - mit dem, was der
3942            // Mitarbeiter bis hierher gesagt hat. Nicht selbst kappen.
3943        } catch (e) {
3944            c.abbruchNotbremse = true;
3945            try { if (c.ctrl) c.ctrl.abort(); } catch (e2) { /* egal */ }
3946        }
3947    }
3948
3949    static _chatKnopfBricht() {
3950        const b = document.getElementById('aiChatSenden');
3951        if (!b) return;
3952        b.disabled = true;
3953        b.removeAttribute('data-action');
3954        b.className = 'btn btn-secondary align-self-end flex-shrink-0';
3955        b.title = t('Wird abgebrochen …');
3956        b.setAttribute('aria-label', t('Wird abgebrochen …'));
3957        b.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
3958    }
3959
3960    static async _chatListeAuffrischen() {
3961        const vorher = JSON.stringify((this._chatListe || []).map(x => x.id + x.nachrichten));
3962        await this._chatListeLaden();
3963        /* Nur neu zeichnen, wenn sich wirklich etwas geaendert hat - ein
3964           unnoetiger Neuaufbau wuerde den Blattstand des Verlaufs
3965           zuruecksetzen. */
3966        if (JSON.stringify((this._chatListe || []).map(x =>
3966 x.id + x.nachrichten)) !== vorher
3967            && this._akteId && (this._akteTab || 'chat') === 'chat') {
3968            this._render();
3969            this._chatScroll();
3970        }
3971    }
3972
3973    // ------------------------------------------------- Spracheingabe (ai-team-mikro-v1)
3974    //
3975    // Diktieren statt Tippen. Michaels Auftrag: "mobil geht das einfach ueber
3976    // das Geraet" - die Geraetetastatur hat eine Diktiertaste, der Desktop
3977    // nicht. Also uebernimmt es die Seite selbst.
3978    //
3979    // Vorlage ist der KI-Assistent (index_v2.html::_v2ToggleMic). VIER
3980    // bewusste Abweichungen davon, jede einzeln begruendet:
3981    //
3982    //   1. KEIN Auto-Absenden. Die Vorlage sendet in onend automatisch,
3983    //      sobald eine Sprechpause die Erkennung beendet. Hier loest eine
3984    //      Nachricht WERKZEUGE aus (E-Mail, Kalender, Ablage) - ein
3985    //      versehentlich abgeschicktes Halbdiktat koennte also etwas TUN,
3986    //      nicht nur etwas fragen. Der Mensch drueckt selbst auf Senden.
3987    //   2. Sprache aus der Einstellung statt hart 'de-DE'. Alle neun
3988    //      Bestandsstellen im Haus stehen fest auf Deutsch; fuer einen
3989    //      spanischen Mandanten (Vialine) waere das unbrauchbar.
3990    //   3. Kein alert() bei fehlender Unterstuetzung, sondern: der Knopf
3991    //      erscheint dann gar nicht erst. Ein Knopf, der nur eine
3992    //      Fehlermeldung kann, ist schlechter als kein Knopf.
3993    //   4. Zustand am MODUL, nicht am Element. Die Karte wird bei jedem
3994    //      _render() neu gebaut; ein Flag am Element stirbt mit dem Element
3995    //      (Lehre praxis-modus-v3). Das Zielfeld wird bei JEDEM Ergebnis
3996    //      frisch geholt - ist es verschwunden, endet die Aufnahme, statt
3997    //      unsichtbar in ein totes Element zu schreiben.
3998
3999    // ai-team-mikro-v1b: Farbe von outline-secondary auf outline-primary
4000    // (gemessen: secondary = Rand rgb(227,232,238) auf weiss, praktisch
4001    // unsichtbar - dieselbe Klasse Fehler wie beim Auftrag-Knopf).
4002    static _mikro = null;   // { feldId, erk, fest }
4003
4004    // ai-team-mikro-v1c: ERKENNUNG MOEGLICHST AUF DEM GERAET.
4005    //
4006    // Ohne Zutun schickt der Browser das Audio an den Erkennungsdienst des
4007    // Herstellers. In diesem Chat koennen Patientennamen, Diagnosen und
4008    // Rufnummern vorkommen (mediOne) - das gehoert nicht ungefragt zu einem
4009    // Dritten. Seit Chrome 139 gibt es processLocally: dann verlaesst weder
4010    // Ton noch Text das Geraet.
4011    //
4012    // BEWUSST VORSICHTIG: processLocally=true LEHNT AB, wenn das Sprachpaket
4013    // fehlt - blind gesetzt waere die Taste bei vielen Nutzern tot. Deshalb
4014    // wird die Verfuegbarkeit EINMAL im Hintergrund geprueft und gemerkt;
4015    // beim Start wird nur gesetzt, was nachweislich geht. Kennt der Browser
4016    // die Pruefung nicht, bleibt alles beim Alten (Rueckfall auf den
4017    // Dienst des Herstellers, wie an den neun Bestandsstellen im Haus).
4018    static _mikroLokal = null;   // null = ungeprueft, true/false = Ergebnis
4019
4020    static _mikroLokalPruefen() {
4021        if (this._mikroLokal !== null) return;
4022        this._mikroLokal = false;   // bis das Gegenteil bewiesen ist
4023        try {
4024            const E = window.SpeechRecognition || window.webkitSpeechRecognition;
4025            if (!E || typeof E.available !== 'function') return;
4026            const p = E.available({ langs: [this._mikroSprache()], processLocally: true });
4027            if (!p || typeof p.then !== 'function') return;
4028            p.then((st) => { this._mikroLokal = (st === 'available'); })
4029             .catch(() => { this._mikroLokal = false; });
4030        } catch (e) { /* alter Browser - Rueckfall gilt */ }
4031    }
4032
4033    static _mikroDa() {
4034        return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
4035    }
4036
4037    static _mikroSprache() {
4038        // Language schreibt beides (language.js: localStorage + htmlRoot).
4039        // KEIN Import - diese Datei verbietet relative Importe (Kopfregel,
4040        // Anamnese-Lehre: ein fehlender Unter-Import macht das ganze Modul tot).
4041        var l = '';
4042        try { l = localStorage.getItem('userLanguage') || ''; } catch (e) { /* egal */ }
4043        if (!l) {
4044            try {
4045                var h = document.getElementById('htmlRoot');
4046                l = (h && h.getAttribute('lang')) || '';
4047            } catch (e) { /* egal */ }
4048        }
4049        return /^[a-z]{2}-[A-Z]{2}$/.test(l) ? l : 'de-DE';
4050    }
4051
4052    // feldId ist immer eine selbst vergebene Kennung (aiChatEingabe oder
4053    // aiAntw-<uuid>), nie ein Fremdwert - trotzdem durch esc(), weil der
4054    // Wert im Markup landet.
4055    /* ai-team-sendeknopf-v1: EINE Quelle fuer die Masse eines Knopfs, der
4056       nur ein Symbol traegt. Zwei Gruende, beide gemessen:
4057       (a) OHNE feste Masse faellt so ein Knopf auf 32x16 zusammen, sobald
4058           die Symbolschrift fehlt oder langsam laedt - genau der Befund,
4059           der frueher zur Beschriftung 'Senden' gefuehrt hat (B5).
4060       (b) Mikrofon und Senden muessen exakt gleich gross sein, sonst
4061           sieht die Zeile schief aus. Eine gemeinsame Quelle garantiert
4062           das; zwei eigene Auslegungen wuerden wieder auseinanderlaufen. */
4063    static _symbolKnopfStil() {
4064        /* ai-team-sendeknopf-v1b: EINE Groesse, keine Fallunterscheidung
4065           nach btn-sm. GEMESSEN: die Hausgestaltung macht btn-sm gar nicht
4066           niedriger - der Nachbarknopf "Verlauf" traegt btn-sm und ist
4067           38px hoch. Meine erste Fassung setzte fuer btn-sm 31px und stand
4068           damit 7px flacher als der Knopf daneben, also genau der schiefe
4069           Eindruck, der behoben werden sollte. */
4070        return 'style="width:44px;height:38px;padding:0;display:inline-flex;'
4071            + 'align-items:center;justify-content:center" ';
4072    }
4073
4074    static _mikroKnopf(feldId, klasse) {
4075        if (!this._mikroDa()) return '';
4076        this._mikroLokalPruefen();
4077        return '<button type="button" class="btn ' + (klasse || 'btn-outline-primary')
4078            + ' flex-shrink-0" ' + this._symbolKnopfStil()
4079            + 'id="aiMik-' + esc(feldId) + '" '
4080            + 'data-action="mikro" data-id="' + esc(feldId) + '" '
4081            + 'title="' + esc(t('Diktieren statt tippen')) + '" '
4082            + 'aria-label="' + esc(t('Diktieren statt tippen')) + '">'
4083            + '<i class="fas fa-microphone"></i></button>';
4084    }
4085
4086    static _mikroAnzeige(feldId, an) {
4087        const k = document.getElementById('aiMik-' + feldId);
4088        if (!k) return;
4089        // btn-danger wird ZUSAETZLICH gesetzt und die Ausgangsklasse nur
4090        // entfernt, wenn sie da war - der Knopf tritt an zwei Stellen mit
4091        // unterschiedlicher Groesse auf (btn-sm / normal).
4092        k.classList.toggle('btn-danger', !!an);
4093        k.classList.toggle('btn-outline-primary', !an);
4094        const txt = an ? t('Aufnahme beenden') : t('Diktieren statt tippen');
4095        k.title = txt;
4096        k.setAttribute('aria-label', txt);
4097        k.innerHTML = '<i class="fas fa-' + (an ? 'stop' : 'microphone') + '"></i>';
4098    }
4099
4100    static _mikroMeldung(feldId, text) {
4101        // Eigene Meldezeile statt _hinweis(): _hinweis schreibt in
4102        // #aiChatTitelHinweis, das es NUR im Chat gibt - am Auftrag waere
4103        // die Meldung unsichtbar verpufft.
4104        const el = document.getElementById('aiMikMsg-' + feldId);
4105        if (!el) return;
4106        el.textContent = text || '';
4107        el.classList.toggle('d-none', !text);
4108    }
4109
4110    static _mikroStopp() {
4111        const m = this._mikro;
4112        if (!m) return;
4113        this._mikro = null;
4114        try { m.erk.stop(); } catch (e) { /* egal */ }
4115        this._mikroAnzeige(m.feldId, false);
4116    }
4117
4118    static _mikroUm(feldId) {
4119        if (!feldId) return;
4120        // Zweiter Klick auf denselben Knopf beendet. Klick auf einen ANDEREN
4121        // wechselt - es darf nie zwei Aufnahmen gleichzeitig geben.
4122        if (this._mikro) {
4123            const war = this._mikro.feldId;
4124            this._mikroStopp();
4125            if (war === feldId) return;
4126        }
4127        const Erk = window.SpeechRecognition || window.webkitSpeechRecognition;
4128        if (!Erk) return;
4129        const feld = document.getElementById(feldId);
4130        if (!feld) return;
4131
4132        let erk;
4133        try { erk = new Erk(); } catch (e) { return; }
4134        erk.lang = this._mikroSprache();
4135        // ai-team-mikro-v1c: nur setzen, wenn nachweislich verfuegbar.
4136        if (this._mikroLokal === true) { try { erk.processLocally = true; } catch (e) { /* egal */ } }
4137        erk.interimResults = true;
4138        erk.continuous = true;
4139        erk.maxAlternatives = 1;
4140
4141        // Bereits Getipptes bleibt stehen, das Diktat haengt an.
4142        const basis = feld.value ? (feld.value.replace(/\s+$/, '') + ' ') : '';
4143        this._mikro = { feldId: feldId, erk: erk, fest: basis };
4144        this._mikroMeldung(feldId, '');
4145
4146        erk.onstart = () => {
4147            this._mikroAnzeige(feldId, true);
4148            this._mikroMeldung(feldId, t('Sprechen Sie …'));
4149        };
4150
4151        erk.onresult = (ev) => {
4152            const m = this._mikro;
4153            if (!m || m.feldId !== feldId) return;
4154            const f = document.getElementById(feldId);
4155            if (!f) { this._mikroStopp(); return; }
4156            let vorlaeufig = '';
4157            for (let i = ev.resultIndex; i < ev.results.length; i++) {
4158                if (ev.results[i].isFinal) m.fest += ev.results[i][0].transcript + ' ';
4159                else vorlaeufig += ev.results[i][0].transcript;
4160            }
4161            // Die Laengengrenze des Feldes gilt auch fuers Diktat - sonst
4162            // schneidet erst der Server ab.
4163            const max = Number(f.getAttribute('maxlength')) || 8000;
4164            f.value = (m.fest + vorlaeufig).slice(0, max);
4165            // chatlaenge-v1: auch beim Diktat sagen, wenn abgeschnitten wurde.
4166            if (feldId === 'aiChatEingabe') {
4167                f._aiGekuerzt = Math.max(0, (m.fest + vorlaeufig).length - max);
4168                this._chatZahlZeigen(f);
4169            }
4170        };
4171
4172        erk.onerror = (ev) => {
4173            const grund = (ev && ev.error) || '';
4174            this._mikroStopp();
4175            if (grund === 'not-allowed' || grund === 'service-not-allowed') {
4176                this._mikroMeldung(feldId, t('Der Browser lässt den Zugriff auf das Mikrofon nicht zu. Bitte in den Browser-Einstellungen für diese Seite erlauben.'));
4177            } else if (grund === 'no-speech') {
4178                this._mikroMeldung(feldId, t('Nichts gehört — bitte noch einmal.'));
4179            } else if (grund === 'audio-capture') {
4180                this._mikroMeldung(feldId, t('Kein Mikrofon gefunden.'));
4181            } else if (grund && grund !== 'aborted') {
4182                this._mikroMeldung(feldId, t('Die Spracherkennung wurde beendet.') + ' (' + grund + ')');
4183            }
4184        };
4185
4186        // Bewusst KEIN Auto-Absenden - siehe Kopfkommentar Punkt 1.
4187        erk.onend = () => {
4188            if (this._mikro && this._mikro.feldId === feldId) this._mikroStopp();
4189            this._mikroMeldung(feldId, '');
4190        };
4191
4192        try { erk.start(); } catch (e) { this._mikroStopp(); }
4193    }
4194
4195    /* ═══ ai-team-chatanhang-v1 — Dateien aus dem Gespraech ═════════════
4196       Michaels Auftrag 17.08.: "bei den chatboxen ... noch die Moeglichkeit
4197       Dateien hochzuladen und die dann im Dateiverzeichnis des jeweiligen
4198       Mitarbeiters dann auch abspeichern".
4199
4200       HOCHGELADEN WIRD SOFORT, nicht erst beim Senden. Damit ist das
4201       Abspeichern auch dann erledigt, wenn die Nachricht nie abgeht - und
4202       ein voller Speicher meldet sich, solange der Inhaber noch davorsitzt.
4203       Beim Senden gehen nur die Pfade mit. */
4204
4205    static _chatKlammerKnopf(gesperrt) {
4206        const c = this._chatZustand();
4207        const wort = t('Datei anhängen');
4208        return '<button class="btn btn-outline-secondary align-self-end flex-shrink-0" '
4209            + this._symbolKnopfStil()
4210            + 'data-action="chat-datei" title="' + esc(wort) + '" '
4211            + 'aria-label="' + esc(wort) + '"'
4212            + (gesperrt || c.laeuft || c.hochlaeuft ? ' disabled' : '') + '>'
4213            + (c.hochlaeuft
4214                ? '<span class="spinner-border spinner-border-sm"></span>'
4215                : '<i class="fas fa-paperclip"></i>')
4216            + '</button>';
4217    }
4218
4219    static _chatAnhangLeiste() {
4220        const c = this._chatZustand();
4221        const st = ((c.info || {}).ablage) || {};
4222        if (!(c.anhaenge || []).length && !c.hochlaeuft && !c.anhMeldung
4223            && !c.anhOk) return '';   /* ablage-knopf-v1 */
4224        let h = '<div class="border rounded bg-white p-2 mt-2">';
4225        if (c.hochlaeuft) {
4226            h += '<div class="small text-muted d-flex align-items-center gap-2">'
4227                + '<span class="spinner-border spinner-border-sm"></span>'
4228                + esc(t('Wird abgelegt …')) + '</div>';
4229        }
4230        if ((c.anhaenge || []).length) {
4231            h += '<div class="d-flex flex-wrap gap-2">'
4232                + c.anhaenge.map(a =>
4233                    '<span class="badge bg-light text-dark border d-inline-flex '
4234                    + 'align-items-center gap-2 py-2 fw-normal">'
4235                    + this._abIcon({ art: a.art })
4236                    + '<span class="text-truncate" style="max-width:220px">'
4237                    + esc(a.name || '') + '</span>'
4238                    + '<span class="text-muted">' + esc(this._abGroesse(a.groesse))
4239                    + '</span>'
4240                    + '<button type="button" class="btn-close" '
4241                    + 'style="font-size:.6rem" data-action="chat-anhang-weg" '
4242                    + 'data-id="' + esc(a.pfad) + '" '
4243                    + 'title="' + esc(t('Von der Nachricht nehmen')) + '"></button>'
4244                    + '</span>').join('')
4245                + '</div>'
4246                /* Der Satz ist wichtig: der Inhaber soll wissen, dass das
4247                   Wegnehmen die Datei NICHT loescht - sie liegt schon in
4248                   der Ablage, und dort soll sie ja auch bleiben. */
4249                + '<div class="form-text mt-1">'
4250                + esc(t('Schon gespeichert — im Reiter „Dateien“ unter „{o}“. '
4251                        + 'Wegnehmen löscht die Datei nicht.')
4252                      .replace('{o}', st.ordner || 'Chat'))
4253                + '</div>';
4254        }
4255        if (c.anhMeldung) {
4256            h += '<div class="alert alert-warning small py-2 px-2 mt-2 mb-0">'
4257                + esc(c.anhMeldung)
4258                + (c.anhGrund === 'keine_ablage'
4259                    ? '<div class="mt-2"><button class="btn btn-sm btn-primary" '
4260                      + 'data-action="chat-ablage-einrichten">'
4261                      + '<i class="fas fa-plus me-1"></i>'
4262                      + esc(t('Ablage einrichten')) + '</button></div>'
4263                    : '')
4264                + '</div>';
4265        }
4266        /* ablage-knopf-v1 */
4267        if (c.anhOk) {
4268            h += '<div class="alert alert-success small py-2 px-2 mt-2 mb-0">'
4269                + '<i class="fas fa-check me-1"></i>' + esc(c.anhOk) + '</div>';
4270        }
4271        return h + '</div>';
4272    }
4273
4274    static _chatEntwurfMerken() {
4275        const feld = document.getElementById('aiChatEingabe');
4276        if (feld) this._chatZustand().entwurf = feld.value;
4277    }
4278
4279    static _chatDateiWaehlen() {
4280        const c = this._chatZustand();
4281        const st = ((c.info || {}).ablage) || {};
4282        /* Erst pruefen, dann fragen: eine Dateiauswahl, die anschliessend
4283           an "keine Ablage" scheitert, ist ein Umweg mit Enttaeuschung am
4284           Ende. Im Zweifel (kein Stand bekannt) wird geoeffnet - dann
4285           meldet der Server den echten Grund. */
4286        if (st.bereit === false) {
4287            this._chatEntwurfMerken();
4288            c.anhMeldung = st.hinweis
4289                || t('Diesem Mitarbeiter ist keine Ablage zugewiesen.');
4290            c.anhGrund = st.grund || 'keine_ablage';
4291            c.anhOk = '';   /* ablage-knopf-v1 */
4292            this._render();
4293            return;
4294        }
4295        const inp = document.createElement('input');
4296        inp.type = 'file'; inp.multiple = true;
4297        inp.onchange = () => this._chatDateienHochladen(Array.from(inp.files || []));
4298        inp.click();
4299    }
4300
4301    static async _chatDateienHochladen(dateien) {
4302        if (!dateien.length) return;
4303        const c = this._chatZustand();
4304        const st = ((c.info || {}).ablage) || {};
4305        const max = Number(st.max || 5);
4306        const fehler = [];
4307        const frei = Math.max(0, max - (c.anhaenge || []).length);
4308        this._chatEntwurfMerken();
4309        if (!frei) {
4310            c.anhMeldung = t('An eine Nachricht passen höchstens {n} Dateien.')
4311                .replace('{n}', max);
4312            this._render();
4313            return;
4314        }
4315        if (dateien.length > frei) {
4316            /* Kappen, aber SAGEN. Stilles Weglassen waere der Fehler, den
4317               der Inhaber erst bemerkt, wenn die Antwort unvollstaendig
4318               ist. */
4319            fehler.push(t('Nur {n} Dateien passen noch an diese Nachricht.')
4320                .replace('{n}', frei));
4321            dateien = dateien.slice(0, frei);
4322        }
4323        c.hochlaeuft = true; c.anhMeldung = ''; c.anhGrund = '';
4324        c.anhOk = '';   /* ablage-knopf-v1 */
4325        this._render();
4326        for (const f of dateien) {
4327            const fd = new FormData();
4328            fd.append('datei', f);
4329            const o = { method: 'POST', body: fd, headers: {} };
4330            try {
4331                const tok = await _token();
4332                if (tok) o.headers['Authorization'] = 'Bearer ' + tok;
4333            } catch (e) { /* die App uebernimmt die Anmeldung */ }
4334            try {
4335                const r = await fetch('/api/ai-team/employees/'
4336                    + encodeURIComponent(this._akteId) + '/chat/upload', o);
4337                let d = {};
4338                try { d = await r.json(); } catch (err) { /* keine JSON-Antwort */ }
4339                if (!r.ok || !d.pfad) {
4340                    fehler.push((f.name || '') + ': ' + (d.error || r.status));
4341                    if (d.grund) c.anhGrund = d.grund;
4342                } else {
4343                    c.anhaenge.push({ pfad: d.pfad, name: d.name || f.name,
4344                                      groesse: d.groesse || f.size,
4345                                      art: d.art || 'datei',
4346                                      lesbar: d.lesbar !== false });
4347                }
4348            } catch (e) { fehler.push((f.name || '') + ': ' + e.message); }
4349        }
4350        c.hochlaeuft = false;
4351        c.anhMeldung = fehler.join(' · ');
4352        /* Der Reiter "Dateien" haelt seinen Stand ueber den Reiterwechsel
4353           hinweg. Ohne dieses Zuruecksetzen fehlte die eben abgelegte Datei
4354           dort bis zum naechsten Neuladen der Seite. */
4355        if (this.__ab) { this.__ab.daten = null; this.__ab.fehler = null; }
4356        this._render();
4357    }
4358
4359    static _chatAnhangWeg(pfad) {
4360        const c = this._chatZustand();
4361        this._chatEntwurfMerken();
4362        c.anhaenge = (c.anhaenge || []).filter(a => a.pfad !== pfad);
4363        this._render();
4364    }
4365
4366    static async _chatAblageEinrichten() {
4367        const c = this._chatZustand();
4368        this._chatEntwurfMerken();
4369        try {
4370            await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
4371                + '/ablage/einrichten', 'POST', {});
4372        } catch (e) { return this._modalFehler(e); }
4373        /* Der Chat-Stand muss NEU geholt werden: er traegt die Auskunft,
4374           ob die Bueroklammer irgendwohin fuehrt - und mit der Ablage
4375           bekommt der Mitarbeiter ausserdem seine Ablage-Werkzeuge. */
4376        try {
4377            c.info = await api('/api/ai-team/employees/'
4378                + encodeURIComponent(this._akteId) + '/chatinfo');
4379        } catch (e) { /* die Bueroklammer versucht es dann eben direkt */ }
4380        c.anhMeldung = ''; c.anhGrund = '';
4381        /* ablage-knopf-v1: eine ausdrueckliche Bestaetigung. Wuerde der
4382           Hinweis nur verschwinden, waere nicht unterscheidbar, ob etwas
4383           passiert ist oder ob der Knopf ihn nur weggeklickt hat. */
4384        c.anhOk = t('Ablage eingerichtet — Sie können jetzt Dateien anhängen.');
4385        if (this.__ab) { this.__ab.daten = null; this.__ab.fehler = null; }
4386        await this._akteAusstattungLaden();
4387        /* ablage-knopf-v1: OHNE dieses Neuzeichnen blieb der Warnhinweis
4388           samt Knopf unveraendert stehen. Der Klick WIRKTE (Server 201,
4389           Ablage zugewiesen), sah aber aus wie "der Knopf tut nichts" —
4390           Michaels Befund vom 28.08. _akteAusstattungLaden() laedt nur
4391           Daten nach und zeichnet bewusst nicht. */
4392        this._render();
4393    }
4394
4395    static _chatStufenBinden() {
4396        /* aiteam-stufen-v1: Die Wahl wird beim Wechseln in den Zustand
4397           geschrieben. Ohne das spraenge sie bei jedem Neuaufbau der Akte
4398           zurueck (Datei anhaengen, Freigabe erteilen, Titel speichern) -
4399           genau die Falle, die beim Entwurfstext schon einmal zugeschlagen
4400           hat. Das Flag sitzt am ELEMENT und nicht am Modul, weil diese
4401           Funktion nach JEDEM Zeichnen laeuft und die Elemente dabei
4402           ersetzt werden.
4403           Bewusst KEIN Neuzeichnen im Handler: das wuerde den halb
4404           getippten Satz und die Schreibmarke verwerfen. Nur der
4405           Hinweistext wird nachgezogen. */
4406        const setz = (id, feld) => {
4407            const el = document.getElementById(id);
4408            if (!el || el._aiGebunden) return;
4409            el._aiGebunden = true;
4410            el.addEventListener('change', () => {
4411                this._chatZustand()[feld] = el.value;
4412                this._chatStufenHinweis();
4413            });
4414        };
4415        setz('aiChatModellStufe', 'modellStufe');
4416        setz('aiChatTiefeStufe', 'tiefeStufe');
4417    }
4418
4419    static _chatStufenHinweis() {
4420        const c = this._chatZustand();
4421        const stf = (c.info || {}).stufen || {};
4422        const ziel = document.getElementById('aiChatStufenHinweis');
4423        if (!ziel) return;
4424        const ms = c.modellStufe || stf.modell_stufe || '';
4425        const ts = c.tiefeStufe || stf.tiefe_stufe || '';
4426        const m = (stf.modelle || []).find(o => o.schluessel === ms) || {};
4427        const d = (stf.tiefen || []).find(o => o.schluessel === ts) || {};
4428        ziel.textContent = [m.hinweis, d.hinweis].filter(Boolean).join(' ');
4429    }
4430
4431    /* chatlaenge-v1 (24.09.2026): Die Grenze allein schneidet eingefuegten
4432       Text STILL ab - der Browser nimmt nach maxlength einfach nichts mehr
4433       an. Deshalb: Zahl ab 90 %, und ein ausdruecklicher Hinweis, wenn
4434       etwas weggefallen ist (feld._aiGekuerzt = Zahl der verlorenen
4435       Zeichen, gesetzt vom Einfuegen und vom Diktat). */
4436    static _chatZahlZeigen(feld) {
4437        const z = document.getElementById('aiChatZahl');
4438        if (!feld || !z) return;
4439        const max = Number(feld.getAttribute('maxlength')) || 50000;
4440        const n = feld.value.length;
4441        if (n < max) feld._aiGekuerzt = 0;
4442        const weg = feld._aiGekuerzt || 0;
4443        const fmt = (x) =>
4443 Number(x).toLocaleString('de-DE');
4444        let text = '', art = '';
4445        if (weg > 0) {
4446            text = t('Der eingefügte Text war zu lang und wurde nach {max} Zeichen abgeschnitten — {weg} Zeichen fehlen. Längere Texte bitte als Datei anhängen (Büroklammer).')
4447                .replace('{max}', fmt(max)).replace('{weg}', fmt(weg));
4448            art = 'text-danger';
4449        } else if (n >= max) {
4450            text = t('Höchstgrenze erreicht: {max} Zeichen je Nachricht. Längere Texte bitte als Datei anhängen (Büroklammer).')
4451                .replace('{max}', fmt(max));
4452            art = 'text-danger';
4453        } else if (n > max * 0.9) {
4454            text = t('{n} von {max} Zeichen').replace('{n}', fmt(n)).replace('{max}', fmt(max));
4455            art = 'text-warning';
4456        }
4457        z.textContent = text;
4458        z.className = 'form-text mt-1 ' + (text ? art : 'd-none');
4459        // Das Theme ueberschreibt text-warning mit Grau - Farbe direkt setzen.
4460        if (art === 'text-warning') z.style.setProperty('color', '#b7791f', 'important');
4461        else z.style.removeProperty('color');
4462    }
4463
4464    static _chatEingabeBinden() {
4465        const feld = document.getElementById('aiChatEingabe');
4466        if (!feld || feld._aiGebunden) return;
4467        feld._aiGebunden = true;
4468        feld.addEventListener('keydown', (ev) => {
4469            if (ev.key === 'Enter' && !ev.shiftKey) {
4470                ev.preventDefault();
4471                this._chatSenden();
4472            }
4473        });
4474        /* ai-team-chatanhang-v1: Der getippte Text wird mitgeschrieben.
4475           Vorher ging er bei JEDEM Neuaufbau der Akte verloren (Freigabe
4476           erteilen, Titel speichern) - mit dem Anhaengen einer Datei waere
4477           das der Regelfall geworden. */
4478        feld.addEventListener('input', () => {
4479            this._chatZustand().entwurf = feld.value;
4480            this._chatZahlZeigen(feld);   // chatlaenge-v1
4481        });
4482        /* chatlaenge-v1: Beim Einfuegen die Laenge VORHER merken und
4483           NACHHER nachmessen, wie viel tatsaechlich hineinkam. Nicht
4484           vorausberechnen: Zeilenumbrueche (\r\n gegen \n) zaehlen in
4485           Zwischenablage und Feld verschieden. */
4486        feld.addEventListener('paste', (ev) => {
4487            let ein = '';
4488            try {
4489                const cd = ev.clipboardData || window.clipboardData;
4490                ein = (cd && cd.getData('text')) || '';
4491            } catch (e) { ein = ''; }
4492            ein = ein.replace(/\r\n?/g, '\n');
4493            const rest = feld.value.length
4494                - Math.abs((feld.selectionEnd || 0) - (feld.selectionStart || 0));
4495            setTimeout(() => {
4496                const drin = feld.value.length - rest;
4497                feld._aiGekuerzt = Math.max(0, ein.length - drin);
4498                this._chatZustand().entwurf = feld.value;
4499                this._chatZahlZeigen(feld);
4500            }, 0);
4501        });
4502        this._chatZahlZeigen(feld);   // wiederhergestellter Entwurf
4503        if (!this._chatZustand().laeuft) {
4504            try {
4505                feld.focus();
4506                // Schreibmarke ans ENDE: sonst tippt man vor den eigenen Satz.
4507                const n = feld.value.length;
4508                if (n) feld.setSelectionRange(n, n);
4509            } catch (e) { /* egal */ }
4510        }
4511    }
4512
4513    static _bindSuche() {
4514        const feld = document.getElementById('aiSuche');
4515        if (!feld) return;
4516        // Das Feld entsteht bei jedem Render neu, der Listener stirbt mit ihm
4517        // (anders als der Host-Listener, ai-team-dupfix-v1). Fokus und
4518        // Cursorposition werden nach dem Neurendern wiederhergestellt.
4519        feld.addEventListener('input', () => {
4520            const pos = feld.selectionStart;
4521            this._suche = feld.value;
4522            this._render();
4523            const neu = document.getElementById('aiSuche');
4524            if (neu) { neu.focus(); try { neu.setSelectionRange(pos, pos); } catch (e) {} }
4525        });
4526    }
4527
4528    /* ai-team-gesichter-v1 — Gesicht eines MITARBEITERS.
4529
4530       Bewusst getrennt von _avatar(): _avatar wird an zwei Stellen mit
4531       einem ROLLENTITEL aufgerufen (Einstellungs-Assistent). Eine Rolle
4532       ist keine Person und hat kein Gesicht - dort bleiben die
4533       Initialen. Wer beides in eine Funktion legt, setzt einem
4534       Stellenangebot ein Portraet auf.
4535
4536       Faellt auf die Initialen zurueck, wenn der Datensatz keine
4537       Adresse traegt (alte Antwort im Zwischenspeicher, aelteres
4538       Backend). Ein kaputtes Bild waere schlechter als ein Buchstabe. */
4539    static _gesichtBild(m, px, farbe) {
4540        px = px || 38;
4541        const url = m && m.gesicht_url;
4542        if (!url) return this._avatar((m || {}).name, farbe || '#0d6efd', px > 44);
4543        const ring = farbe ? ';box-shadow:0 0 0 2px ' + farbe : '';
4544        return '<img src="' + esc(url) + '" alt="" aria-hidden="true" '
4545            + 'width="' + px + '" height="' + px + '" loading="lazy" '
4546            + 'class="rounded-circle flex-shrink-0" '
4547            + 'style="width:' + px + 'px;height:' + px + 'px;object-fit:cover'
4548            + ';background:#e9ecef' + ring + '">';
4549    }
4550
4551    static _avatar(name, farbe, gross) {
4552        const px = gross ? 52 : 38;
4553        const ini = esc(String(name || '?').trim().charAt(0).toUpperCase() || '?');
4554        return '<span class="d-inline-flex align-items-center justify-content-center rounded-circle flex-shrink-0" '
4555            + 'style="width:' + px + 'px;height:' + px + 'px;background:' + farbe + ';color:#fff;font-weight:600'
4556            + (gross ? ';font-size:1.3rem' : '') + '">' + ini + '</span>';
4557    }
4558
4559    static _rolleTitel(key) {
4560        const r = ((this._katalog || {}).rollen || []).find(x => x.key === key);
4561        return r ? r.titel : key;
4562    }
4563
4564    static _datum(iso) {
4565        try { return new Date(iso).toLocaleDateString('de-DE'); } catch (e) { return iso; }
4566    }
4567
4568    // ---------------------------------------------------------- Einstellen (eigene Seite)
4569
4570    static async _katalogLaden() {
4571        if (!this._katalog) this._katalog = await api('/api/ai-team/catalog');
4572        return this._katalog;
4573    }
4574
4575    static async _hireStart() {
4576        try { await this._katalogLaden(); } catch (e) { return this._modalFehler(e); }
4577        this._hire = { titel: '', schritt: 1, rolle: null, name: '',
4578                       anweisungen: '', gesicht: '' };
4579        this._akteId = null;
4580        this._render();
4581    }
4582
4583    static _hireRolle(key) {
4584        const r = (this._katalog.rollen || []).find(x => x.key === key);
4585        if (!r || !this._hire) return;
4586        this._hire.rolle = r;
4587        this._hire.name = r.default_name;
4588        /* ai-team-gesicht-hire-v1: eine Vorbelegung, damit nie ein
4589           Mitarbeiter ohne Gesicht entsteht, wenn der Inhaber die
4590           Auswahl uebergeht. Bewusst NICHT ueberschreiben, falls er
4591           schon gewaehlt und dann die Rolle gewechselt hat. */
4592        if (!this._hire.gesicht) this._hire.gesicht = this._hireGesichtFrei();
4593        this._hire.schritt = 2;
4594        this._render();
4595    }
4596
4597    static _hireZurueck() {
4598        if (this._hire && this._hire.schritt > 1) {
4599            this._hireMerken();
4600            this._hire.schritt -= 1;
4601            this._render();
4602        }
4603    }
4604
4605    static _hireWeiter() {
4606        this._hireMerken();
4607        /* kiteam-schritt4-v1: War hart auf 3 gesetzt. Mit dem vierten
4608           Schritt muss die Navigation zaehlen statt zu springen - sonst
4609           waere Schritt 4 ueber "Weiter" nie erreichbar. */
4610        const max = 4;
4611        this._hire.schritt = Math.min((this._hire.schritt || 1) + 1, max);
4612        if (this._hire.schritt === 4) this._hireKostenLaden();
4613        this._render();
4614    }
4615
4616    /* kiteam-schritt4-v1: Die Zahlen holt der SERVER. Die Oberflaeche
4617       darf den Rollenpreis nicht selbst auf den Monatsbetrag addieren -
4618       das waere eine zweite Auslegung derselben Preisstufe, und die
4619       zweite ist die, die niemand nachzieht, wenn sich der Katalog
4620       aendert. Faellt der Abruf aus, zeigt Schritt 4 einen Hinweis statt
4621       einer erfundenen Zahl. */
4622    static async _hireKostenLaden() {
4623        const key = ((this._hire || {}).rolle || {}).key;
4624        if (!key) return;
4625        this._hireKosten = { laedt: true };
4626        try {
4627            /* Die Reihenfolge ist (pfad, methode) - der Pfad zuerst.
4628               Hier stand die umgekehrte, was die URL "GET" abgerufen
4629               haette statt der Route. */
4630            this._hireKosten = await api(
4631                '/api/ai-team/einstellen/kosten?rolle=' + encodeURIComponent(key));
4632        } catch (e) {
4633            this._hireKosten = { fehler: (e && e.message) || String(e) };
4634        }
4635        /* Nur neu zeichnen, wenn der Inhaber noch auf Schritt 4 steht -
4636           sonst ueberschreibt eine langsame Antwort eine Seite, die er
4637           inzwischen verlassen hat. */
4638        if (this._hire && this._hire.schritt === 4) this._render();
4639        /* kiteam-karte-v1: Zahlungsmittel GETRENNT holen. Bewusst nicht
4640           im selben Aufruf wie die Kosten: faellt Stripe aus, soll der
4641           Inhaber trotzdem seine Zahlen sehen. Ein gemeinsamer Aufruf
4642           haette beide Auskuenfte an dasselbe Schicksal gebunden. */
4643        this._hireKarteLaden();
4644    }
4645
4646    static async _hireKarteLaden() {
4647        this._hireKarte = { laedt: true };
4648        try {
4649            this._hireKarte = await api('/api/ai-team/einstellen/zahlungsmittel');
4650        } catch (e) {
4651            this._hireKarte = { fehler: (e && e.message) || String(e) };
4652        }
4653        if (this._hire && this._hire.schritt === 4) this._render();
4654    }
4655
4656    static _hireMerken() {
4657        const n = document.getElementById('aiWizName');
4658        const a = document.getElementById('aiWizAnw');
4659        const ti = document.getElementById('aiWizTitel');
4660        if (n) this._hire.name = n.value;
4661        if (a) this._hire.anweisungen = a.value;
4662        if (ti) this._hire.titel = ti.value;
4663        /* onboard-rollen-v1: Die Zugriffs-Schalter aus Schritt 3. Ohne das
4664           waere die Auswahl nach einem Klick auf "Zurueck" wieder weg —
4665           dieselbe Falle wie beim Namen, nur unauffaelliger, weil der
4666           Inhaber sie erst nach dem Einstellen bemerkt. */
4667        const sch = document.querySelectorAll('[data-wizzugriff]');
4668        if (sch.length) {
4669            const z = {};
4670            sch.forEach(el => { z[el.getAttribute('data-wizzugriff')] = el.checked; });
4671            this._hire.zugriffe = z;
4672        }
4673    }
4674
4675    /* onboard-rollen-v1: Stand der Zugriffe im Assistenten — gewaehlt,
4676       sonst die Rollenvorgabe des Servers. Die Oberflaeche legt die
4677       Vorgabe NICHT selbst aus (zwei Auslegungen desselben Rechts sind
4678       der Fehler, der uns bei den Modulrechten dreimal einholte). */
4679    static _hireZugriffStand() {
4680        const w = this._hire || {};
4681        const rk = (w.rolle || {}).key || '';
4682        const vorgabe = ((this._katalog || {}).zugriff_je_rolle || {})[rk] || [];
4683        const moegl = this._hireZugriffMoeglich();
4684        const aus = {};
4685        moegl.forEach(k => { aus[k] = vorgabe.indexOf(k) >= 0; });
4686        if (w.zugriffe) {
4687            Object.keys(w.zugriffe).forEach(k => {
4688                if (moegl.indexOf(k) >= 0) aus[k] = w.zugriffe[k] === true;
4689            });
4690        }
4691        return aus;
4692    }
4693
4694    static _hireZugriffMoeglich() {
4695        const rk = ((this._hire || {}).rolle || {}).key || '';
4696        const k = this._katalog || {};
4697        const m = (k.zugriff_moeglich_je_rolle || {})[rk];
4698        /* Aeltere Serverfassung ohne die Ableitung: lieber alles anbieten
4699           als nichts — der Server weist Rollenfremdes ohnehin ab. */
4700        return Array.isArray(m) ? m : Object.keys(k.zugriffe || {});
4701    }
4702
4703    /* ai-team-gesicht-hire-v1 — Auswahl schon beim Einstellen.
4704       Bis hierher wurde das Gesicht ausschliesslich vom Server vergeben
4705       und war erst NACH dem Einstellen in der Personalakte aenderbar.
4706       Der Bestand kommt aus demselben Katalog wie der Aendern-Dialog,
4707       nicht aus einer zweiten Liste im Browser. */
4708    static _hireGesichtListe() {
4709        return ((this._katalog || {}).gesichter) || [];
4710    }
4711
4712    static _hireGesichtFrei() {
4713        /* Erstes Gesicht, das im Team noch niemand hat. Die genaue
4714           Reihenfolge des Servers (frei_waehlen ab der Mitarbeiter-
4715           Kennung) laesst sich hier nicht nachbauen — die Kennung
4716           entsteht erst beim Einstellen. Wichtig ist nur, dass die
4717           Vorauswahl im eigenen Team eindeutig ist. */
4718        const liste = this._hireGesichtListe();
4719        if (!liste.length) return '';
4720        const belegt = new Set((this._members || [])
4721            .map(m => String(m.gesicht || '')).filter(Boolean));
4722        const frei = liste.find(g => !belegt.has(String(g.id)));
4723        return String((frei || liste[0]).id);
4724    }
4725
4726    static _hireGesichtUrl() {
4727        const gid = String((this._hire || {}).gesicht || '');
4728        const g = this._hireGesichtListe().find(x => String(x.id) === gid);
4729        return g ? g.url : '';
4730    }
4731
4732    static _hireGesicht(gid) {
4733        if (!this._hire || !gid) return;
4734        this._hire.gesicht = String(gid);
4735        /* Bewusst KEIN _render(): der Assistent baut Schritt 2 aus dem
4736           Zustand neu auf, und ein halb getippter Name bzw. die
4737           Cursorstelle im Anweisungsfeld gingen dabei verloren
4738           (dieselbe Klasse wie rez-vermerk-v1). Es wandert nur die
4739           Markierung und das Vorschaubild. */
4740        const url = this._hireGesichtUrl();
4741        document.querySelectorAll(
4742            '#aiWizGesichter [data-action="wizard-gesicht"]').forEach(b => {
4743            const an = String(b.getAttribute('data-id')) === String(gid);
4744            b.setAttribute('aria-current', an ? 'true' : 'false');
4745            const im = b.querySelector('img');
4746            if (im) im.style.boxShadow = an ? '0 0 0 3px #2e6b94' : '';
4747        });
4748        ['aiWizVorschau', 'aiWizKopfBild'].forEach(k => {
4749            const el = document.getElementById(k);
4750            if (el && url) el.setAttribute('src', url);
4751        });
4752    }
4753
4754    static _hireGesichtKacheln() {
4755        const liste = this._hireGesichtListe();
4756        if (!liste.length) return '';
4757        const jetzt = String((this._hire || {}).gesicht || '');
4758        const belegt = new Set((this._members || [])
4759            .map(m =>
4759 String(m.gesicht || '')).filter(Boolean));
4760        const kacheln = liste.map(g => {
4761            const an = String(g.id) === jetzt;
4762            const doppelt = belegt.has(String(g.id));
4763            return '<button type="button" class="btn p-1 border-0 bg-transparent" '
4764                + 'data-action="wizard-gesicht" data-id="' + esc(g.id) + '" '
4765                + 'aria-current="' + (an ? 'true' : 'false') + '" '
4766                + 'title="' + esc(doppelt ? t('Dieses Gesicht hat bereits jemand im Team')
4767                                          : t('Dieses Gesicht verwenden')) + '" '
4768                + 'style="border-radius:50%">'
4769                + '<img src="' + esc(g.url) + '" alt="" width="48" height="48" '
4770                + 'loading="lazy" class="rounded-circle" '
4771                + 'style="width:48px;height:48px;background:#e9ecef'
4772                + (doppelt && !an ? ';opacity:.45' : '')
4773                + (an ? ';box-shadow:0 0 0 3px #2e6b94' : '') + '">'
4774                + '</button>';
4775        }).join('');
4776        return '<div class="mb-3"><label class="form-label">'
4777            + t('Gesicht') + '</label>'
4778            + '<div id="aiWizGesichter" class="d-flex flex-wrap gap-1 p-2 border rounded" '
4779            + 'style="max-height:190px;overflow-y:auto">' + kacheln + '</div>'
4780            + '<div class="form-text">'
4781            + t('Gezeichnet statt fotografiert — damit erkennbar bleibt, '
4782                + 'dass es ein Assistent ist. Blasse Bilder sind im Team '
4783                + 'schon vergeben, änderbar bleibt die Wahl jederzeit.')
4784            + '</div></div>';
4785    }
4786
4787    /* kiteam-schritt4-v1: Schritt 4 - Kosten & Buchung.
4788
4789       Bewusst KEIN Zahlungsschritt. Michael hat die Probezeit kostenlos
4790       gestellt; eine Kasse an dieser Stelle wuerde das wieder aufheben.
4791       Der Schritt beantwortet die Frage, die der Ablauf bisher offen
4792       liess: was passiert nach Tag 14.
4793
4794       Er verspricht ausdruecklich KEINE automatische Buchung - es gibt
4795       keinen Automatismus, der das Abo zum Probezeitende erhoeht. Wer
4796       hier "wird automatisch gebucht" schreibt, erzeugt eine Zusage, die
4797       das System nicht einloest. */
4798    static _hireKostenBlock() {
4799        const w = this._hire || {};
4800        const r = w.rolle || {};
4801        const k = this._hireKosten || {};
4802        const eur = (n) => Number(n || 0).toLocaleString('de-DE') + ' €';
4803
4804        let zahlen;
4805        if (k.laedt) {
4806            zahlen = '<div class="text-muted"><span class="spinner-border '
4807                + 'spinner-border-sm me-2"></span>' + t('Kosten werden berechnet …') + '</div>';
4808        } else if (k.fehler) {
4809            /* Ehrlich bleiben: lieber sagen, dass die Zahl fehlt, als eine
4810               zu zeigen, die vielleicht falsch ist. */
4811            zahlen = '<div class="alert alert-warning mb-0">'
4812                + '<i class="fas fa-triangle-exclamation me-2"></i>'
4813                + t('Die Kostenübersicht konnte nicht geladen werden. Das Einstellen '
4814                    + 'funktioniert trotzdem — die Kosten sehen Sie danach unter '
4815                    + '„Abrechnung“.')
4816                + '<div class="small text-muted mt-1">' + esc(k.fehler) + '</div></div>';
4817        } else if (k.preis_unbekannt) {
4818            zahlen = '<div class="alert alert-warning mb-0">'
4819                + t('Für diese Rolle ist kein Preis hinterlegt. Bitte melden Sie sich, '
4820                    + 'bevor Sie fortfahren.') + '</div>';
4821        } else {
4822            zahlen = '<table class="table align-middle mb-0">'
4823                + '<tbody>'
4824                + '<tr><td>' + t('Ihr Team heute')
4825                + ' <span class="text-muted small">(' + esc(k.jetzt_anzahl) + ' '
4826                + t('Arbeitsplätze') + ')</span></td>'
4827                + '<td class="text-end">' + esc(eur(k.jetzt_netto)) + '/' + t('Monat') + '</td></tr>'
4828                + '<tr><td>' + esc(r.titel || '') + ' '
4829                + '<span class="text-muted small">(' + t('ab Tag') + ' '
4830                + esc(Number(k.probezeit_tage || 0) + 1) + ')</span></td>'
4831                + '<td class="text-end text-nowrap">+ ' + esc(eur(k.differenz)) + '</td></tr>'
4832                + '<tr class="fw-semibold border-top">'
4833                + '<td>' + t('Danach') + '</td>'
4834                + '<td class="text-end">
4834' + esc(eur(k.nachher_netto)) + '/' + t('Monat') + '</td></tr>'
4835                + '</tbody></table>'
4836                + '<div class="small text-muted mt-2">'
4837                + t('Alle Beträge netto zzgl. USt.') + '</div>';
4838        }
4839
4840        /* Der Hinweis haengt an der Abo-Lage, weil die naechste Handlung
4841           sich unterscheidet: wer noch kein Abo hat, muss eines abschliessen;
4842           wer eines hat, passt die Menge an. Beides passiert in der
4843           Abrechnung und beides erst NACH der Probezeit. */
4844        let danach = '';
4845        if (k.abo_lage === 'keins') {
4846            danach = '<div class="alert alert-secondary mb-0">'
4847                + '<div class="fw-semibold mb-1"><i class="fas fa-circle-info me-2"></i>'
4848                + t('Was nach der Probezeit passiert') + '</div>'
4849                + t('Es wird nichts automatisch abgebucht. Vor Ablauf der Probezeit '
4850                    + 'buchen Sie den Arbeitsplatz unter „Abrechnung“ — danach arbeitet '
4851                    + 'Ihr Mitarbeiter ohne Unterbrechung weiter.') + '</div>';
4852        } else if (k.abo_lage === 'vorhanden') {
4853            danach = '<div class="alert alert-secondary mb-0">'
4854                + '<div class="fw-semibold mb-1"><i class="fas fa-circle-info me-2"></i>'
4855                + t('Was nach der Probezeit passiert') + '</div>'
4856                + t('Ihr Abo erhöht sich nicht von selbst. Nach Ablauf der Probezeit '
4857                    + 'erscheint der Arbeitsplatz unter „Abrechnung“, und Sie bestätigen '
4858                    + 'die Anpassung dort — anteilig auf die nächste Rechnung.') + '</div>';
4859        }
4860
4861        const probeTage = k.probezeit_tage || this._katalog.probezeit_tage || '';
4862        const probeAkt = k.probezeit_aktionen || this._katalog.probezeit_aktionen || '';
4863
4864        return '<div class="row"><div class="col-12 col-lg-8">'
4865            + '<div class="card border-success mb-3"><div class="card-body p-4">'
4866            + '<div class="d-flex align-items-center mb-2">'
4867            + '<span class="badge bg-success me-2"><i class="fas fa-gift"></i></span>'
4868            + '<h6 class="mb-0">' + t('Die Probezeit ist kostenlos') + '</h6></div>'
4869            + '<div class="small">'
4870            + t('Sie zahlen für diesen Mitarbeiter zunächst nichts.') + ' '
4871            + (probeTage ? '<strong>' + esc(probeTage) + ' ' + t('Tage') + '</strong>' : '')
4872            + (probeTage && probeAkt ? ' ' + t('oder') + ' ' : '')
4873            + (probeAkt ? '<strong>' + esc(Number(probeAkt).toLocaleString('de-DE'))
4874                + ' ' + t('Aktionen') + '</strong>' : '')
4875            + (probeTage || probeAkt ? ' — ' + t('was zuerst erreicht wird, zählt.') : '')
4876            + '</div></div></div>'
4877            + '<div class="card mb-3"><div class="card-body p-4">'
4878            + '<h6 class="mb-3">' + t('Was danach fällig wird') + '</h6>'
4879            + zahlen + '</div></div>'
4880            + this._hireKarteBlock()
4881            + (danach ? '<div class="mb-3">' + danach + '</div>' : '')
4882            + (Number(k.probezeit_laufend || 0) > 0
4883                ? '<div class="small text-muted mb-3">'
4884                    + '<i class="fas fa-user-clock me-1"></i>'
4885                    + esc(k.probezeit_laufend) + ' '
4886                    + t('weitere Mitarbeiter sind bereits in der Probezeit — auch deren '
4887                        + 'Arbeitsplätze werden nach Ablauf fällig.') + '</div>'
4888                : '')
4889            + '<div class="d-flex justify-content-between">'
4890            + '<button class="btn btn-outline-secondary" data-action="wizard-zurueck">'
4891            + t('Zurück') + '</button>'
4892            + '<button class="btn btn-success btn-lg" data-action="wizard-einstellen">'
4893            + '<i class="fas fa-user-check me-1"></i>'
4894            + t('Jetzt kostenlos einstellen') + '</button></div>'
4895            + '</div></div>';
4896    }
4897
4898    /* kiteam-karte-v1: Karte hinterlegen - OHNE Abbuchung.
4899
4900       Die Kartendaten erreichen unseren Server NIE. Das Eingabefeld ist
4901       ein iframe von Stripe; wir bekommen nur die Kennung der fertigen
4902       Zahlungsmethode. Ein eigenes Kartenfeld waere ein PCI-Verstoss.
4903
4904       Liegt bereits eine Karte vor, wird sie ANGEZEIGT statt erneut
4905       abgefragt - wer beim zweiten Mitarbeiter wieder tippen soll, haelt
4906       das zu Recht fuer einen Fehler. */
4907    static _hireKarteBlock() {
4908        const z = this._hireKarte || {};
4909        if (z.laedt) {
4910            return '<div class="card mb-3"><div class="card-body p-4 text-muted">'
4911                + '<span class="spinner-border spinner-border-sm me-2"></span>'
4912                + t('Zahlungsdaten werden geprüft …') + '</div></div>';
4913        }
4914        /* Ehrlich bleiben: lieber sagen, dass es gerade nicht geht, als ein
4915           totes Feld zeigen. Das Einstellen bleibt moeglich - die Probezeit
4916           ist kostenlos, eine fehlende Karte darf sie nicht blockieren. */
4917        if (z.fehler || z.formular_moeglich === false) {
4918            return '<div class="alert alert-warning mb-3">'
4919                + '<i class="fas fa-triangle-exclamation me-2"></i>'
4920                + t('Die Zahlungsdaten können gerade nicht geladen werden. Sie können '
4921                    + 'trotzdem einstellen — die Karte hinterlegen Sie später unter '
4922                    + '„Abrechnung“.')
4923                + (z.fehler ? '<div class="small text-muted mt-1">' + esc(z.fehler)
4924                    + '</div>' : '') + '</div>';
4925        }
4926
4927        const kopf = '<div class="d-flex align-items-center mb-2">'
4928            + '<span class="badge bg-secondary me-2"><i class="fas fa-credit-card"></i></span>'
4929            + '<h6 class="mb-0">' + t('Zahlungsmittel') + '</h6></div>';
4930
4931        /* Bereits hinterlegt: anzeigen, nicht erneut fragen. */
4932        if (z.hat_karte && !this._hireKarteNeuAn) {
4933            const k = (z.karten || [])[0] || {};
4934            const marke = String(k.marke || '').replace(/^./, c => c.toUpperCase());
4935            return '<div class="card border-success mb-3"><div class="card-body p-4">'
4936                + kopf
4937                + '<div class="d-flex align-items-center justify-content-between">'
4938                + '<div><i class="fas fa-circle-check text-success me-2"></i>'
4939                + esc(marke) + ' •••• ' + esc(k.letzte4 || '')
4940                + (k.monat ? '<span class="text-muted small ms-2">'
4941                    + esc(String(k.monat).padStart(2, '0')) + '/' + esc(k.jahr || '')
4942                    + '</span>' : '')
4943                + '<div class="small text-muted mt-1">'
4944                + t('Ist hinterlegt. Für die Probezeit wird nichts abgebucht.')
4945                + '</div></div>'
4946                + '<button class="btn btn-sm btn-outline-secondary" '
4947                + 'data-action="wizard-karte-neu">' + t('Ändern') + '</button>'
4948                + '</div></div></div>';
4949        }
4950
4951        /* Noch keine Karte: eingebettetes Formular. */
4952        const f = this._hireKarteFehler;
4953        return '<div class="card mb-3"><div class="card-body p-4">'
4954            + kopf
4955            + '<div class="small text-muted mb-3">'
4956            + t('Damit es nach der Probezeit ohne Unterbrechung weitergeht. '
4957                + 'Es wird jetzt nichts abgebucht — die Probezeit ist kostenlos.')
4958            + '</div>'
4959            + '<div id="aiWizKarteFeld" class="mb-3"></div>'
4960            + (f ? '<div class="alert alert-danger py-2 small mb-3">' + esc(f) + '</div>' : '')
4961            + '<div class="d-flex align-items-center gap-2">'
4962            + '<button class="btn btn-primary" data-action="wizard-karte-speichern" '
4963            + 'id="aiWizKarteBtn"><i class="fas fa-lock me-1"></i>'
4964            + t('Karte sicher hinterlegen') + '</button>'
4965            + '<span class="small text-muted">'
4966            + t('Verschlüsselt direkt bei Stripe — wir speichern keine Kartendaten.')
4967            + '</span></div></div></div>';
4968    }
4969
4970    /* Elements erst NACH dem Zeichnen montieren - vorher gibt es den
4971       Container nicht. Mehrfaches Montieren wird verhindert, sonst haengen
4972       nach jedem Neuzeichnen mehrere iframes im selben Feld. */
4973    static _hireKarteMontieren() {
4974        if (!this._hire || this._hire.schritt !== 4) return;
4975        const feld = document.getElementById('aiWizKarteFeld');
4976        if (!feld) return;
4977        const z = this._hireKarte || {};
4978        if (!z.publishable_key) return;
4979        if (this._karteMontiertAn === feld) return;
4980
4981        if (typeof Stripe === 'undefined') {
4982            /* Stripe.js liegt in index_v2.html. Fehlt es, sagen wir das -
4983               ein leeres Feld ohne Erklaerung ist die schlechtere Variante. */
4984            this._hireKarteFehler = t('Das Kartenformular konnte nicht geladen werden.');
4985            return;
4986        }
4987        this._hireKarteStarten(feld, z.publishable_key);
4988    }
4989
4990    static async _hireKarteStarten(feld, pk) {
4991        this._karteMontiertAn = feld;
4992        try {
4993            const vor = await api('/api/ai-team/einstellen/zahlungsmittel', 'POST');
4994            this._karteSi = vor.setup_intent;
4995            this._karteStripe = Stripe(pk);
4996            this._karteElements = this._karteStripe.elements({
4997                clientSecret: vor.client_secret,
4998                appearance: { theme: 'stripe' }
4999            });
5000            const pe = this._karteElements.create('payment', {
5001                fields: { billingDetails: { address: 'never' } }
5002            });
5003            pe.mount(feld);
5004            this._karteElement = pe;
5005        } catch (e) {
5006            this._karteMontiertAn = null;
5007            this._hireKarteFehler = (e && e.message) || String(e);
5008            if (this._hire && this._hire.schritt === 4) this._render();
5009        }
5010    }
5011
5012    static _hireKarteNeu() {
5013        /* Andere Karte: Formular zeigen. Der Montage-Merker muss zurueck,
5014           sonst haelt er den alten Container fest und es erscheint nichts. */
5015        this._hireKarteNeuAn = true;
5016        this._karteMontiertAn = null;
5017        this._hireKarteFehler = '';
5018        this._render();
5019    }
5020
5021    static async _hireKarteSpeichern() {
5022        if (!this._karteStripe || !this._karteElements) return;
5023        const btn = document.getElementById('aiWizKarteBtn');
5024        if (btn) { btn.disabled = true; btn.innerHTML = t('Wird geprüft …'); }
5025        this._hireKarteFehler = '';
5026        try {
5027            /* DER KERN DER UNTERBRECHUNGSFREIHEIT: redirect 'if_required'.
5028               Der Browser bleibt auf dieser Seite; nur wenn die Bank eine
5029               Bestaetigung verlangt, wird umgeleitet - dann trifft
5030               return_url. */
5031            const erg = await this._karteStripe.confirmSetup({
5032                elements: this._karteElements,
5033                confirmParams: { return_url: window.location.href },
5034                redirect: 'if_required'
5035            });
5036            if (erg.error) throw new Error(erg.error.message || 'Karte abgelehnt');
5037
5038            /* Der Browser sagt "hat geklappt" - das ist KEIN Beleg. Der
5039               Server prueft den Vorgang bei Stripe nach und setzt die Karte
5040               als Standard. Ohne diesen Schritt haengt sie zwar am Kunden,
5041               greift bei der spaeteren Abo-Rechnung aber NICHT. */
5042            const si = (erg.setupIntent && erg.setupIntent.id) || this._karteSi;
5043            await api('/api/ai-team/einstellen/zahlungsmittel/bestaetigen', 'POST',
5044                { setup_intent: si });
5045            this._hireKarteNeuAn = false;
5046            this._karteMontiertAn = null;
5047            this._karteStripe = null;
5048            this._karteElements = null;
5049            await this._hireKarteLaden();
5050        } catch (e) {
5051            this._hireKarteFehler = (e && e.message) || String(e);
5052            if (btn) {
5053                btn.disabled = false;
5054                btn.innerHTML = '<i class="fas fa-lock me-1"></i>'
5055                    + t('Karte sicher hinterlegen');
5056            }
5057            this._render();
5058        }
5059    }
5060
5061    static _hireSchritte(aktiv) {
5062        /* kiteam-schritt4-v1: Vierter Schritt. Der Ablauf endete bisher
5063           mit dem Einstellen-Knopf, und was danach kostet, stand nur als
5064           Schild auf der Rollenkarte in Schritt 1. */
5065        const schritte = [t('Rolle wählen'), t('Kennenlernen'),
5066                          t('Vertrauen & Probezeit'), t('Kosten & Buchung')];
5067        return '<div class="d-flex align-items-center flex-wrap gap-2 mb-4">'
5068            + schritte.map((s, i) => {
5069                const n = i + 1;
5070                const cls = n === aktiv ? 'bg-primary' : (n < aktiv ? 'bg-success' : 'bg-secondary opacity-50');
5071                return (i ? '<i class="fas fa-chevron-right text-muted small"></i>' : '')
5072                    + '<span class="d-inline-flex align-items-center gap-2">'
5073                    + '<span class="badge rounded-pill ' + cls + '">'
5074                    + (n < aktiv ? '<i class="fas fa-check"></i>' : n) + '</span>'
5075                    + '<span class="' + (n === aktiv ? 'fw-semibold' : 'text-muted') + '">' + s + '</span></span>';
5076            }).join('')
5077            + '</div>';
5078    }
5079
5080    static _rollenKarte(r) {
5081        const farbe = ROLLEN_FARBEN[r.key] || '#0d6efd';
5082        return '<div class="card h-100" style="cursor:pointer" data-action="wizard-rolle" data-id="' + esc(r.key) + '">'
5083            + '<div class="card-body p-4">'
5084            + '<div class="d-flex align-items-start">'
5085            + this._avatar(r.titel, farbe, true)
5086            + '<div class="ms-3 me-auto">'
5087            + '<h5 class="mb-1">' + esc(r.titel) + '</h5>'
5088            + '<div class="text-muted">' + esc(r.kurz) + '</div></div>'
5089            + '<span class="badge bg-light text-dark fs-6 flex-shrink-0">' + esc(r.preis_eur) + ' €/' + t('Monat') + '</span>'
5090            + '</div>'
5091            + '<div class="row mt-3 g-3">'
5092            + '<div class="col-md-6"><div class="small fw-semibold mb-1">'
5093            + '<i class="fas fa-circle-check me-1" style="color:#198754"></i>' + t('Das übernimmt er') + '</div>'
5094            + '<ul class="small mb-0 ps-3">' + (r.uebernimmt || []).map(x => '<li>' + esc(x) + '</li>').join('') + '</ul></div>'
5095            + '<div class="col-md-6"><div class="small fw-semibold mb-1 text-muted">'
5096            + '<i class="fas fa-ban me-1"></i>' + t('Das kann er nicht') + '</div>'
5097            + '<ul class="small mb-0 ps-3 text-muted">' + (r.kann_nicht || []).map(x => '<li>' + esc(x) + '</li>').join('') + '</ul></div>'
5098            + '</div>'
5099            + '<button class="btn btn-outline-primary btn-sm mt-3" data-action="wizard-rolle" data-id="' + esc(r.key) + '">'
5100            + t('Auswählen') + '</button>'
5101            + '</div></div>';
5102    }
5103
5104    static _renderHire(host) {
5105        const w = this._hire;
5106        if (!w) return;
5107        const kopf = '<div class="d-flex align-items-center mb-3">'
5108            + '<button class="btn btn-outline-secondary btn-sm me-3" data-action="hire-abbrechen">'
5109            + '<i class="fas fa-arrow-left me-1"></i>' + t('Zurück zum Team') + '</button>'
5110            + (w.schritt >= 2 && this._hireGesichtUrl()
5111                ? '<img id="aiWizKopfBild" src="' + esc(this._hireGesichtUrl())
5112                    + '" alt="" class="rounded-circle me-2" '
5113                    + 'style="width:32px;height:32px;background:#e9ecef">'
5114                : '')
5115            + '<h5 class="mb-0">' + t('Mitarbeiter einstellen')
5116            + (w.rolle ? ' — ' + esc(w.rolle.titel) + (w.schritt >= 2 && w.name ? ' „' + esc(w.name) + '"' : '') : '')
5117            + '</h5></div>'
5118            + this._hireSchritte(w.schritt);
5119
5120        let inhalt = '';
5121        if (w.schritt === 1) {
5122            inhalt = '<div class="row g-3">'
5123                + (this._katalog.rollen || []).map(r =>
5124                    '<div class="col-12 col-xl-6">' + this._rollenKarte(r) + '</div>').join('')
5125                + '</div>';
5126        } else if (w.schritt === 2) {
5127            const r = w.rolle;
5128            inhalt = '<div class="row g-3">'
5129                + '<div class="col-12 col-lg-7"><div class="card"><div class="card-body p-4">'
5130                + '<div class="mb-3"><label class="form-label">' + t('Name') + '</label>'
5131                + '<input id="aiWizName" class="form-control form-control-lg" maxlength="60" value="' + esc(w.name) + '">'
5132                + '<label class="form-label mt-3">' + t('Wie soll die Position heißen?') + '</label>'
5133                + '<input id="aiWizTitel" class="form-control" maxlength="60" value="'
5134                + esc(w.titel || '') + '" placeholder="' + esc(t('optional — z. B. „CEO", „Praxisleitung"'))
5135                + '">'
5136                + '<div class="form-text">'
5137                + t('Ohne Angabe wird die Rollenbezeichnung verwendet. Die Rolle steuert, was der '
5138                    + 'Mitarbeiter tut — der Titel nur, wie er angezeigt wird.') + '</div>'
5139                + '<div class="form-text">' + t('So erscheint er in Berichten, Entwürfen und im Verlauf.') + '</div></div>'
5140                /* ai-team-gesicht-hire-v1b: Name, Titel und Gesicht
5141                   beantworten dieselbe Frage — wer ist er. Erst danach
5142                   kommt, was er tun soll. Hinter dem Anweisungsfeld
5143                   stand die Auswahl am Seitenende und war leicht zu
5144                   uebersehen (Sichtpruefung). */
5145                + this._hireGesichtKacheln()
5146                + '<div class="mb-3"><label class="form-label">' + t('Erste Anweisungen (optional, eine je Zeile)') + '</label>'
5147                + '<textarea id="aiWizAnw" class="form-control" rows="6" '
5148                + 'placeholder="' + esc(t('z. B. Angebote immer erst mir zeigen')) + '">'
5149                + esc(w.anweisungen) + '</textarea>'
5150                + '<div class="form-text">' + t('Anweisungen können Sie jederzeit in der Personalakte ergänzen oder ändern.') + '</div></div>'
5151                + '<div class="d-flex justify-content-between">'
5152                + '<button class="btn btn-outline-secondary" data-action="wizard-zurueck">' + t('Zurück') + '</button>'
5153                + '<button class="btn btn-primary btn-lg" data-action="wizard-weiter">' + t('Weiter') + '</button></div>'
5154                + '</div></div></div>'
5155                + '<div class="col-12 col-lg-5"><div class="card h-100"><div class="card-body p-4">'
5156                + '<div class="d-flex align-items-center mb-2">'
5157                + (this._hireGesichtUrl()
5158                    ? '<img id="aiWizVorschau" src="' + esc(this._hireGesichtUrl())
5159                        + '" alt="" class="rounded-circle" '
5160                        + 'style="width:48px;height:48px;background:#e9ecef">'
5161                    : this._avatar(r.titel, ROLLEN_FARBEN[r.key] || '#0d6efd', true))
5162                + '<div class="ms-3"><h6 class="mb-0">' + esc(r.titel) + '</h6>'
5163                + '<span class="badge bg-light text-dark">' + esc(r.preis_eur) + ' €/' + t('Monat') + '</span></div></div>'
5164                + '<div class="small text-muted mb-2">' + esc(r.kurz) + '</div>'
5165                + '<div class="small fw-semibold mb-1">' + t('Das übernimmt er') + '</div>'
5166                + '<ul class="small ps-3 mb-0">' + (r.uebernimmt || []).map(x => '<li>' + esc(x) + '</li>').join('') + '</ul>'
5167                + '</div></div></div>'
5168                + '</div>';
5169        } else if (w.schritt === 3) {
5170            const ak = this._katalog.aktionsklassen || {};
5171            /* onboard-rollen-v1: Nur die Klassen, die diese Stelle
5172               ueberhaupt braucht. Michaels Vorgabe war, dass unpassende
5173               Funktionen "bei den Einstellungen gar nicht verfuegbar"
5174               sind — das gilt fuer den Einstellen-Assistenten genauso
5175               wie fuer die Personalakte. Der Server leitet die Liste aus
5176               den Werkzeugen ab; faellt sie aus, zeigen wir wie bisher
5177               alles (lieber zu viel anzeigen als eine leere Tabelle). */
5178            const _rkw = ((w.rolle || {}).key) || '';
5179            const _klListe = ((this._katalog || {}).klassen_je_rolle || {})[_rkw];
5180            const _klRelevant = Array.isArray(_klListe) && _klListe.length
5181                ? _klListe : Object.keys(ak);
5182            inhalt = '<div class="row"><div class="col-12 col-lg-8">'
5183                + '<div class="card"><div class="card-body p-4">'
5184                + '<div class="alert alert-info">'
5185                + '<i class="fas fa-shield-halved me-2"></i>'
5186                + esc(w.name || (w.rolle || {}).default_name || '') + ' '
5187                + t('startet mit der vorsichtigsten Einstellung: Alles nach außen braucht Ihre Freigabe. In der Probezeit')
5188                + ' (' + esc(this._katalog.probezeit_tage) + ' ' + t('Tage') + ') '
5189                + t('sendet er grundsätzlich nichts — er zeigt Ihnen, was er getan hätte.') + '</div>'
5190                + '<table class="table align-middle"><tbody>'
5191                + _klRelevant.filter(k => ak[k]).map(k => {
5192                    const regel = ak[k];
5193                    if (regel.max === 'gesperrt') {
5194                        // Wie in der Personalakte: die Sperre MIT Begruendung
5195                        // zeigen — was das System nicht tut, schafft Vertrauen.
5196                        return '<tr class="text-muted"><td>' + esc(regel.label) + '</td>'
5197                            + '<td class="text-end"><i class="fas fa-lock me-1"></i>'
5198                            + t('gesperrt — ein KI-Mitarbeiter löscht nie') + '</td></tr>';
5199                    }
5200                    return '<tr><td>' + esc(regel.label) + '</td><td class="text-end">'
5201                        + '<span class="badge bg-light text-dark">' + esc(t(this._stufeLabel(regel.default))) + '</span>'
5202                        + '</td></tr>';
5203                }).join('')
5204                + '</tbody></table>'
5205                + '<div class="small text-muted mb-3">'
5206                + t('Die Stufen passen Sie nach der Probezeit in der Personalakte an — der Mitarbeiter macht dazu selbst einen Vorschlag.') + '</div>'
5207                + this._hireZugriffBlock()
5208                + '<div class="d-flex justify-content-between">'
5209                + '<button class="btn btn-outline-secondary" data-action="wizard-zurueck">' + t('Zurück') + '</button>'
5210                /* kiteam-schritt4-v1: Fuehrt jetzt WEITER statt direkt
5211                   einzustellen. Der Einstellen-Knopf steht in Schritt 4,
5212                   nach den Kosten - vorher war er das Ende des Ablaufs. */
5213                + '<button class="btn btn-primary btn-lg" data-action="wizard-weiter">'
5214                + t('Weiter') + '<i class="fas fa-chevron-right ms-1"></i></button></div>'
5215                + '</div></div></div></div>';
5216        } else {
5217            inhalt = this._hireKostenBlock();
5218        }
5219        host.innerHTML = kopf + inhalt;
5220        window.scrollTo(0, 0);
5221        /* kiteam-karte-v1: Elements braucht den Container im DOM. Der
5222           Aufruf steht deshalb NACH innerHTML - und darf den Render nie
5223           scheitern lassen. */
5224        try { this._hireKarteMontieren(); } catch (e) { /* Karte ist optional */ }
5225    }
5226
5227    /* onboard-rollen-v1: WORAUF er zugreifen darf — schon beim
5228       Einstellen. Bisher stand das ausschliesslich in der Personalakte,
5229       also erst NACH dem Einstellen. Angeboten wird nur, was zur Stelle
5230       passt; der Server (norm_zugriffe) weist alles andere ohnehin ab,
5231       diese Anzeige ist die bequeme Haelfte davon, nicht der Schutz. */
5232    static _hireZugriffBlock() {
5233        const kat = (this._katalog || {}).zugriffe || {};
5234        const moegl = this._hireZugriffMoeglich().filter(k => kat[k]);
5235        if (!moegl.length) return '';
5236        const stand = this._hireZugriffStand();
5237        const zeilen = moegl.map(k => '<div class="form-check form-switch py-1">'
5238            + '<input class="form-check-input" type="checkbox" role="switch" '
5239            + 'id="aiWizZug_' + esc(k) + '" data-wizzugriff="' + esc(k) + '"'
5240            + (stand[k] ? ' checked' : '') + '>'
5241            + '<label class="form-check-label" for="aiWizZug_' + esc(k) + '">'
5242            + esc(kat[k].label || k)
5243            + '<span class="d-block small text-muted">' + esc(kat[k].was || '')
5244            + '</span></label></div>').join('');
5245        return '<h6 class="mt-4">' + t('Worauf er zugreifen darf') + '</h6>'
5246            + '<div class="text-muted small mb-2">'
5247            + t('Angeboten wird, was zu dieser Stelle passt. Sie können das jederzeit in der Personalakte ändern.')
5248            + '</div>' + zeilen
5249            + '<div class="small text-muted mt-2 mb-3">'
5250            + t('Was er nicht sehen darf, fasst er auch nicht an.') + '</div>';
5251    }
5252
5253    static _stufeLabel(s) {
5254        return ({ gesperrt: 'gesperrt', entwurf: 'nur Entwurf', freigabe: 'mit Freigabe',
5255                  autonom_limit: 'autonom mit Limit', autonom: 'autonom' })[s] || s;
5256    }
5257
5258    static async _hireEinstellen() {
5259        // ai-team-dupfix-v1: laufender Einstell-Vorgang blockt weitere.
5260        if (this._hireLaeuft) return;
5261        this._hireLaeuft = true;
5262        try { await this._hireAusfuehren(); }
5263        finally { this._hireLaeuft = false; }
5264    }
5265
5266    static async _hireAusfuehren() {
5267        this._hireMerken();
5268        const w = this._hire;
5269        if (!w || !w.rolle) return;
5270        const anw = String(w.anweisungen || '').split('\n').map(s =>
5270 s.trim()).filter(Boolean);
5271        try {
5272            await api('/api/ai-team/members', 'POST',
5273                { titel: (this._hire || {}).titel || '',
5274                role_key: w.rolle.key, name: w.name, anweisungen: anw,
5275                /* ai-team-gesicht-hire-v1: leer heisst "entscheide du" —
5276                   der Server belegt dann wie bisher vor. */
5277                gesicht: (this._hire || {}).gesicht || '',
5278                /* onboard-rollen-v1: die im Assistenten gewaehlten
5279                   Zugriffe. Der Server begrenzt sie auf die Rolle. */
5280                zugriffe: this._hireZugriffStand() });
5281        } catch (e) {
5282            /* kiteam-zirkular-v1: Der Deckel ist KEIN Fehler, sondern der
5283               Kauffall - dieselbe Weiche wie bei 'kiteam_kein_abo' beim
5284               Laden. Ein roter Kasten waere hier irrefuehrend: der Inhaber
5285               hat nichts falsch gemacht, er muss nur buchen. */
5286            if (e && e.code === 'kiteam_probe_ausgeschoepft') {
5287                this._hire = null;
5288                this._abr = null;
5289                this._abrMeldung = { art: 'info', text: e.message };
5290                return this._neuLaden('abrechnung');
5291            }
5292            return this._modalFehler(e);
5293        }
5294        const frischerName = w.name || '';
5295        this._hire = null;
5296        /* kiteam-zirkular-v1: Die Kaufsicht verwerfen, damit der eben
5297           eingestellte Mitarbeiter dort auftaucht. Ohne das stuende auf
5298           der Kaufseite weiter "Stellen Sie zuerst jemanden ein" - und
5299           der Kauf-Knopf bliebe gesperrt, obwohl die Mannschaft da ist. */
5300        this._abr = null;
5301        if (this._keinAbo) {
5302            this._abrMeldung = { art: 'success', text:
5303                t('{n} ist eingestellt und in der kostenlosen Probezeit. Buchen Sie jetzt den Arbeitsplatz — bis zum Ende der Probezeit wird nichts berechnet.')
5304                    .replace('{n}', frischerName || t('Der KI-Mitarbeiter')) };
5305        }
5306        await this._neuLaden(this._keinAbo ? 'abrechnung' : 'board');
5307        /* ai-team-einstieg-v1: Der einzige Moment, in dem der Inhaber
5308           sicher an seinen Betrieb denkt, ist der, in dem er gerade
5309           jemanden dafuer eingestellt hat. Deshalb hier und nicht in
5310           einem Hinweis, den er drei Tage spaeter sieht. */
5311        if (this._einrichtung && !this._einrichtung.betrieb_bekannt) {
5312            this._einrichtungFrisch = frischerName;
5313            this._einrichtungZu = false;
5314            this._render();
5315        }
5316    }
5317
5318    // ---------------------------------------------------------- Personalakte
5319
5320    static async _akteAusstattungLaden() {
5321        // ai-team-ausstattung-ui-v1b: Ohne diesen Nachladeschritt waeren
5322        // die Auswahllisten leer, wenn die Akte ohne vorherigen Besuch
5323        // des Reiters "Zugaenge" geoeffnet wird.
5324        try {
5325            const d = await api('/api/ai-team/resources');
5326            this._spec = d.spec || this._spec;
5327            this._ressourcen = d.resources || [];
5328            this._setup = d.setup || this._setup;
5329        } catch (e) { /* Akte bleibt bedienbar, nur ohne Auswahl */ }
5330    }
5331
5332    static async _akteOeffnen(id, startReiter) {
5333        this._akteId = id;
5334        /* ai-team-mail-ui-v1: Postfach-Zustand gehoert zu EINEM Mitarbeiter. */
5335        if (this._pfEmp !== id) {
5336            this._pf = null; this._pfGeladen = false; this._pfEmp = id;
5337        }
5338        try {
5339            await this._katalogLaden();
5340            this._akteDaten = await api('/api/ai-team/members/' + encodeURIComponent(id));
5341            /* ai-team-chat-ui-v1: Zustand nur zuruecksetzen, wenn ein
5342               ANDERER Mitarbeiter geoeffnet wird - sonst geht der laufende
5343               Gespraechsverlauf bei jedem Neuladen der Akte verloren
5344               (dieselbe Klasse wie _neLastConflicts / _soapKiQuelle). */
5345            if (!this._chat || this._chat.empId !== id) {
5346                /* aiteam-mehrlauf-v1: der Strom des bisherigen
5347                   Mitarbeiters haette sonst in die Blasen der neuen Akte
5348                   geschrieben (gleiche Elementnummern). */
5349                if (this._chat) this._chatLoesen(this._chat);
5350                this._akteLaeufe = [];
5351                this._chat = this._chatAnfangszustand(id);   /* chatzustand-v1: war eine
5352                       Kurzfassung ohne 'anhaenge' - der erste Dateianhang
5353                       nach einem Mitarbeiterwechsel scheiterte deshalb. */
5354                // ai-team-akte-v1: bei einem ANDEREN Mitarbeiter auch zurueck
5355                // auf den Gespraechsreiter — sonst landet man in dessen
5356                // Einstellungen, obwohl man nur nachsehen wollte.
5357                this._akteTab = 'chat';
5358                this.__ab = null;   /* ai-team-ablage-ui-v1: Zustands-Hygiene */
5359                this._chatListe = [];
5360                this._chatWahl = [];  /* ai-team-convdel-ui-v1 */
5361            }
5362            /* ai-team-einstieg-v1: ein ausdruecklich gewuenschter Reiter
5363               gewinnt IMMER - auch gegen die Ruecksetzung oben. Wer aus dem
5364               Menue "Postfach" kommt, will das Postfach sehen. */
5365            if (startReiter) this._akteTab = startReiter;
5366            try {
5367                this._chat.info = await api('/api/ai-team/employees/'
5368                    + encodeURIComponent(id) + '/chatinfo');
5369            } catch (e) { this._chat.info = null; }
5370        } catch (e) {
5371            this._akteId = null;
5372            return this._modalFehler(e);
5373        }
5374        await this._akteAusstattungLaden();
5375        // ai-team-akte-v1: Faehigkeiten und Gespraechsliste PARALLEL — beide
5376        // sind fuer die Akte nicht kritisch, ein Fehler dort darf die Seite
5377        // nicht toeten (Muster _katalogLaden).
5378        await Promise.all([
5379            this._skLaden(),
5380            this._chatListeLaden(),
5381        ]);
5382        /* aiteam-offenerlauf-v1: NACH der Gespraechsliste - _chatLaden
5383           holt den Titel von dort. Vor dem Zeichnen, damit die Akte
5384           gleich mit Fortschrittszeile und Stopp-Knopf erscheint. */
5385        try { await this._laufWiederaufnehmen(); }
5386        catch (e) { /* die Akte bleibt bedienbar */ }
5387        this._render();
5388        if ((this._akteTab || 'chat') === 'chat') {
5389            try { this._chatScroll(); } catch (e) { /* egal */ }
5390        }
5391    }
5392
5393    static async _chatListeLaden() {
5394        try {
5395            const q = (this._chatSuche || '').trim();
5396            const d = await api('/api/ai-team/employees/'
5397                + encodeURIComponent(this._akteId) + '/conversations'
5398                + (q ? '?q=' + encodeURIComponent(q) : ''));
5399            this._chatListe = d.conversations || [];
5400            this._chatListeGekappt = !!d.gekappt;
5401            this._chatListeGesamt = d.gesamt || 0;
5402        } catch (e) { this._chatListe = []; this._chatListeGekappt = false; }
5403    }
5404
5405    /* ai-team-convtitel-ui-v1: Die Suche fragt den Server, deshalb NICHT
5406       bei jedem Tastendruck - sonst laeuft je Zeichen eine Anfrage samt
5407       Entschluesselung. 300 ms Ruhe genuegen, um einen Tippfluss von einem
5408       fertigen Suchbegriff zu unterscheiden. */
5409    static _chatSuchBinden() {
5410        const feld = document.getElementById('aiChatSuche');
5411        if (!feld || feld._aiGebunden) return;
5412        feld._aiGebunden = true;
5413        feld.addEventListener('input', () => {
5414            this._chatSuche = feld.value;
5415            /* ai-team-convdel-ui-v1: Mit der Trefferliste wechselt auch die
5416               Grundmenge - eine Auswahl aus der vorigen Ansicht waere ab hier
5417               unsichtbar und wuerde beim Zuruecksetzen der Suche wieder
5418               auftauchen. */
5419            this._chatWahl = [];
5420            clearTimeout(this._chatSuchUhr);
5421            this._chatSuchUhr = setTimeout(async () => {
5422                await this._chatListeLaden();
5423                this._render();
5424                const neu = document.getElementById('aiChatSuche');
5425                if (neu) {
5426                    neu.focus();
5427                    try { neu.setSelectionRange(neu.value.length, neu.value.length); }
5428                    catch (e) { /* egal */ }
5429                }
5430            }, 300);
5431        });
5432    }
5433
5434    static _chatTitelBinden() {
5435        const feld = document.getElementById('aiChatTitel');
5436        if (!feld || feld._aiGebunden) return;
5437        feld._aiGebunden = true;
5438        feld.addEventListener('keydown', (ev) => {
5439            if (ev.key === 'Enter') { ev.preventDefault(); this._chatTitelSpeichern(); }
5440        });
5441        // Auch ohne Klick auf den Haken merken - der Wert darf beim
5442        // Neuzeichnen nicht verlorengehen.
5443        feld.addEventListener('input', () => {
5444            this._chatZustand().titel = String(feld.value || '').slice(0, 120);
5445        });
5446    }
5447
5448    // ================= ai-team-akte-v1 =================
5449    // Michaels Befund 11.08.: "die seite fuer die mitarbeiter
5450    // uebersichtlicher machen ... eine sektion in der wir einstellungen
5451    // machen und dann eine chatfunktion mit links einer liste der
5452    // vergangenen tabs ... skills zuordnen und entwickeln".
5453    //
5454    // VORHER: 13 Karten in zwei Spalten, das Gespraech mittendrin
5455    // zwischen Ausstattung und Werkzeugen. Alles gleich gewichtet,
5456    // obwohl seit heute frueh das Gespraech der Hauptweg ist.
5457    //
5458    // JETZT: vier Reiter nach Benutzungshaeufigkeit. Sprechen ist das,
5459    // was man taeglich tut; Einstellungen macht man einmal.
5460
5461    static get AKTE_REITER() {
5462        return [
5463            ['chat',    'Gespräch',    'fa-comments'],
5464            ['postfach','Postfach',    'fa-envelope'],
5465            ['dateien','Dateien',      'fa-folder-open'],   /* ai-team-ablage-ui-v1 */
5466            ['koennen','Fähigkeiten',  'fa-graduation-cap'],
5467            ['einst',  'Einstellungen','fa-sliders'],
5468            /* ai-team-vier-v1: Auftragsbuch + Dauerauftraege haben einen
5469               eigenen Reiter. Sie lagen unter "Verlauf" — ein Dauerauftrag
5470               ist aber gerade KEINE Vergangenheit, und niemand sucht dort
5471               nach etwas, das kuenftig laufen soll. */
5472            ['auftraege','Aufträge',   'fa-list-check'],
5473            ['verlauf','Verlauf',      'fa-clock-rotate-left'],
5474        ];
5475    }
5476
5477    static _akteReiter(key) {
5478        if (!key) return;
5479        this._akteTab = key;
5480        this._render();
5481        if (key === 'chat') { try { this._chatScroll(); } catch (e) { /* egal */ } }
5482        /* ai-team-pf-frisch-v1: Beim Wechsel AUF das Postfach die Liste
5483           frisch holen. Vorher lud sie nur EINMAL je Mitarbeiter (Flag
5484           _pfGeladen), danach zeigte der Reiter den alten Stand - ein
5485           Entwurf, den der KI-Mitarbeiter waehrend des Gespraechs angelegt
5486           hat, war erst nach einem Seiten-Neuladen zu sehen. Nur in der
5487           Listenansicht: im Verfassen-Formular stehen die Eingaben im DOM,
5488           ein Neu-Rendern wuerde sie verwerfen. */
5489        if (key === 'postfach' && this._pfGeladen) {
5490            const p = this._pfZustand();
5491            if (p.ansicht === 'liste' && !p.laden) { this._pfLaden(false); }
5492        }
5493    }
5494
5495    static _akteReiterLeiste() {
5496        const akt = this._akteTab || 'chat';
5497        const off = ((this._akteDaten || {}).tasks || [])
5498            .filter(x => x.status === 'offen' || x.status === 'in_arbeit').length;
5499        const skn = (((this._akteDaten || {}).member || {}).config || {}).skills || [];
5500        return '<ul class="nav nav-tabs mb-3">'
5501            + this.AKTE_REITER.map(([k, label, ic]) => {
5502                let zusatz = '';
5503                if (k === 'koennen' && skn.length) {
5504                    zusatz = ' <span class="badge bg-secondary ms-1">' + skn.length + '</span>';
5505                }
5506                if (k === 'auftraege' && off) {
5507                    zusatz = ' <span class="badge bg-primary ms-1">' + off + '</span>';
5508                }
5509                return '<li class="nav-item"><button class="nav-link'
5510                    + (k === akt ? ' active' : '') + '" data-action="akte-reiter" '
5511                    + 'data-id="' + k + '"><i class="fas ' + ic + ' me-1"></i>'
5512                    + t(label) + zusatz + '</button></li>';
5513            }).join('')
5514            + '</ul>';
5515    }
5516
5517    // ---------- Reiter 1: Gespräch ----------
5518    // Michaels Wunsch: "links eine liste der vergangenen tabs".
5519    // Der Verlauf lag bisher hinter einem Knopf in einem Dialog — man
5520    // musste wissen, dass es ihn gibt. Jetzt steht er dauerhaft daneben.
5521
5522    static _chatListeSpalte() {
5523        const c = this._chatZustand();
5524        const liste = this._chatListe || [];
5525        const zeilen = liste.length
5526            ? liste.map(x => {
5527                const aktiv = c.conv === x.id;
5528                /* ai-team-chatdel-v1: Michaels Auftrag "wir muessen chats
5529                   loeschen koennen". Die Route bestand seit ai-team-chat-v1,
5530                   es fehlte nur der Knopf. Er sitzt in der Zeile statt in
5531                   einem Menue — und fragt vorher nach, weil ein Gespraech
5532                   nicht wiederherstellbar ist. */
5533                const gewaehlt = (this._chatWahl || []).indexOf(x.id) >= 0;
5534                return '<div class="list-group-item list-group-item-action py-2 d-flex '
5535                    + 'align-items-center gap-2' + (aktiv ? ' active' : '') + '">'
5536                    /* ai-team-convdel-ui-v1: Das Kaestchen steht AUSSERHALB des
5537                       klickbaren Bereichs - sonst wuerde ein Haken das Gespraech
5538                       oeffnen und die Liste neu zeichnen. */
5539                    + '<input type="checkbox" class="form-check-input ait-ccb flex-shrink-0"'
5540                    + (gewaehlt ? ' checked' : '') + ' data-action="chat-wahl" data-id="'
5541                    + esc(x.id) + '" title="' + esc(t('Auswählen')) + '">'
5542                    + '<div class="flex-grow-1 text-truncate" style="cursor:pointer" '
5543                    + 'data-action="chat-laden" data-id="' + esc(x.id) + '">'
5544                    + '<div class="small fw-semibold text-truncate">' + esc(x.titel) + '</div>'
5545                    /* aiteam-mehrlauf-v1: welche Gespraeche gerade laufen */
5546                    + this._convLaufMarke(x.id)
5547                    + '<div class="small' + (aktiv ? '' : ' text-muted') + '">'
5548                    + esc(this._datum(x.zuletzt)) + ' · '
5549                    + esc(x.nachrichten + ' ' + t('Nachrichten')) + '</div>'
5550                    /* Bei einem Textfund die Fundstelle zeigen - sonst
5551                       steht da ein Titel, in dem das Gesuchte gar nicht
5552                       vorkommt, und der Treffer wirkt wie ein Fehler. */
5553                    + (x.fund === 'text' && x.ausschnitt
5554                        ? '<div class="small fst-italic' + (aktiv ? '' : ' text-muted')
5555                          + '" style="opacity:.85">' + esc(x.ausschnitt) + '</div>'
5556                        : '')
5557                    + '</div>'
5558                    + '<button class="btn btn-sm btn-link p-0 flex-shrink-0'
5559                    + (aktiv ? ' text-white' : ' text-muted')
5560                    + '" title="' + esc(t('Umbenennen')) + '" '
5561                    + 'data-action="chat-umbenennen" data-id="' + esc(x.id) + '">'
5562                    + '<i class="fas fa-pen"></i></button>'
5563                    + '<button class="btn btn-sm btn-link p-0 flex-shrink-0'
5564                    + (aktiv ? ' text-white' : ' text-muted')
5565                    + '" title="' + esc(t('Gespräch löschen')) + '" '
5566                    + 'data-action="chat-loeschen" data-id="' + esc(x.id) + '">'
5567                    + '<i class="fas fa-trash"></i></button></div>';
5568            }).join('')
5569            : '<div class="p-3 small text-muted">'
5570              + esc(this._chatSuche
5571                    ? t('Kein Gespräch gefunden.')
5572                    : t('Noch keine früheren Gespräche.')) + '</div>';
5573        const suche = this._chatSuche || '';
5574        const gek = this._chatListeGekappt;
5575        const ges = this._chatListeGesamt || 0;
5576        return '<div class="card h-100"><div class="card-body p-2">'
5577            + '<button class="btn btn-primary btn-sm w-100 mb-2" data-action="chat-neu">'
5578            + '<i class="fas fa-plus me-1"></i>' + t('Neues Gespräch') + '</button>'
5579            /* ai-team-convtitel-ui-v1: Suche ueber Titel UND Text. Der Text
5580               liegt verschluesselt in der Datenbank, deshalb sucht der
5581               Server und nicht der Browser. */
5582            + '<div class="input-group input-group-sm mb-2">'
5583            + '<span class="input-group-text"><i class="fas fa-search"></i></span>'
5584            + '<input id="aiChatSuche" class="form-control" maxlength="80" '
5585            + 'placeholder="' + esc(t('Gespräche durchsuchen')) + '" '
5586            + 'value="' + esc(suche) + '">'
5587            + (suche
5588                ? '<button class="btn btn-outline-secondary" data-action="chat-suche-leeren" '
5589                  + 'title="' + esc(t('Suche zurücksetzen')) + '">&times;</button>'
5590                : '')
5591            + '</div>'
5592            + (suche
5593                ? '<div class="small text-muted mb-1">'
5594                  + esc(liste.length + ' ' + t('Treffer')) + '</div>'
5595                : '')
5596            + (gek
5597                ? '<div class="small text-warning mb-1"><i class="fas fa-info-circle me-1"></i>'
5598                  + esc(t('{a} von {b} — ältere über die Suche finden.')
5599                        .replace('{a}', liste.length).replace('{b}', ges)) + '</div>'
5600                : '')
5601            + this._chatWahlLeiste()   /* ai-team-convdel-ui-v1 */
5602            + '<div class="list-group list-group-flush" '
5603            + 'style="max-height:52vh;overflow:auto">'
5604            + (c.conv === null
5605                ? '<div class="list-group-item active py-2"><div class="small fw-semibold">'
5606                  + esc(t('Aktuelles Gespräch')) + '</div>'
5607                  + '<div class="small">' + esc(c.msgs.length + ' ' + t('Nachrichten'))
5608                  + '</div></div>'
5609                : '')
5610            + zeilen + '</div></div></div>';
5611    }
5612
5613    static _akteTabChat(m) {
5614        return '<div class="row g-3">'
5615            + '<div class="col-12 col-lg-3">' + this._chatListeSpalte() + '</div>'
5616            + '<div class="col-12 col-lg-9">' + this._akteChat(m) + '</div>'
5617            + '</div>';
5618    }
5619
5620    /* ---- Mehrfachauswahl (ai-team-convdel-ui-v1) ----
5621       Michaels Auftrag (14.08.): "eine Moeglichkeit mit einer Checkbox viele
5622       Gespraeche auf einmal zu loeschen, sonst wird das zu voll". Bedienung
5623       bewusst identisch zu Postfach und Ablage - wer dort auswaehlt, kann es
5624       hier sofort.
5625
5626       Die Auswahl liegt am MODUL, nicht an den Kaestchen: die Liste wird bei
5627       jeder Aenderung neu gezeichnet, ein Zustand am Element waere dann weg.
5628       Gezaehlt und geloescht wird nur, was auch SICHTBAR ist - sonst stuende
5629       nach einem Suchwechsel "3 ausgewaehlt" ueber einer einzigen Zeile. */
5630
5631    static _chatWahlIds() {
5632        // Schnittmenge aus Auswahl und sichtbarer Liste (Schutz vor Resten).
5633        const sichtbar = (this._chatListe || []).map(x => x.id);
5634        return (this._chatWahl || []).filter(id => sichtbar.indexOf(id) >= 0);
5635    }
5636
5637    static _chatWahlUm(id) {
5638        if (!this._chatWahl) this._chatWahl = [];
5639        const i = this._chatWahl.indexOf(id);
5640        if (i >= 0) this._chatWahl.splice(i, 1); else this._chatWahl.push(id);
5641        this._render();
5642    }
5643
5644    static _chatWahlAlle() {
5645        const alle = (this._chatListe || []).map(x => x.id);
5646        this._chatWahl = this._chatWahlIds().length === alle.length ? [] : alle;
5647        this._render();
5648    }
5649
5650    static _chatWahlLeiste() {
5651        const wahl = this._chatWahlIds();
5652        if (!wahl.length) return '';
5653        const alle = (this._chatListe || []).length;
5654        return '<div class="border rounded p-2 mb-2 bg-light">'
5655            + '<div class="small fw-semibold mb-1">'
5656            + esc(wahl.length + ' ' + t(wahl.length === 1 ? 'Gespräch' : 'Gespräche')
5657                  + ' ' + t('ausgewählt')) + '</div>'
5658            + '<div class="d-flex align-items-center gap-2 flex-wrap">'
5659            /* ai-team-convdel-ui-v1b: NICHT btn-danger — die Hausregel
5660               '.btn-sm.btn-danger{height:30px;width:35px}' macht daraus einen
5661               reinen Symbolknopf fester Groesse; der Text lief heraus
5662               (gemessen 35px breit bei 53px Bedarf). btn-outline-danger ist
5663               von der Regel nicht erfasst, bleibt rot und traegt seinen Text.
5664               flex-shrink-0, damit ihn die Knoepfe daneben nicht quetschen. */
5665            + '<button class="btn btn-sm btn-outline-danger flex-shrink-0" '
5666            + 'data-action="chat-wahl-loeschen">'
5667            + '<i class="fas fa-trash me-1"></i>'
5668            + esc(t('{n} löschen').replace('{n}', wahl.length)) + '</button>'
5669            + '<button class="btn btn-sm btn-link p-0 ms-auto" data-action="chat-wahl-alle">'
5670            + t(wahl.length === alle ? 'Auswahl aufheben' : 'Alle auswählen') + '</button>'
5671            + (wahl.length === alle ? ''
5672                : '<button class="btn btn-sm btn-link p-0 text-muted" '
5673                  + 'data-action="chat-wahl-zu">' + t('Auswahl leeren') + '</button>')
5674            + '</div></div>';
5675    }
5676
5677    static _chatWahlLoeschenFragen() {
5678        const wahl = this._chatWahlIds();
5679        if (!wahl.length) return;
5680        const liste = (this._chatListe || []).filter(x => wahl.indexOf(x.id) >= 0);
5681        const nachrichten = liste.reduce((n, x) => n + (x.nachrichten || 0), 0);
5682        /* Zeigen, WAS geloescht wird - eine blosse Zahl laesst den Nutzer
5683           raten, ob er das Richtige angehakt hat. Bei vielen Zeilen die
5684           ersten fuenf und dann die Restzahl. */
5685        const vorschau = liste.slice(0, 5).map(x =>
5686            '<div class="text-truncate">' + esc(x.titel || t('Gespräch')) + '</div>').join('')
5687            + (liste.length > 5
5688                ? '<div class="text-muted">' + esc(t('… und {n} weitere')
5689                    .replace('{n}', liste.length - 5)) + '</div>'
5690                : '');
5691        this._modal('<div class="modal-header"><h5 class="modal-title">'
5692            + t('Gespräche löschen') + '</h5>'
5693            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
5694            + '<div class="modal-body"><p>'
5695            + esc(t('Sollen diese {n} Gespräche wirklich gelöscht werden?')
5696                  .replace('{n}', liste.length)) + '</p>'
5697            + '<div class="border rounded p-2 bg-light small" '
5698            + 'style="max-height:30vh;overflow:auto">' + vorschau + '</div>'
5699            + '<div class="text-muted small mt-2">'
5700            + esc(nachrichten + ' ' + t('Nachrichten')) + ' · '
5701            + t('Das lässt sich nicht rückgängig machen. Was der Mitarbeiter sich '
5702                + 'gemerkt hat, bleibt erhalten.') + '</div></div>'
5703            + '<div class="modal-footer"><button class="btn btn-secondary" '
5704            + 'data-action="modal-zu">' + t('Abbrechen') + '</button>'
5705            + '<button class="btn btn-danger" data-action="chat-wahl-loeschen-ja">'
5706            + t('Löschen') + '</button></div>');
5707    }
5708
5709    static async _chatWahlLoeschen() {
5710        const ids = this._chatWahlIds();
5711        if (!ids.length || this._chatWahlLaeuft) return;
5712        this._chatWahlLaeuft = true;
5713        let d;
5714        try {
5715            d = await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
5716                + '/conversations/loeschen', 'POST', { ids: ids });
5717        } catch (e) {
5718            this._chatWahlLaeuft = false;
5719            return this._modalFehler(e);
5720        }
5721        this._chatWahlLaeuft = false;
5722        this._modal('');
5723        // War das offene Gespräch dabei, wird ein neues begonnen — sonst
5724        // zeigte die Oberfläche weiter einen Verlauf, den es nicht mehr gibt.
5725        const c = this._chatZustand();
5726        if (c.conv && ids.indexOf(c.conv) >= 0) { c.conv = null; c.msgs = []; }
5727        this._chatWahl = [];
5728        await this._chatListeLaden();
5729        this._render();
5730        const n = (d && d.anzahl) || 0;
5731        let text = n + ' ' + t(n === 1 ? 'Gespräch gelöscht' : 'Gespräche gelöscht');
5732        /* Was NICHT geloescht wurde, muss dastehen - sonst zaehlt der Nutzer
5733           nach und findet mehr Zeilen vor, als er erwartet. */
5734        if (d && (d.uebersprungen || d.fehlgeschlagen)) {
5735            text += ' · ' + esc(((d.uebersprungen || 0) + (d.fehlgeschlagen || 0))
5736                + ' ' + t('übersprungen'));
5737        }
5738        if (d && d.gekappt) {
5739            text += ' · ' + t('Höchstens {n} auf einmal.').replace('{n}', d.grenze);
5740        }
5741        this._hinweis(text);
5742    }
5743
5744    // ---------- Gespräch löschen (ai-team-chatdel-v1) ----------
5745
5746    static _chatLoeschenFragen(convId) {
5747        const x = (this._chatListe || []).find(c => c.id === convId) || {};
5748        this._modal('<div class="modal-header"><h5 class="modal-title">'
5749            + t('Gespräch löschen') + '</h5>'
5750            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
5751            + '<div class="modal-body"><p>'
5752            + t('Soll dieses Gespräch wirklich gelöscht werden?') + '</p>'
5753            + '<div class="border rounded p-2 bg-light small">'
5754            + '<div class="fw-semibold">' + esc(x.titel || t('Gespräch')) + '</div>'
5755            + '<div class="text-muted">' + esc(x.nachrichten || 0) + ' '
5756            + t('Nachrichten') + '</div></div>'
5757            + '<div class="text-muted small mt-2">'
5758            + t('Das lässt sich nicht rückgängig machen. Was der Mitarbeiter sich '
5759                + 'gemerkt hat, bleibt erhalten.') + '</div></div>'
5760            + '<div class="modal-footer"><button class="btn btn-secondary" '
5761            + 'data-action="modal-zu">' + t('Abbrechen') + '</button>'
5762            + '<button class="btn btn-danger" data-action="chat-loeschen-ja" '
5763            + 'data-id="' + esc(convId) + '">' + t('Löschen') + '</button></div>');
5764    }
5765
5766    static async _chatLoeschen(convId) {
5767        try {
5768            await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
5769                + '/conversations/' + encodeURIComponent(convId), 'DELETE');
5770        } catch (e) { return this._modalFehler(e); }
5771        this._modal('');
5772        // War es das offene Gespräch, wird ein neues begonnen — sonst zeigte
5773        // die Oberfläche weiter einen Verlauf, den es nicht mehr gibt.
5774        const c = this._chatZustand();
5775        if (c.conv === convId) { c.conv = null; c.msgs = []; }
5776        await this._chatListeLaden();
5777        this._render();
5778    }
5779
5780    // ---------- Anhang herunterladen (ai-team-download-v1) ----------
5781
5782    static async _blobHolen(url, name, fortschritt) {
5783        const o = { headers: {} };
5784        try {
5785            const tok = await _token();
5786            if (tok) o.headers['Authorization'] = 'Bearer ' + tok;
5787        } catch (e) { /* Anmeldung übernimmt die App */ }
5788        const r = await fetch(url, o);
5789        if (!r.ok) {
5790            let msg = t('Fehler') + ' ' + r.status;
5791            try { const d = await r.json(); msg = d.error || msg; } catch (e) { /* egal */ }
5792            throw new Error(msg);
5793        }
5794        /* ai-team-dlspin-v1: Bei grossen Dateien (Videos, 10-20 MB) dauert
5795           das Holen spuerbar - die Anzeige bekommt den Stand in Prozent,
5796           sofern der Server die Groesse mitschickt. */
5797        const gesamt = parseInt(r.headers.get('Content-Length') || '0', 10) || 0;
5798        if (typeof fortschritt !== 'function' || !r.body || !r.body.getReader) {
5799            return await r.blob();
5800        }
5801        const leser = r.body.getReader();
5802        const teile = [];
5803        let n = 0, zuletzt = -1;
5804        for (;;) {
5805            const { done, value } = await leser.read();
5806            if (done) break;
5807            teile.push(value);
5808            n += value.length;
5809            if (gesamt > 0) {
5810                const pct = Math.min(99, Math.floor(n * 100 / gesamt));
5811                if (pct !== zuletzt) { zuletzt = pct; try { fortschritt(pct); } catch (e) { /* egal */ } }
5812            }
5813        }
5814        return new Blob(teile, { type: r.headers.get('Content-Type') || '' });
5815    }
5816
5817    /* ai-team-dlspin-v1 — Michaels Befund 30.09.: "beim Herunterladen fehlt
5818       ein Spinner". Nur das ZIP-Paket hatte einen. Einzeldatei, Mailanhang
5819       und Dokumentablage liefen ohne jede Rueckmeldung - man klickt, nichts
5820       passiert, man klickt nochmal. Der Zustand haengt an einem SCHLUESSEL,
5821       nicht am Knopf: die Akte wird waehrenddessen neu gezeichnet (Abfrage
5822       alle 15 s), ein am Knopf gesetzter Spinner waere dabei verschwunden.
5823       _dlMarken() traegt den Zustand nach jedem Zeichnen neu auf. */
5824    static _dlSchluessel(aktion, id) {
5825        if (aktion === 'ab-runter') return 'ab|' + this._akteId + '|' + id;
5826        if (aktion === 'pf-anhang') {
5827            const p = this._pfZustand ? this._pfZustand() : {};
5828            return 'pf|' + this._akteId + '|' + (p.ordner || '') + '|'
5829                + ((p.mail || {}).uid || '') + '|' + id;
5830        }
5831        if (aktion === 'dok-datei') {
5832            const d = this._dok || {};
5833            return 'dok|' + (d.id || '') + '|' + (d.pfad || '') + '|' + id;
5834        }
5835        return null;
5836    }
5837
5838    static async _mitLadeanzeige(aktion, id, arbeit) {
5839        if (!this._dlLaeuft) this._dlLaeuft = new Map();
5840        const k = this._dlSchluessel(aktion, id);
5841        if (!k) return arbeit(() => {});
5842        // Doppelklick waehrend des Ladens holt die Datei nicht ein zweites Mal.
5843        if (this._dlLaeuft.has(k)) return;
5844        this._dlLaeuft.set(k, { pct: null });
5845        this._dlMarken();
5846        try {
5847            await arbeit((pct) => {
5848                const z = this._dlLaeuft.get(k);
5849                if (z) { z.pct = pct; this._dlMarken(); }
5850            });
5851        } finally {
5852            this._dlLaeuft.delete(k);
5853            this._dlMarken();
5854        }
5855    }
5856
5857    static _dlMarken() {
5858        const laeuft = this._dlLaeuft || new Map();
5859        const sel = ['ab-runter', 'pf-anhang', 'dok-datei']
5860            .map(a => '#aiTeamRoot [data-action="' + a + '"], #aiTeamModal [data-action="' + a + '"]')
5861            .join(', ');
5862        document.querySelectorAll(sel).forEach(el => {
5863            const k = this._dlSchluessel(el.getAttribute('data-action'),
5864                                         el.getAttribute('data-id') || '');
5865            const z = k ? laeuft.get(k) : null;
5866            if (z) {
5867                if (!el.hasAttribute('data-dl-orig')) {
5868                    el.setAttribute('data-dl-orig', el.innerHTML);
5869                    const sym = el.querySelector('i.fas');
5870                    const sp = document.createElement('span');
5871                    const abstand = sym ? (sym.className.match(/\bme-\d\b/) || [''])[0] : '';
5872                    sp.className = 'spinner-border spinner-border-sm' + (abstand ? ' ' + abstand : '');
5873                    sp.setAttribute('role', 'status');
5874                    sp.style.width = sp.style.height = '.85rem';
5875                    sp.style.borderWidth = '.15em';
5876                    if (sym) sym.replaceWith(sp);
5876 else el.insertBefore(sp, el.firstChild);
5877                    el.disabled = true;
5878                    el.setAttribute('aria-busy', 'true');
5879                    el.setAttribute('data-dl-titel', el.getAttribute('title') || '');
5880                    el.setAttribute('title', t('Wird heruntergeladen …'));
5881                }
5882                let pz = el.querySelector('.ait-dlpct');
5883                if (z.pct != null) {
5884                    if (!pz) {
5885                        pz = document.createElement('span');
5886                        pz.className = 'ait-dlpct ms-1 small';
5887                        el.appendChild(pz);
5888                    }
5889                    pz.textContent = z.pct + ' %';
5890                }
5891            } else if (el.hasAttribute('data-dl-orig')) {
5892                el.innerHTML = el.getAttribute('data-dl-orig');
5893                el.removeAttribute('data-dl-orig');
5894                el.removeAttribute('aria-busy');
5895                el.disabled = false;
5896                const tt = el.getAttribute('data-dl-titel');
5897                if (tt) el.setAttribute('title', tt); else el.removeAttribute('title');
5898                el.removeAttribute('data-dl-titel');
5899            }
5900        });
5901    }
5902
5903    static async _speichernAls(blob, name) {
5904        const url = URL.createObjectURL(blob);
5905        const a = document.createElement('a');
5906        a.href = url;
5906 a.download = name || 'datei';
5907        document.body.appendChild(a); a.click(); a.remove();
5908        setTimeout(() => URL.revokeObjectURL(url), 4000);
5909    }
5910
5911    static async _pfAnhang(teil, name) {
5912        const p = this._pfZustand();
5913        return this._mitLadeanzeige('pf-anhang', String(teil), async (stand) => {
5914            try {
5915                const b = await this._blobHolen(
5916                    '/api/ai-team/employees/' + encodeURIComponent(this._akteId)
5917                    + '/mail/anhang?ordner=' + encodeURIComponent(p.ordner)
5918                    + '&uid=' + encodeURIComponent((p.mail || {}).uid || '')
5919                    + '&teil=' + encodeURIComponent(teil), name, stand);
5920                await this._speichernAls(b, name);
5921            } catch (e) { this._modalFehler(e); }
5922        });
5923    }
5924
5925    // ---------- Reiter: Dateien (ai-team-ablage-ui-v1) ----------
5926    // Michaels Auftrag: "neben dem Postfach brauchen wir auch eine
5927    // Dateiablage wie hier". Vorbild ist der Datei-Browser des Command
5928    // Center: Krümelpfad, Liste links, Vorschau rechts, Suche mit Inhalt.
5929    // Unterschied: hier wird auch geschrieben (Hochladen, Ordner, Löschen),
5930    // und der Bestand ist der, in den der Mitarbeiter im Takt selbst ablegt.
5931
5932    static get _ab() {
5933        if (!this.__ab) this.__ab = { pfad: '', daten: null, laden: false,
5934                                      sel: null, vorschau: null, suche: null,
5935                                      /* ai-team-ablage-paket-ui-v1 */
5936                                      wahl: [], packt: false,
5937                                      /* ablage-sort-v1 */
5938                                      sort: this._abSortLesen() };
5939        return this.__ab;
5940    }
5941    static set _ab(v) { this.__ab = v; }
5942
5943    /* ablage-sort-v1 (26.09.2026): Michaels Auftrag "in der Dateiuebersicht
5944       das Aenderungsdatum genau sehen und sortieren koennen". Die Akte-Ablage
5945       zeigte am Rechner nur Name und Groesse (das Datum kam vom Server, wurde
5946       aber nirgends gezeichnet), sortiert war fest nach Namen. Die Sortierung
5947       liegt in der Oberflaeche, damit der Server fuer Rechner und Handy
5948       dieselbe Liste liefert; die Wahl wird im Browser gemerkt - Rechner und
5949       Handy teilen denselben Schluessel. */
5950    static _abSortLesen() {
5951        let w = null;
5952        try { w = JSON.parse(localStorage.getItem('voiceone.aiteam.ablage.sort') || 'null'); }
5953        catch (e) { w = null; }
5954        if (!w || ['name', 'groesse', 'geaendert'].indexOf(w.feld) < 0) {
5955            return { feld: 'name', ab: false };
5956        }
5957        return { feld: w.feld, ab: !!w.ab };
5958    }
5959
5960    static _abSortSetzen(feld) {
5961        const a = this._ab;
5962        const alt = a.sort || this._abSortLesen();
5963        /* Gleiches Feld erneut -> Richtung wechseln. Neues Feld -> sinnvolle
5964           Vorgabe: Namen aufwaerts, Datum und Groesse abwaerts (das Neueste
5965           bzw. Groesste zuerst ist das, was man sucht). */
5966        const neu = alt.feld === feld ? { feld, ab: !alt.ab } : { feld, ab: feld !== 'name' };
5967        a.sort = neu;
5968        try { localStorage.setItem('voiceone.aiteam.ablage.sort', JSON.stringify(neu)); }
5969        catch (e) { /* egal */ }
5970        this._render();
5971    }
5972
5973    static _abSortieren(liste) {
5974        const w = this._ab.sort || this._abSortLesen();
5975        const r = w.ab ? -1 : 1;
5976        const nachName = (x, y) => String(x.name || '').localeCompare(
5977            String(y.name || ''), 'de', { sensitivity: 'base', numeric: true });
5978        return (liste || []).slice().sort((x, y) => {
5979            /* Ordner stehen immer vor Dateien - wie in jedem Dateimanager. */
5980            if (!!x.ordner !== !!y.ordner) return x.ordner ? -1 : 1;
5981            let d;
5982            if (w.feld === 'groesse') d = (Number(x.groesse) || 0) - (Number(y.groesse) || 0);
5983            else if (w.feld === 'geaendert') d = (Number(x.geaendert) || 0) - (Number(y.geaendert) || 0);
5984            else d = nachName(x, y);
5985            /* Gleichstand (z.B. zwei Ordner ohne Groesse): Name aufwaerts. */
5986            if (d === 0) return nachName(x, y);
5987            return d * r;
5988        });
5989    }
5990
5991    static _abZeit(sek) {
5992        const n = Number(sek);
5993        if (!n) return '';
5994        const d = new Date(n * 1000);
5995        if (isNaN(d.getTime())) return '';
5996        return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
5997            + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
5998    }
5999
6000    static _abSortLeiste() {
6001        const w = this._ab.sort || this._abSortLesen();
6002        const knopf = (feld, text) => {
6003            const aktiv = w.feld === feld;
6004            return '<button type="button" class="ait-absort' + (aktiv ? ' ait-absort-aktiv' : '')
6005                + '" data-action="ab-sort" data-id="' + feld + '" title="'
6006                + esc(t('Sortieren nach') + ' ' + text
6007                      + (aktiv ? ' – ' + t('Klick wechselt die Richtung') : '')) + '">'
6008                + esc(text)
6009                + (aktiv ? '<i class="fas ' + (w.ab ? 'fa-arrow-down' : 'fa-arrow-up') + ' ms-1"></i>' : '')
6010                + '</button>';
6011        };
6012        return '<div class="ait-absortz"><span class="me-1">' + t('Sortieren') + ':</span>'
6013            + knopf('name', t('Name'))
6014            + knopf('geaendert', t('Geändert'))
6015            + knopf('groesse', t('Größe'))
6016            + '</div>';
6017    }
6018
6019    static _abGroesse(n) {
6020        n = Number(n || 0);
6021        if (n < 1024) return n + ' B';
6022        if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
6023        if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
6024        return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
6025    }
6026
6027    static _abIcon(e) {
6028        /* ai-team-papierkorb-v1: Der Papierkorb bekommt sein eigenes Symbol -
6029           als gelber Ordner war er von einem Sachordner nicht zu
6030           unterscheiden. Alles DARIN wird gedaempft dargestellt. */
6031        if (e.ordner && e.pfad === '_geloescht')
6032            return '<i class="fas fa-trash-can text-secondary"></i>';
6033        if (e.geloescht && !e.ordner)
6034            return '<i class="fas fa-trash-can" style="color:#adb5bd"></i>';
6035        if (e.ordner) return '<i class="fas fa-folder" style="color:#f0ad4e"></i>';
6036        if (e.art === 'bild') return '<i class="fas fa-image text-primary"></i>';
6037        if (e.art === 'pdf') return '<i class="fas fa-file-pdf text-danger"></i>';
6038        if (e.art === 'video') return '<i class="fas fa-film text-primary"></i>';  // video-ki-v1
6039        if (e.art === 'text') return '<i class="fas fa-file-lines text-secondary"></i>';
6040        return '<i class="fas fa-file text-muted"></i>';
6041    }
6042
6043    static async _abLaden(pfad) {
6044        const a = this._ab;
6045        a.laden = true; a.suche = null; this._render();
6046        try {
6047            const d = await api('/api/ai-team/employees/'
6048                + encodeURIComponent(this._akteId) + '/ablage?pfad='
6049                + encodeURIComponent(pfad || ''));
6050            a.daten = d; a.pfad = d.pfad || ''; a.fehler = d.error || null;
6051        } catch (e) { a.daten = null; a.fehler = e.message; }
6052        /* ai-team-app-ui-v1: veroeffentlichte Anwendungen mitladen. Ein
6053           eigener try-Block, weil ein Fehler hier die Dateiansicht NICHT
6054           unbrauchbar machen darf - sie ist die Hauptsache. */
6055        try {
6056            const p = await api('/api/ai-team/apps');
6057            a.apps = p.apps || [];
6058            a.appsEigen = !!p.eigene_herkunft;
6059        } catch (e) { a.apps = null; }
6060        a.laden = false; a.sel = null; a.vorschau = null;
6061        /* ai-team-ablage-paket-ui-v1: Eine Auswahl gilt fuer die Liste, die
6062           man sieht. Sie ueber einen Ordnerwechsel mitzuschleppen hiesse,
6063           unsichtbare Dateien mitzuschicken. */
6064        a.wahl = [];
6065        a.loeschMeldung = null;   /* ai-team-ablage-sammelloeschen-v1 */
6066        this._render();
6067    }
6068
6069    static _abOeffnen(pfad) { this._abLaden(pfad); }
6070
6071    /* ai-team-app-ui-v1 -------------------------------------------------
6072       Michaels Befund (13.08.): der Mitarbeiter soll nach dem Speichern eine
6073       Adresse nennen. Genannt wird sie im Gespraech - aber ein Gespraech
6074       scrollt weg. Dieselbe Lehre wie beim unsichtbaren Auftrag: es braucht
6075       einen festen Ort, an dem der Inhaber die Adresse wiederfindet und die
6076       Anwendung auch wieder VOM NETZ NEHMEN kann. */
6077    static _abAppBlock() {
6078        const a = this._ab;
6079        const apps = a.apps;
6080        if (!apps || !apps.length) return '';
6081        const zeilen = apps.map(p => {
6082            const an = !!p.aktiv;
6083            return '<div class="d-flex flex-wrap align-items-center gap-2 py-2 border-top">'
6084                + '<div class="me-auto" style="min-width:0">'
6085                + '<div class="fw-semibold text-truncate">'
6086                + '<i class="fas fa-globe me-1 ' + (an ? 'text-success' : 'text-muted') + '"></i>'
6087                + esc(p.name || p.slug) + (an ? '' : ' <span class="badge bg-secondary">'
6088                    + t('abgeschaltet') + '</span>') + '</div>'
6089                + (an
6090                    ? '<a class="small text-break" href="' + esc(p.url) + '" target="_blank" '
6091                      + 'rel="noopener noreferrer">' + esc(p.url) + '</a>'
6092                    : '<span class="small text-muted text-break">' + esc(p.url) + '</span>')
6093                + '<div class="text-muted small">' + t('Ordner') + ': ' + esc(p.ordner)
6094                + ' · ' + esc(p.aufrufe || 0) + ' ' + t('Aufrufe')
6095                /* ai-team-appdaten-v1: ob die Anwendung sich etwas merken
6096                   darf, ist fuer den Inhaber eine eigene Information - er
6097                   hat dort seine Preisliste liegen. */
6098                + ' · ' + (p.speicher_an
6099                    ? '<span class="text-success">' + t('Datenspeicher an') + '</span>'
6100                    : t('kein Datenspeicher'))
6101                + '</div>'
6102                + '</div>'
6103                + '<button class="btn btn-sm btn-outline-secondary flex-shrink-0" '
6104                + 'data-action="app-kopieren" data-id="' + esc(p.url) + '" '
6105                + 'title="' + t('Adresse kopieren') + '"><i class="fas fa-copy"></i></button>'
6106                + '<button class="btn btn-sm btn-outline-secondary flex-shrink-0" '
6107                + 'data-action="app-speicher" data-id="' + esc(p.id) + '" '
6108                + 'title="' + t('Datenspeicher und PIN') + '">'
6109                + '<i class="fas fa-key"></i></button>'
6110                + '<button class="btn btn-sm btn-outline-' + (an ? 'warning' : 'success')
6111                + ' flex-shrink-0" data-action="app-umschalten" data-id="' + esc(p.id)
6112                + '" data-arg="' + (an ? '0' : '1') + '">'
6113                + (an ? t('Vom Netz nehmen') : t('Wieder freigeben')) + '</button>'
6114                + '</div>';
6115        }).join('');
6116        return '<div class="card mb-3"><div class="card-body py-2">'
6117            + '<div class="fw-semibold mb-1"><i class="fas fa-rocket me-1"></i>'
6118            + t('Veröffentlichte Anwendungen') + '</div>'
6119            + '<div class="text-muted small mb-1">'
6120            + (a.appsEigen
6121                ? t('Jeder, der die Adresse kennt, kann sie aufrufen.')
6122                : t('Jeder, der die Adresse kennt, kann sie aufrufen. Die Seite '
6123                    + 'läuft abgeschottet und kommt nicht an Ihre Unternehmensdaten.'))
6124            + '</div>'
6125            + zeilen + '</div></div>';
6126    }
6127
6128    /* ai-team-appdaten-v1 ------------------------------------------------
6129       Der Datenspeicher einer Anwendung. Bewusst KEINE Anzeige der PIN:
6130       sie liegt nur als Streuwert vor, jede Anzeige waere gelogen. Wer sie
6131       nicht mehr weiss, vergibt hier eine neue - das ist der einzige Weg
6132       zurueck, und er muss deshalb an einem festen Ort stehen. */
6133    static async _abAppSpeicher(id) {
6134        let stand;
6135        try {
6136            stand = await api('/api/ai-team/apps/' + encodeURIComponent(id) + '/speicher');
6137        } catch (e) { alert(e.message); return; }
6138        const kb = Math.round((stand.bytes || 0) / 1024);
6139        const lage = stand.speicher_an
6140            ? t('Der Datenspeicher ist eingeschaltet.')
6141            : t('Der Datenspeicher ist ausgeschaltet.');
6142        const inhalt = (stand.eintraege || 0) + ' ' + t('Einträge') + ' · ' + kb + ' KB';
6143        const pin = prompt(lage + '\n' + inhalt + '\n\n'
6144            + t('Neue PIN vergeben (mindestens 6 Zeichen). Leer lassen und '
6145                + 'bestätigen schaltet den Speicher ab — die Daten bleiben '
6146                + 'erhalten. Abbrechen ändert nichts.'), '');
6147        if (pin === null) return;
6148        try {
6149            const erg = await api('/api/ai-team/apps/' + encodeURIComponent(id) + '/speicher',
6150                                  'PUT', { pin: pin });
6151            this._hinweis(erg.speicher_an
6152                ? t('PIN gesetzt. Alte Anmeldungen sind damit ungültig.')
6153                : t('Datenspeicher abgeschaltet.'));
6154        } catch (e) { alert(e.message); return; }
6155        await this._abLaden(this._ab.pfad || '');
6156    }
6157
6158    static async _abAppUmschalten(id, an) {
6159        try {
6160            await api('/api/ai-team/apps/' + encodeURIComponent(id), 'PUT',
6161                      { aktiv: an === '1' });
6162        } catch (e) { alert(e.message); return; }
6163        await this._abLaden(this._ab.pfad || '');
6164    }
6165
6166    static _abAppKopieren(url) {
6167        try {
6168            navigator.clipboard.writeText(url);
6169            this._hinweis(t('Adresse kopiert.'));
6170        } catch (e) { window.prompt(t('Adresse'), url); }
6171    }
6172
6173    static async _abDatei(pfad) {
6174        const a = this._ab;
6175        a.sel = pfad; a.vorschau = { laden: true }; this._render();
6176        const alle = (a.suche ? a.suche.treffer : (a.daten || {}).eintraege) || [];
6177        const e = alle.find(x => x.pfad === pfad) || {};
6178        const url = '/api/ai-team/employees/' + encodeURIComponent(this._akteId)
6179            + '/ablage/datei?pfad=' + encodeURIComponent(pfad);
6180        try {
6181            if (e.art === 'text') {
6182                const d = await api('/api/ai-team/employees/'
6183                    + encodeURIComponent(this._akteId) + '/ablage/text?pfad='
6184                    + encodeURIComponent(pfad));
6185                a.vorschau = { art: 'text', name: d.name, text: d.text,
6186                               gekuerzt: d.gekuerzt, pfad: pfad, e: e };
6187            } else if (e.art === 'bild' || e.art === 'pdf') {
6188                // Der Server liefert bewusst immer als Download (nosniff).
6189                // Für die Vorschau bauen wir den Blob mit einem von UNS
6190                // gesetzten Typ — so kann eine hochgeladene HTML-Datei
6191                // niemals in unserer Herkunft ausgeführt werden.
6192                const roh = await this._blobHolen(url, e.name);
6193                const typ = e.art === 'pdf' ? 'application/pdf'
6194                    : ('image/' + (/\.(png|gif|webp|bmp|svg)$/i.test(e.name)
6195                        ? e.name.split('.').pop().toLowerCase().replace('svg', 'svg+xml')
6196                        : 'jpeg'));
6197                if (a.vorschau && a.vorschau.url) URL.revokeObjectURL(a.vorschau.url);
6198                const burl = URL.createObjectURL(new Blob([roh], { type: typ }));
6199                a.vorschau = { art: e.art, name: e.name, pfad: pfad, e: e,
6200                               url: burl, dokUrl: burl };
6201            } else if (e.art === 'buero') {
6202                // ai-team-buero-v1: Word/Excel/PowerPoint und die Altformate.
6203                // Bei einer Tabelle ist das Raster die Vorgabe — eine
6204                // Preisliste als Fließtext mit Tabulatoren wäre die
6205                // schlechtere Antwort auf "zeig mir die Datei".
6206                return this._abBueroAnsicht(pfad, this._abIstTabelle(e.name)
6207                    ? 'tabelle' : 'text', e);
6208            } else {
6209                a.vorschau = { art: 'datei', name: e.name, pfad: pfad, e: e };
6210            }
6211        } catch (err) { a.vorschau = { art: 'fehler', text: err.message, pfad: pfad, e: e }; }
6212        this._render();
6213    }
6214
6215    /* ai-team-buero-v1 ---------------------------------------------------
6216       Die Endungsliste steht bewusst AUCH hier und nicht nur im Server:
6217       welche Ansicht beim Klick geladen wird, muss die Oberfläche
6218       entscheiden, bevor sie irgendetwas geholt hat. Sie ist die kürzere
6219       Liste — nur Tabellen — und der Server weist alles andere ohnehin ab. */
6220    static _abIstTabelle(name) {
6221        return /\.(xlsx|xlsm|xls|xlsb|ods|fods|sxc|dif)$/i.test(String(name || ''));
6222    }
6223
6224    static async _abBueroAnsicht(pfad, modus, eintrag) {
6225        const a = this._ab;
6226        const alt = a.vorschau || {};
6227        const e = eintrag || alt.e || {};
6228        a.sel = pfad;
6229        a.vorschau = { laden: true, pfad: pfad };
6230        this._render();
6231        const basis = '/api/ai-team/employees/' + encodeURIComponent(this._akteId)
6232            + '/ablage/';
6233        try {
6234            if (modus === 'tabelle') {
6235                const d = await api(basis + 'tabelle?pfad=' + encodeURIComponent(pfad));
6236                a.vorschau = { art: 'bueroTabelle', name: d.name, pfad: pfad, e: e,
6237                               blaetter: d.blaetter || [], blatt: 0,
6238                               hinweis: d.hinweis || null, tabelle: true };
6239            } else {
6240                const d = await api(basis + 'text?pfad=' + encodeURIComponent(pfad));
6241                a.vorschau = { art: 'buero', name: d.name, pfad: pfad, e: e,
6242                               text: d.text, hinweis: d.hinweis || null,
6243                               gekuerzt: d.gekuerzt, format: d.format,
6244                               tabelle: !!d.tabelle };
6245            }
6246        } catch (err) {
6247            a.vorschau = { art: 'fehler', text: err.message, pfad: pfad, e: e };
6248        }
6249        this._render();
6250    }
6251
6252    static _abBlatt(pfad, nr) {
6253        const v = this._ab.vorschau;
6254        if (!v || v.art !== 'bueroTabelle') return;
6255        v.blatt = parseInt(nr, 10) || 0;
6256        this._render();
6257    }
6258
6259    static async _abPdfAnsicht(pfad, modus) {
6260        const a = this._ab;
6261        const v = a.vorschau || {};
6262        if (modus === 'dokument') {
6263            // Der Blob-Link der Dokumentansicht bleibt erhalten, solange die
6264            // Datei ausgewaehlt ist - kein zweiter Download noetig.
6265            if (v.dokUrl) { a.vorschau = Object.assign({}, v, { art: 'pdf', url: v.dokUrl }); this._render(); return; }
6266            return this._abOeffnen(pfad);
6267        }
6268        const merken = v.url || v.dokUrl;
6269        a.vorschau = { laden: true, pfad: pfad };
6270        this._render();
6271        try {
6272            const d = await api('/api/ai-team/employees/'
6273                + encodeURIComponent(this._akteId) + '/ablage/text?pfad='
6274                + encodeURIComponent(pfad));
6275            a.vorschau = { art: 'pdftext', name: d.name, text: d.text,
6276                           hinweis: d.hinweis || null, gekuerzt: d.gekuerzt,
6277                           pfad: pfad, e: v.e, dokUrl: merken };
6278        } catch (err) {
6279            a.vorschau = { art: 'fehler', text: err.message, pfad: pfad, e: v.e };
6280        }
6281        this._render();
6282    }
6283
6284    static async _abRunterladen(pfad) {
6285        const alle = (this._ab.suche ? this._ab.suche.treffer
6286                                     : (this._ab.daten || {}).eintraege) || [];
6287        const e = alle.find(x => x.pfad === pfad) || {};
6288        const emp = this._akteId;
6289        return this._mitLadeanzeige('ab-runter', pfad, async (stand) => {
6290            try {
6291                const b = await this._blobHolen('/api/ai-team/employees/'
6292                    + encodeURIComponent(emp) + '/ablage/datei?pfad='
6293                    + encodeURIComponent(pfad), e.name, stand);
6294                await this._speichernAls(b, e.name || 'datei');
6295            } catch (err) { this._modalFehler(err); }
6296        });
6297    }
6298
6299    static _abHochWaehlen() {
6300        const inp = document.createElement('input');
6301        inp.type = 'file'; inp.multiple = true;
6302        inp.onchange = () => this._abHochladen(Array.from(inp.files || []));
6303        inp.click();
6304    }
6305
6306    static async _abHochladen(dateien) {
6307        if (!dateien.length) return;
6308        const a = this._ab;
6309        let ok = 0; const fehler = [];
6310        for (const f of dateien) {
6311            const fd = new FormData();
6312            fd.append('datei', f); fd.append('ordner', a.pfad || '');
6313            const o = { method: 'POST', body: fd, headers: {} };
6314            try {
6315                const tok = await _token();
6316                if (tok) o.headers['Authorization'] = 'Bearer ' + tok;
6317            } catch (e) { /* egal */ }
6318            try {
6319                const r = await fetch('/api/ai-team/employees/'
6320                    + encodeURIComponent(this._akteId) + '/ablage/upload', o);
6321                const d = await r.json();
6322                if (!r.ok) fehler.push(f.name + ': ' + (d.error || r.status));
6323                else ok++;
6324            } catch (e) { fehler.push(f.name + ': ' + e.message); }
6325        }
6326        await this._abLaden(a.pfad);
6327        if (fehler.length) {
6328            this._modal('<div class="modal-header"><h5 class="modal-title">'
6329                + t('Nicht alles konnte abgelegt werden') + '</h5>'
6330                + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
6331                + '<div class="modal-body"><p>' + esc(ok + ' ' + t('von') + ' '
6332                + (ok + fehler.length) + ' ' + t('Dateien abgelegt.')) + '</p>'
6333                + '<ul class="small mb-0">'
6334                + fehler.map(x => '<li>' + esc(x) + '</li>').join('')
6335                + '</ul></div><div class="modal-footer">'
6336                + '<button class="btn btn-secondary" data-action="modal-zu">'
6337                + t('Schließen') + '</button></div>');
6338        }
6339    }
6340
6341    static _abOrdnerFragen() {
6342        this._modal('<div class="modal-header"><h5 class="modal-title">'
6343            + t('Neuer Ordner') + '</h5>'
6344            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
6345            + '<div class="modal-body">'
6346            + '<input class="form-control" id="abOrdnerName" maxlength="60" '
6347            + 'placeholder="' + esc(t('Name des Ordners')) + '">'
6348            + '<div class="text-muted small mt-2">' + esc(t('Wird angelegt in:')) + ' '
6349            + esc(this._ab.pfad || t('Hauptordner')) + '</div></div>'
6350            + '<div class="modal-footer"><button class="btn btn-secondary" '
6351            + 'data-action="modal-zu">' + t('Abbrechen') + '</button>'
6352            + '<button class="btn btn-primary" data-action="ab-ordner-anlegen">'
6353            + t('Anlegen') + '</button></div>');
6354        setTimeout(() => { const el = document.getElementById('abOrdnerName');
6355                           if (el) el.focus(); }, 60);
6356    }
6357
6358    static async _abOrdnerAnlegen() {
6359        const el = document.getElementById('abOrdnerName');
6360        const name = (el && el.value || '').trim();
6361        if (!name) { if (el) el.classList.add('is-invalid'); return; }
6362        try {
6363            await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
6364                + '/ablage/ordner', 'POST', { ordner: this._ab.pfad || '', name: name });
6365        } catch (e) { return this._modalFehler(e); }
6366        this._modal('');
6367        this._abLaden(this._ab.pfad);
6368    }
6369
6370    static _abLoeschenFragen(pfad) {
6371        const alle = (this._ab.suche ? this._ab.suche.treffer
6372                                     : (this._ab.daten || {}).eintraege) || [];
6373        const e = alle.find(x => x.pfad === pfad) || {};
6374        /* ai-team-papierkorb-v1 — DATENVERLUST BEHOBEN. Bisher stand hier
6375           (this._ab.daten||{}).im_papierkorb, also der Zustand des zuletzt
6376           GEOEFFNETEN ORDNERS. In der Suche ist das aber die Wurzel, auch
6377           wenn der Treffer im Papierkorb liegt. Folge: der Dialog sagte "wird
6378           verschoben und laesst sich zurueckholen", waehrend das Backend
6379           (loeschen(): schon_drin -> sp.remove) die Datei ENDGUELTIG loeschte.
6380           Ein Versprechen ohne Deckung. Der Eintrag selbst weiss es genau. */
6381        const drin = e.geloescht !== undefined
6382            ? !!e.geloescht
6383            : !!(this._ab.daten || {}).im_papierkorb;
6384        this._modal('<div class="modal-header"><h5 class="modal-title">'
6385            + (drin ? t('Endgültig löschen') : t('In den Papierkorb')) + '</h5>'
6386            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
6387            + '<div class="modal-body"><p>' + esc(e.name || pfad) + '</p>'
6388            + '<div class="text-muted small">'
6389            + (drin
6390                ? t('Das ist endgültig — der Eintrag liegt bereits im '
6391                    + 'Papierkorb und lässt sich danach nicht mehr zurückholen.')
6392                /* ai-team-papierkorb-v1: kein technischer Ordnern
6392ame mehr im
6393                   Text - und die Zusage stimmt jetzt, weil es das
6394                   Zurückholen tatsächlich gibt. */
6395                : t('Wird in den Papierkorb verschoben. Von dort können Sie '
6396                    + 'die Datei jederzeit wiederherstellen.'))
6397            + '</div></div><div class="modal-footer">'
6398            + '<button class="btn btn-secondary" data-action="modal-zu">'
6399            + t('Abbrechen') + '</button>'
6400            + '<button class="btn btn-danger" data-action="ab-del-ja" '
6401            + 'data-id="' + esc(pfad) + '">'
6402            + (drin ? t('Endgültig löschen') : t('Löschen')) + '</button></div>');
6403    }
6404
6405    static async _abWiederherstellen(pfad) {
6406        /* ai-team-papierkorb-v1: Bis hierher war der Papierkorb eine
6407           Einbahnstrasse - der Loeschdialog versprach das Zurueckholen,
6408           es gab dafuer aber weder Knopf noch Route noch Funktion. */
6409        let r;
6410        try {
6411            r = await api('/api/ai-team/employees/'
6412                + encodeURIComponent(this._akteId) + '/ablage/wiederherstellen',
6413                'POST', { pfad: pfad });
6414        } catch (e) { return this._modalFehler(e); }
6415        /* Wenn der Name belegt war, wurde ausgewichen. Das MUSS man sagen -
6416           sonst sucht der Inhaber eine Datei, die jetzt anders heisst. */
6417        if (r && r.umbenannt) {
6418            this._modal('<div class="modal-header"><h5 class="modal-title">'
6419                + t('Wiederhergestellt') + '</h5>'
6420                + '<button type="button" class="btn-close" data-action="modal-zu">'
6421                + '</button></div><div class="modal-body"><p>'
6422                + t('In Ihrer Ablage gab es schon eine Datei mit diesem Namen. '
6423                    + 'Die wiederhergestellte Datei heißt deshalb jetzt:')
6424                + '</p><p class="fw-semibold">' + esc(r.name) + '</p></div>'
6425                + '<div class="modal-footer">'
6426                + '<button class="btn btn-primary" data-action="modal-zu">'
6427                + t('Verstanden') + '</button></div>');
6428        }
6429        /* Zurueck in die Ablage-Ansicht: in der Suche waere der Treffer
6430           sonst weiterhin mit altem Pfad zu sehen. */
6431        this._ab.suche = null;
6432        this._abLaden('');
6433    }
6434
6435    static async _abLoeschen(pfad) {
6436        try {
6437            await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
6438                + '/ablage/datei', 'DELETE', { pfad: pfad });
6439        } catch (e) { return this._modalFehler(e); }
6440        this._modal('');
6441        this._abLaden(this._ab.pfad);
6442    }
6443
6444    static async _abSuchen() {
6445        const el = document.getElementById('abSuche');
6446        const q = (el && el.value || '').trim();
6447        if (!q) { this._ab.suche = null; return this._render(); }
6448        const inhalt = (document.getElementById('abSucheInhalt') || {}).checked;
6449        this._ab.laden = true; this._render();
6450        try {
6451            const d = await api('/api/ai-team/employees/'
6452                + encodeURIComponent(this._akteId) + '/ablage/suche?q='
6453                + encodeURIComponent(q) + (inhalt ? '&inhalt=1' : ''));
6454            this._ab.suche = { begriff: q, inhalt: !!inhalt, treffer: d.treffer || [],
6455                               gekappt: d.gekappt, max: d.max_treffer };
6456        } catch (e) { this._ab.suche = null; this._modalFehler(e); }
6457        this._ab.laden = false; this._ab.sel = null; this._ab.vorschau = null;
6458        this._ab.wahl = [];   /* ai-team-ablage-paket-ui-v1 */
6459        this._render();
6460    }
6461
6462    static async _abEinrichten() {
6463        try {
6464            await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
6465                + '/ablage/einrichten', 'POST', {});
6466        } catch (e) { return this._modalFehler(e); }
6467        // _akteAusstattungLaden statt _akteOeffnen: letzteres setzt den Reiter
6468        // bewusst auf 'chat' zurück — man würde aus den Dateien geworfen.
6469        await this._akteAusstattungLaden();
6470        this._abLaden('');
6471    }
6472
6473    static _abKruemel() {
6474        const a = this._ab;
6475        const teile = (a.pfad || '').split('/').filter(Boolean);
6476        let weg = '';
6477        const stuecke = ['<button class="btn btn-sm btn-link p-0" data-action="ab-oeffnen" '
6478            + 'data-id=""><i class="fas fa-hard-drive me-1"></i>' + t('Ablage') + '</button>'];
6479        for (const t2 of teile) {
6480            weg = weg ? weg + '/' + t2 : t2;
6481            stuecke.push('<span class="text-muted mx-1">›</span>'
6482                + '<button class="btn btn-sm btn-link p-0" data-action="ab-oeffnen" '
6483                + 'data-id="' + esc(weg) + '">' + esc(t2) + '</button>');
6484        }
6485        return '<div class="d-flex align-items-center flex-wrap">' + stuecke.join('') + '</div>';
6486    }
6487
6488
6489    /* ---- Mehrfachauswahl & Sammel-Download (ai-team-ablage-paket-ui-v1) ----
6490       Michaels Auftrag (13.08.): "bei den dateien brauchen wir noch eine
6491       download moeglichkeit auch bitte mit einer checkbox was man alles
6492       haben will". Bedienung bewusst identisch zum Postfach - wer dort
6493       Nachrichten auswaehlt, kann es hier sofort. */
6494
6495    static _abListe() {
6496        const a = this._ab;
6497        /* ablage-sort-v1: EINE Stelle sortiert - Liste, Auswahl und Paket
6498           sehen dieselbe Reihenfolge. */
6499        return this._abSortieren((a.suche ? a.suche.treffer : (a.daten || {}).eintraege) || []);
6500    }
6501
6502    static _abWahl(pfad) {
6503        const a = this._ab;
6504        if (!a.wahl) a.wahl = [];
6505        const i = a.wahl.indexOf(pfad);
6506        if (i >= 0) a.wahl.splice(i, 1); else a.wahl.push(pfad);
6507        this._render();
6508    }
6509
6510    static _abAlle() {
6511        const a = this._ab;
6512        const alle = this._abListe().map(e => e.pfad);
6513        a.wahl = (a.wahl || []).length === alle.length ? [] : alle;
6514        this._render();
6515    }
6516
6517    static async _abPaket() {
6518        const a = this._ab;
6519        const pfade = (a.wahl || []).slice();
6520        if (!pfade.length || a.packt) return;
6521        a.packt = true; this._render();
6522        try {
6523            const o = { method: 'POST', headers: { 'Content-Type': 'application/json' },
6524                        body: JSON.stringify({ pfade: pfade }) };
6525            try {
6526                const tok = await _token();
6527                if (tok) o.headers['Authorization'] = 'Bearer ' + tok;
6528            } catch (e) { /* Anmeldung übernimmt die App */ }
6529            const r = await fetch('/api/ai-team/employees/'
6530                + encodeURIComponent(this._akteId) + '/ablage/paket', o);
6531            if (!r.ok) {
6532                let msg = t('Fehler') + ' ' + r.status;
6533                try { const d = await r.json(); msg = d.error || msg; } catch (e) { /* egal */ }
6534                throw new Error(msg);
6535            }
6536            // Dateiname aus dem Kopf lesen - der Server vergibt ihn (Ordner-
6537            // name, Dateiname oder Datum), damit hier keine zweite Auslegung
6538            // derselben Regel entsteht.
6539            const cd = r.headers.get('Content-Disposition') || '';
6540            const tr = /filename\*=UTF-8''([^;]+)/i.exec(cd);
6541            let name = 'Dateien.zip';
6542            try { if (tr) name = decodeURIComponent(tr[1]); } catch (e) { /* egal */ }
6543            const anzahl = r.headers.get('X-Ablage-Anzahl');
6544            const b = await r.blob();
6545            await this._speichernAls(b, name);
6546            this._hinweis(anzahl
6547                ? (anzahl + ' ' + (anzahl === '1' ? t('Datei') : t('Dateien'))
6548                   + ' ' + t('heruntergeladen') + ' (' + this._abGroesse(b.size) + ')')
6549                : t('Heruntergeladen.'));
6550        } catch (e) { this._modalFehler(e); }
6551        a.packt = false; this._render();
6552    }
6553
6554    static _abSammelleiste() {
6555        const a = this._ab;
6556        const wahl = a.wahl || [];
6557        if (!wahl.length) return '';
6558        const alle = this._abListe();
6559        const gewaehlt = alle.filter(e => wahl.indexOf(e.pfad) >= 0);
6560        const ordner = gewaehlt.filter(e => e.ordner).length;
6561        // Ordnergroessen kennt die Liste nicht - lieber ehrlich "+ Ordner"
6562        // schreiben als eine Zahl zeigen, die zu klein ist.
6563        const bytes = gewaehlt.reduce((n, e) => n + (e.ordner ? 0 : (e.groesse || 0)), 0);
6564        const groesse = this._abGroesse(bytes) + (ordner ? ' + ' + t('Ordner') : '');
6565        return '<div class="d-flex flex-wrap align-items-center gap-2 p-2 mb-2 rounded-3" '
6566            + 'style="background:#eef4ff;border:1px solid #cddbff">'
6567            + '<span class="fw-semibold small">' + esc(wahl.length) + ' '
6568            + t(wahl.length === 1 ? 'Eintrag ausgewählt' : 'Einträge ausgewählt')
6569            + '</span>'
6570            + '<span class="text-muted small">' + esc(groesse) + '</span>'
6571            + '<button class="btn btn-sm btn-primary" data-action="ab-paket"'
6572            + (a.packt ? ' disabled' : '') + '>'
6573            + (a.packt
6574                ? '<span class="spinner-border spinner-border-sm me-1"></span>'
6575                  + t('Wird gepackt …')
6576                : '<i class="fas fa-download me-1"></i>'
6577                  + t(wahl.length === 1 && !ordner ? 'Herunterladen'
6578                                                   : 'Als ZIP herunterladen'))
6579            + '</button>'
6580            /* ai-team-ablage-sammelloeschen-v1: Michaels Befund 25.09. -
6581               markieren ging, loeschen nur Zeile fuer Zeile. */
6582            + '<button class="btn btn-sm btn-outline-danger" data-action="ab-sammel-del"'
6583            + (a.packt || a.loescht ? ' disabled' : '') + '>'
6584            + (a.loescht
6585                ? '<span class="spinner-border spinner-border-sm me-1"></span>'
6586                  + t('Wird gelöscht …')
6587                : '<i class="fas fa-trash me-1"></i>' + t('Löschen'))
6588            + '</button>'
6589            + '<button class="btn btn-sm btn-link ms-auto" data-action="ab-wahl-alle">'
6590            + t(wahl.length === alle.length ? 'Auswahl aufheben' : 'Alle auswählen')
6591            + '</button>'
6592            + (wahl.length === alle.length ? ''
6593                : '<button class="btn btn-sm btn-link text-muted" data-action="ab-wahl-zu">'
6594                  + t('Auswahl leeren') + '</button>')
6595            + '</div>';
6596    }
6597
6598    /* ---- Sammel-Loeschen (ai-team-ablage-sammelloeschen-v1) ----
6599       Dieselbe Regel wie beim einzelnen Loeschen, am Server in EINER
6600       Funktion: ausserhalb des Papierkorbs verschieben, darin endgueltig.
6601       Der Dialog muss deshalb BEIDES ehrlich zaehlen - aus der Suche kann
6602       eine Auswahl Eintraege aus dem Papierkorb und von ausserhalb
6603       mischen. Ob ein Eintrag im Papierkorb liegt, sagt der Eintrag selbst
6604       (e.geloescht), nicht der gerade geoeffnete Ordner - genau diese
6605       Verwechslung war beim Einzel-Loeschen schon einmal ein Datenverlust
6606       (ai-team-papierkorb-v1). */
6607    static _abSammelAufteilung() {
6608        const a = this._ab;
6609        const wahl = a.wahl || [];
6610        const gewaehlt = this._abListe().filter(e => wahl.indexOf(e.pfad) >= 0);
6611        const istKorb = e => !!e.ordner && e.pfad === '_geloescht';
6612        const imKorb = e => (e.geloescht !== undefined
6613            ? !!e.geloescht : !!(a.daten || {}).im_papierkorb);
6614        const rest = gewaehlt.filter(e => !istKorb(e));
6615        return {
6616            korb: gewaehlt.filter(istKorb),
6617            endgueltig: rest.filter(imKorb),
6618            verschieben: rest.filter(e => !imKorb(e)),
6619            ordner: rest.filter(e => e.ordner).length
6620        };
6621    }
6622
6623    static _abAnzahl(n, eins, viele) {
6624        return n + ' ' + t(n === 1 ? eins : viele);
6625    }
6626
6627    static _abSammelLoeschenFragen() {
6628        const auf = this._abSammelAufteilung();
6629        const alle = auf.verschieben.concat(auf.endgueltig);
6630        if (!alle.length) {
6631            /* Nur der Papierkorb selbst ausgewaehlt: den leert dieser Weg
6632               bewusst nicht - dafuer gibt es darin das Loeschen je Eintrag. */
6633            return this._modal('<div class="modal-header"><h5 class="modal-title">'
6634                + t('Nichts zu löschen') + '</h5><button type="button" '
6635                + 'class="btn-close" data-action="modal-zu"></button></div>'
6636                + '<div class="modal-body"><p>'
6637                + t('Der Papierkorb selbst wird über die Auswahl nicht gelöscht. '
6638                    + 'Öffnen Sie ihn und wählen Sie die Einträge darin aus.')
6639                + '</p></div><div class="modal-footer">'
6640                + '<button class="btn btn-primary" data-action="modal-zu">'
6641                + t('Verstanden') + '</button></div>');
6642        }
6643        const nurEndgueltig = !auf.verschieben.length;
6644        const zeilen = [];
6645        if (auf.verschieben.length) {
6646            zeilen.push('<p class="mb-2">'
6647                + esc(this._abAnzahl(auf.verschieben.length,
6648                                     'Eintrag wird', 'Einträge werden'))
6649                + ' ' + t('in den Papierkorb verschoben. Von dort lässt sich '
6650                          + 'jederzeit alles wiederherstellen.') + '</p>');
6651        }
6652        if (auf.endgueltig.length) {
6653            zeilen.push('<div class="alert alert-danger py-2 small mb-2">'
6654                + '<i class="fas fa-triangle-exclamation me-1"></i>'
6655                + esc(this._abAnzahl(auf.endgueltig.length,
6656                    'Eintrag liegt bereits im Papierkorb und wird endgültig gelöscht.',
6657                    'Einträge liegen bereits im Papierkorb und werden endgültig gelöscht.'))
6658                + ' ' + t('Das lässt sich nicht rückgängig machen.') + '</div>');
6659        }
6660        if (auf.ordner) {
6661            zeilen.push('<div class="text-muted small mb-2">'
6662                + t('Ausgewählte Ordner werden mit ihrem gesamten Inhalt gelöscht.')
6663                + '</div>');
6664        }
6665        if (auf.korb.length) {
6666            zeilen.push('<div class="text-muted small mb-2">'
6667                + t('Der Papierkorb selbst ist mit ausgewählt – er bleibt unangetastet.')
6668                + '</div>');
6669        }
6670        const zeig = alle.slice(0, 8);
6671        zeilen.push('<ul class="small mb-0 ps-3">'
6672            + zeig.map(e => '<li class="text-truncate">'
6673                + (e.ordner ? '<i class="fas fa-folder text-muted me-1"></i>' : '')
6674                + esc(this._abAnzeigeName(e)) + '</li>').join('')
6675            + (alle.length > zeig.length
6676                ? '<li class="text-muted">' + t('und') + ' '
6677                  + esc(alle.length - zeig.length) + ' ' + t('weitere') + '</li>'
6678                : '')
6679            + '</ul>');
6680        this._modal('<div class="modal-header"><h5 class="modal-title">'
6681            + (nurEndgueltig ? t('Endgültig löschen') : t('Auswahl löschen')) + '</h5>'
6682            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
6683            + '<div class="modal-body">' + zeilen.join('') + '</div>'
6684            + '<div class="modal-footer">'
6685            + '<button class="btn btn-secondary" data-action="modal-zu">'
6686            + t('Abbrechen') + '</button>'
6687            + '<button class="btn btn-danger" data-action="ab-sammel-del-ja">'
6688            + '<i class="fas fa-trash me-1"></i>'
6689            + esc(nurEndgueltig
6690                ? t('Endgültig löschen') + ' (' + alle.length + ')'
6691                : t('Löschen') + ' (' + alle.length + ')')
6692            + '</button></div>');
6693    }
6694
6695    static async _abSammelLoeschen() {
6696        const a = this._ab;
6697        if (a.loescht) return;
6698        const auf = this._abSammelAufteilung();
6699        const pfade = auf.verschieben.concat(auf.endgueltig).map(e => e.pfad);
6700        this._modal('');
6701        if (!pfade.length) return;
6702        a.loescht = true; this._render();
6703        let r;
6704        try {
6705            r = await api('/api/ai-team/employees/'
6706                + encodeURIComponent(this._akteId) + '/ablage/loeschen',
6707                'POST', { pfade: pfade });
6708        } catch (e) {
6709            a.loescht = false; this._render();
6710            return this._modalFehler(e);
6711        }
6712        a.loescht = false;
6713        /* Wie beim einzelnen Loeschen: Liste frisch laden. Eine Suche wird
6714           dabei verlassen - ihre Treffer stuenden sonst mit alten Pfaden da. */
6715        await this._abLaden(a.pfad);
6716        const teile = [];
6717        if (r.verschoben) teile.push(this._abAnzahl(r.verschoben,
6718            'Eintrag in den Papierkorb verschoben', 'Einträge in den Papierkorb verschoben'));
6719        if (r.endgueltig) teile.push(this._abAnzahl(r.endgueltig,
6720            'Eintrag endgültig gelöscht', 'Einträge endgültig gelöscht'));
6721        a.loeschMeldung = {
6722            text: teile.join(' · ') || t('Nichts gelöscht.'),
6723            fehler: (r.fehler || []).slice()
6724        };
6725        this._render();
6726    }
6727
6728    static _abLoeschMeldung() {
6729        const m = this._ab.loeschMeldung;
6730        if (!m) return '';
6731        const fehler = m.fehler || [];
6732        return '<div class="alert ' + (fehler.length ? 'alert-warning' : 'alert-success')
6733            + ' py-2 px-2 small mb-2 d-flex align-items-start gap-2">'
6734            + '<div class="flex-grow-1"><div>' + esc(m.text) + '</div>'
6735            + (fehler.length
6736                ? '<div class="mt-1 fw-semibold">'
6737                  + esc(this._abAnzahl(fehler.length, 'Eintrag konnte',
6738                                       'Einträge konnten'))
6739                  + ' ' + t('nicht gelöscht werden:') + '</div><ul class="mb-0 ps-3">'
6740                  + fehler.slice(0, 10).map(f => '<li>' + esc(f.name || f.pfad)
6741                      + ' – ' + esc(f.fehler) + '</li>').join('') + '</ul>'
6742                : '')
6743            + '</div><button type="button" class="btn-close btn-sm" '
6744            + 'data-action="ab-meldung-zu" aria-label="' + esc(t('Schließen'))
6745            + '"></button></div>';
6746    }
6747
6748    /* ai-team-papierkorb-v1: Der technische Ordnername '_geloescht' hat in
6749       der Oberflaeche nichts zu suchen. */
6750    static _abPfadText(p) {
6751        return String(p || '').split('/')
6752            .map(teil => teil === '_geloescht' ? t('Papierkorb') : teil)
6753            .join(' / ');
6754    }
6755
6756    static _abAnzeigeName(e) {
6757        return (e.ordner && e.pfad === '_geloescht') ? t('Papierkorb') : e.name;
6758    }
6759
6760    static _abZeile(e, mitPfad) {
6761        const a = this._ab;
6762        const aktiv = !e.ordner && a.sel === e.pfad;
6763        const gewaehlt = (a.wahl || []).indexOf(e.pfad) >= 0;
6764        /* ai-team-papierkorb-v1: Michaels Befund 07.09. - eine geloeschte
6765           Datei sah in der Suche aus wie jede andere, nur mit '_geloescht'
6766           als grauer Zeile darunter. Der Eindruck war "das Loeschen hat
6767           nicht funktioniert". Jetzt traegt der Eintrag sein eigenes
6768           Kennzeichen vom Server (e.geloescht) und wird sichtbar
6769           abgesetzt: geduempft, mit Papierkorb-Symbol und Merker. */
6770        const weg = !!e.geloescht;
6771        const istKorb = e.ordner && e.pfad === '_geloescht';
6772        return '<div class="ait-abrow' + (aktiv ? ' ait-abrow-aktiv' : '')
6773            + (gewaehlt ? ' ait-abrow-wahl' : '')
6774            + (weg && !istKorb ? ' ait-abrow-weg' : '') + '">'
6775            /* ai-team-ablage-paket-ui-v1: Auch Ordner sind waehlbar - sie
6776               kommen mit ihrem gesamten Inhalt ins Paket. */
6777            + '<input type="checkbox" class="form-check-input ait-abcb flex-shrink-0"'
6778            + (gewaehlt ? ' checked' : '') + ' data-action="ab-wahl" data-id="'
6779            + esc(e.pfad) + '" title="' + esc(t('Auswählen')) + '">'
6780            + '<div class="ait-abklick" data-action="' + (e.ordner ? 'ab-oeffnen' : 'ab-datei')
6781            + '" data-id="' + esc(e.pfad) + '">'
6782            + '<span class="ait-abic">' + this._abIcon(e) + '</span>'
6783            + '<span class="ait-abnm">'
6784            /* Merker VOR dem Namen: er wird auch dann gelesen, wenn der
6785               Name lang ist und rechts abgeschnitten wird. */
6786            + (weg && !istKorb
6787                ? '<span class="ait-abweg-merker">' + t('Gelöscht') + '</span>' : '')
6788            + '<span class="' + (weg && !istKorb ? 'ait-abweg-name' : '') + '">'
6789            + esc(this._abAnzeigeName(e)) + '</span>'
6790            + (mitPfad && e.pfad.indexOf('/') > -1
6791                ? '<span class="text-muted small d-block">'
6792                  + esc(this._abPfadText(e.pfad.slice(0, e.pfad.lastIndexOf('/'))))
6793                  + '</span>' : '')
6794            + '</span>'
6795            /* ablage-sort-v1: Aenderungsdatum mit Uhrzeit - auch fuer Ordner,
6796               dort sagt es, wann zuletzt etwas hineingelegt wurde. */
6797            + '<span class="ait-abdt" title="' + esc(t('Geändert')) + '">'
6798            + esc(this._abZeit(e.geaendert)) + '</span>'
6799            + '<span class="ait-absz">' + (e.ordner ? '' : this._abGroesse(e.groesse))
6800            + '</span></div>'
6801            /* ai-team-ablage-paket-ui-v1: Einzeln herunterladen ging bisher NUR
6802               ueber die Vorschau rechts - fuer eine Datei, die man gar nicht
6803               ansehen will, ein Umweg. */
6804            + (e.ordner ? ''
6805                : '<button class="btn btn-sm btn-link p-0 text-muted flex-shrink-0 me-1" '
6806                  + 'title="' + esc(t('Herunterladen')) + '" data-action="ab-runter" '
6807                  + 'data-id="' + esc(e.pfad) + '"><i class="fas fa-download"></i></button>')
6808            /* ai-team-papierkorb-v1: Im Papierkorb ist Umbenennen sinnlos -
6809               dort will man zurueckholen. Und der Loeschknopf sagt jetzt,
6810               dass er endgueltig ist. */
6811            /* ai-team-papierkorb-v1b: Der Papierkorb selbst bekommt AUCH
6812               kein Umbenennen - der Dienst weist es ausdruecklich ab
6813               ("Der Papierkorb laesst sich nicht umbenennen"), der Knopf
6814               fuehrte also sicher in eine Fehlermeldung. Im Bild aufgefallen,
6815               kein Logiktest konnte das zeigen. Mobil war es schon ric
6815htig. */
6816            + (istKorb ? ''
6817               : weg
6818                ? '<button class="btn btn-sm btn-link p-0 flex-shrink-0 me-1 '
6819                  + 'text-success" title="' + esc(t('Wiederherstellen'))
6820                  + '" data-action="ab-zurueck" data-id="' + esc(e.pfad) + '">'
6821                  + '<i class="fas fa-rotate-left"></i></button>'
6822                : '<button class="btn btn-sm btn-link p-0 text-muted flex-shrink-0 me-1" '
6823                  + 'title="' + esc(t('Umbenennen')) + '" data-action="ab-uben" '
6824                  + 'data-id="' + esc(e.pfad) + '" data-arg="' + esc(e.name) + '">'
6825                  + '<i class="fas fa-pen"></i></button>')
6826            + (istKorb ? ''
6827                : '<button class="btn btn-sm btn-link p-0 flex-shrink-0 me-1 '
6828                  + (weg ? 'text-danger' : 'text-muted') + '" title="'
6829                  + esc(t(weg ? 'Endgültig löschen' : 'Löschen'))
6830                  + '" data-action="ab-del" data-id="' + esc(e.pfad) + '">'
6831                  + '<i class="fas fa-trash"></i></button>')
6832            + '</div>';
6833    }
6834
6835    static _abVorschauHtml() {
6836        const v = this._ab.vorschau;
6837        if (!v) {
6838            return '<div class="ait-abempty">'
6839                + esc(t('Wählen Sie links eine Datei zur Vorschau.')) + '</div>';
6840        }
6841        if (v.laden) return '<div class="ait-abempty">' + t('Wird geladen …') + '</div>';
6842        const kopf = '<div class="ait-abphead"><span class="fw-semibold text-truncate">'
6843            + esc(v.name || '') + '</span>'
6844            // ai-team-pdftext-ui-v1: Bei PDF laesst sich zwischen Dokument und
6845            // ausgelesenem Text umschalten - das ist genau der Text, den der
6846            // Mitarbeiter liest.
6847            + (v.art === 'pdf' || v.art === 'pdftext'
6848                ? '<div class="btn-group btn-group-sm ms-auto flex-shrink-0" role="group">'
6849                  + '<button class="btn btn-outline-secondary'
6850                  + (v.art === 'pdf' ? ' active' : '') + '" data-action="ab-pdfansicht" '
6851                  + 'data-id="' + esc(v.pfad) + '" data-arg="dokument">'
6852                  + t('Dokument') + '</button>'
6853                  + '<button class="btn btn-outline-secondary'
6854                  + (v.art === 'pdftext' ? ' active' : '') + '" data-action="ab-pdfansicht" '
6855                  + 'data-id="' + esc(v.pfad) + '" data-arg="text">'
6856                  + t('Als Text') + '</button></div>'
6857                : '')
6858            // ai-team-buero-v1: derselbe Umschalter für Tabellen — Raster
6859            // oder ausgelesener Text. Er erscheint nur, wenn es wirklich
6860            // eine Tabelle ist; bei Word und PowerPoint gibt es nichts
6861            // umzuschalten.
6862            + ((v.art === 'bueroTabelle' || v.art === 'buero') && v.tabelle
6863                ? '<div class="btn-group btn-group-sm ms-auto flex-shrink-0" role="group">'
6864                  + '<button class="btn btn-outline-secondary'
6865                  + (v.art === 'bueroTabelle' ? ' active' : '')
6866                  + '" data-action="ab-bueroansicht" '
6867                  + 'data-id="' + esc(v.pfad) + '" data-arg="tabelle">'
6868                  + t('Tabelle') + '</button>'
6869                  + '<button class="btn btn-outline-secondary'
6870                  + (v.art === 'buero' ? ' active' : '')
6871                  + '" data-action="ab-bueroansicht" '
6872                  + 'data-id="' + esc(v.pfad) + '" data-arg="text">'
6873                  + t('Als Text') + '</button></div>'
6874                : '')
6875            + '<button class="btn btn-sm btn-outline-secondary'
6876            + (v.art === 'pdf' || v.art === 'pdftext'
6877               || ((v.art === 'bueroTabelle' || v.art === 'buero') && v.tabelle)
6878                ? ' ms-2' : ' ms-auto')
6879            + ' flex-shrink-0" '
6880            + 'data-action="ab-runter" data-id="' + esc(v.pfad) + '">'
6881            + '<i class="fas fa-download me-1"></i>' + t('Herunterladen') + '</button></div>';
6882        let koerper;
6883        if (v.art === 'fehler') {
6884            koerper = '<div class="alert alert-warning m-3">' + esc(v.text) + '</div>';
6885        } else if (v.art === 'text') {
6886            koerper = (v.gekuerzt
6887                ? '<div class="alert alert-info m-2 py-1 small">'
6888                  + t('Es wird nur der Anfang der Datei gezeigt.') + '</div>' : '')
6889                + '<pre class="ait-abtxt">' + esc(v.text || '') + '</pre>';
6890        } else if (v.art === 'bild') {
6891            koerper = '<div class="p-3 text-center"><img src="' + esc(v.url)
6892                + '" alt="" style="max-width:100%;max-height:60vh"></div>';
6893        } else if (v.art === 'pdf') {
6894            koerper = '<iframe class="ait-abpdf" src="' + esc(v.url) + '"></iframe>';
6895        } else if (v.art === 'pdftext') {
6896            koerper = (v.hinweis
6897                ? '<div class="alert alert-warning m-2 py-1 small">'
6898                  + esc(v.hinweis) + '</div>' : '')
6899                + (v.gekuerzt
6900                ? '<div class="alert alert-info m-2 py-1 small">'
6901                  + t('Es wird nur der Anfang des Dokuments gezeigt.') + '</div>' : '')
6902                + '<pre class="ait-abtxt">' + esc(v.text || '') + '</pre>';
6903        } else if (v.art === 'buero') {
6904            // ai-team-buero-v1. Der Hinweis steht OBEN und nicht am Ende:
6905            // "es wird nur der Anfang gezeigt" ist wertlos, wenn man erst
6906            // 400 Zeilen scrollen muss, um ihn zu finden.
6907            koerper = (v.hinweis
6908                ? '<div class="alert alert-info m-2 py-1 small">'
6909                  + esc(v.hinweis) + '</div>' : '')
6910                + '<pre class="ait-abtxt">' + esc(v.text || '') + '</pre>';
6911        } else if (v.art === 'bueroTabelle') {
6912            const bl = (v.blaetter || [])[v.blatt || 0] || { zeilen: [] };
6913            const zeilen = bl.zeilen || [];
6914            // Erste Zeile als Kopfzeile: bei Geschäftstabellen ist das der
6915            // Regelfall, und eine fixierte Kopfzeile ist der eigentliche
6916            // Unterschied zwischen "lesbar" und "Zahlenwüste".
6917            const kopfz = zeilen.length ? zeilen[0] : [];
6918            const rest = zeilen.slice(1);
6919            const reiter = (v.blaetter || []).length > 1
6920                ? '<div class="ait-abblaetter">'
6921                  + v.blaetter.map((b, i) =>
6922                      '<button class="ait-abblatt'
6923                      + (i === (v.blatt || 0) ? ' ait-abblatt-aktiv' : '')
6924                      + '" data-action="ab-blatt" data-id="' + esc(v.pfad)
6925                      + '" data-arg="' + i + '">' + esc(b.name) + '</button>').join('')
6926                  + '</div>'
6927                : '';
6928            koerper = reiter
6929                + (v.hinweis
6930                    ? '<div class="alert alert-info m-2 py-1 small">'
6931                      + esc(v.hinweis) + '</div>' : '')
6932                + (zeilen.length
6933                    ? '<div class="ait-abtabwrap"><table class="ait-abtab">'
6934                      + '<thead><tr><th class="ait-abnr"></th>'
6935                      + kopfz.map(z => '<th>' + esc(z) + '</th>').join('')
6936                      + '</tr></thead><tbody>'
6937                      + rest.map((r, i) => '<tr><td class="ait-abnr">' + (i + 2)
6938                          + '</td>'
6939                          + r.map(z => '<td>' + esc(z) + '</td>').join('')
6940                          + '</tr>').join('')
6941                      + '</tbody></table></div>'
6942                    : '<div class="ait-abempty">'
6943                      + esc(t('Dieses Blatt ist leer.')) + '</div>');
6944        } else {
6945            koerper = '<div class="ait-abempty">'
6946                + esc(t('Für diesen Dateityp gibt es keine Vorschau.')) + '</div>';
6947        }
6948        return '<div class="d-flex flex-column h-100">' + kopf
6949            + '<div class="flex-grow-1 overflow-auto">' + koerper + '</div></div>';
6950    }
6951
6952    static _akteTabDateien(m) {
6953        this._abStil();
6954        const a = this._ab;
6955        if (a.daten === null && !a.laden && !a.fehler) {
6956            setTimeout(() => this._abLaden(''), 0);
6957            return '<div class="text-muted p-4">' + t('Wird geladen …') + '</div>';
6958        }
6959        if (a.fehler) {
6960            // Kein Zugang: der Mitarbeiter sagt selbst, was fehlt. Die interne
6961            // Ablage hat keine Pflichtfelder — deshalb ein Knopf statt des
6962            // Umwegs über die Zugänge-Verwaltung.
6963            const intern = !(a.daten || {}).art || (a.daten || {}).art === 'intern';
6964            return '<div class="card"><div class="card-body text-center py-5">'
6965                + '<i class="fas fa-folder-open fa-2x text-muted mb-3"></i>'
6966                + '<p class="mb-3">' + esc(a.fehler) + '</p>'
6967                + (intern
6968                    ? '<button class="btn btn-primary" data-action="ab-einrichten">'
6969                      + '<i class="fas fa-plus me-1"></i>' + t('Ablage einrichten')
6970                      + '</button>'
6971                      + '<div class="text-muted small mt-3">'
6972                      + t('Nichts einzurichten — die Dateien liegen verschlüsselt '
6973                          + 'auf unserem Server in Deutschland.') + '</div>'
6974                    : '')
6975                + '</div></div>';
6976        }
6977        const d = a.daten || {};
6978        const appBlock = this._abAppBlock();   /* ai-team-app-ui-v1 */
6979        const liste = this._abListe();   /* ablage-sort-v1: sortiert */
6980        const belegt = d.belegt_max
6981            ? Math.round((d.belegt || 0) / d.belegt_max * 100) : 0;
6982        /* ai-team-ablage-paket-ui-v1b */
6983        const alleGewaehlt = liste.length > 0
6984            && (a.wahl || []).length === liste.length;
6985        const kopf = appBlock
6986            + '<div class="d-flex flex-wrap align-items-center gap-2 mb-2">'
6987            + (a.suche
6988                ? '<div class="me-auto"><span class="fw-semibold">'
6989                  + esc(a.suche.treffer.length) + ' ' + t('Treffer für') + ' „'
6990                  + esc(a.suche.begriff) + '“</span> '
6991                  + '<button class="btn btn-sm btn-link p-0 ms-1" data-action="ab-suche-zu">'
6992                  + t('Suche beenden') + '</button></div>'
6993                : '<div class="me-auto">' + this._abKruemel() + '</div>')
6994            /* ai-team-ablage-ui-v1c: 320px liess den Platzhalter abbrechen */
6995            + '<div class="input-group input-group-sm flex-nowrap" '
6996            + 'style="max-width:420px;min-width:300px">'
6997            + '<input class="form-control" id="abSuche" placeholder="'
6998            + esc(t('Dateien durchsuchen …')) + '" value="'
6999            + esc(a.suche ? a.suche.begriff : '') + '">'
7000            + '<span class="input-group-text"><label class="mb-0 small" '
7001            + 'title="' + esc(t('Auch im Text suchen (langsamer)')) + '">'
7002            + '<input type="checkbox" id="abSucheInhalt" class="me-1"'
7003            + (a.suche && a.suche.inhalt ? ' checked' : '') + '>'
7004            + t('im Text') + '</label></span>'
7005            + '<button class="btn btn-outline-secondary" data-action="ab-suchen">'
7006            + '<i class="fas fa-search me-1"></i>' + t('Suchen') + '</button></div>'
7007            /* ai-team-ablage-paket-ui-v1 */
7008            /* ai-team-ablage-paket-ui-v1b: sagen, was der Knopf tut - er
7009               nimmt ALLE Eintraege der Liste, und beim zweiten Klick keinen. */
7010            + '<button class="btn btn-sm btn-outline-secondary'
7011            + (alleGewaehlt ? ' active' : '') + '" data-action="ab-wahl-alle" '
7012            + 'title="' + esc(t('Alle Einträge dieser Liste anhaken')) + '">'
7013            + '<i class="far fa-square-check me-1"></i>'
7014            + t(alleGewaehlt ? 'Auswahl aufheben' : 'Alle auswählen') + '</button>'
7015            + '<button class="btn btn-sm btn-outline-secondary" data-action="ab-ordner">'
7016            + '<i class="fas fa-folder-plus me-1"></i>' + t('Neuer Ordner') + '</button>'
7017            + '<button class="btn btn-sm btn-primary" data-action="ab-hoch">'
7018            + '<i class="fas fa-upload me-1"></i>' + t('Hochladen') + '</button>'
7019            + '</div>';
7020        const hinweise = []
7021            .concat(d.im_papierkorb
7022                ? ['<div class="alert alert-secondary py-1 px-2 small mb-2">'
7023                   + t('Sie sind im Papierkorb. Hier gelöschte Dateien sind endgültig weg.')
7024                   + '</div>'] : [])
7025            .concat(d.gekappt
7026                ? ['<div class="alert alert-warning py-1 px-2 small mb-2">'
7027                   + t('Es werden nur die ersten') + ' ' + esc(d.max_eintraege) + ' '
7028                   + t('Einträge gezeigt.') + '</div>'] : [])
7029            .concat(a.suche && a.suche.gekappt
7030                ? ['<div class="alert alert-warning py-1 px-2 small mb-2">'
7031                   + t('Es werden nur die ersten') + ' ' + esc(a.suche.max) + ' '
7032                   + t('Treffer gezeigt.') + '</div>'] : []);
7033        const inhalt = liste.length
7034            ? liste.map(e => this._abZeile(e, !!a.suche)).join('')
7035            : '<div class="p-4 text-center text-muted small">'
7036              + (a.suche ? t('Nichts gefunden.')
7037                         : t('Dieser Ordner ist leer. Laden Sie etwas hoch — '
7038                             + 'oder warten Sie, bis Ihr Mitarbeiter etwas ablegt.'))
7039              + '</div>';
7040        return kopf + this._abSammelleiste() + this._abLoeschMeldung()
7041            + hinweise.join('')
7042            + '<div class="ait-abbody">'
7043            + '<div class="ait-ablinks">' + (a.laden
7044                ? '<div class="p-3 text-muted small">' + t('Wird geladen …') + '</div>'
7045                : (liste.length ? this._abSortLeiste() : '') + inhalt) + '</div>'
7046            + '<div class="ait-abrechts">' + this._abVorschauHtml() + '</div>'
7047            + '</div>'
7048            + '<div class="d-flex flex-wrap gap-3 mt-2 text-muted small">'
7049            + '<span>' + t('Belegt') + ': ' + this._abGroesse(d.belegt) + ' '
7050            + t('von') + ' ' + this._abGroesse(d.belegt_max)
7051            + ' (' + belegt + '%)</span>'
7052            + '<span>' + t('Je Datei höchstens') + ' ' + esc(d.max_datei_mb) + ' MB</span>'
7053            + '<span class="ms-auto">'
7054            + t('Diese Ablage gehört Ihrem Unternehmen — alle Mitarbeiter mit '
7055                + 'derselben Ablage sehen dieselben Dateien.') + '</span></div>';
7056    }
7057
7058    static _abStil() {
7059        if (document.getElementById('aitAbStil')) return;
7060        const s = document.createElement('style');
7061        s.id = 'aitAbStil';
7062        s.textContent = [
7063            /* ai-team-ablage-ui-v1b */
7064            '.ait-abbody{display:grid;grid-template-columns:minmax(300px,42%) 1fr;',
7065            'border:1px solid #e3e6ec;border-radius:.5rem;overflow:hidden;',
7066            /* ai-team-ablage-ui-v1b: KEINE feste Hoehe. Bei vier Dateien stand der Rahmen 580px hoch und fast leer — derselbe Eindruck, den Michael am Board kritisiert hat. Jetzt waechst er mit und deckelt erst bei vielen Eintraegen. */
7067            'min-height:280px;max-height:58vh}',
7068            /* ablage-scroll-v1: ohne feste Zeilenhoehe wuchs die Rasterzeile mit der Liste,
7069               der Rahmen schnitt sie bei 58vh ab und overflow:auto griff nie -> kein Scrollen. */
7070            '.ait-abbody{grid-template-rows:minmax(0,1fr)}.ait-abbody>*{min-height:0}',
7071            '.ait-ablinks{overflow:auto;border-right:1px solid #e3e6ec;background:#fafbfd}',
7072            '.ait-abrechts{overflow:auto;background:#fff}',
7073            '.ait-abrow{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;',
7074            'border-bottom:1px solid #eef0f4}',
7075            '.ait-abrow:hover{background:#f1f4f9}',
7076            '.ait-abrow-aktiv{background:#e8f0ff}',
7077            /* ai-team-ablage-paket-ui-v1 */
7078            '.ait-abrow-wahl{background:#eef4ff}',
7079            /* ai-team-papierkorb-v1: Geloeschtes muss auf einen Blick anders
7080               aussehen als Vorhandenes - schraffierter Hintergrund, gedaempfte
7081               Schrift, durchgestrichener Name. Bewusst KEIN reines
7082               opacity auf die ganze Zeile: die Knoepfe (besonders
7083               "Wiederherstellen") muessen gut treffbar und lesbar bleiben. */
7084            '.ait-abrow-weg{background:repeating-linear-gradient(45deg,',
7085            '#f6f7f9,#f6f7f9 6px,#f1f2f5 6px,#f1f2f5 12px)}',
7086            '.ait-abrow-weg .ait-absz{color:#adb5bd}',
7087            '.ait-abweg-name{text-decoration:line-through;color:#868e96}',
7088            '.ait-abweg-merker{display:inline-block;background:#e9ecef;color:#495057;',
7089            'border-radius:.25rem;padding:0 .35rem;margin-right:.4rem;',
7090            'font-size:.68rem;font-weight:600;text-transform:uppercase;',
7091            'letter-spacing:.02em;vertical-align:middle}',
7092            '.ait-abcb{margin:0;cursor:pointer}',
7093            '.ait-abklick{display:flex;align-items:center;gap:.6rem;flex:1;',
7094            'min-width:0;cursor:pointer}',
7095            '.ait-abic{width:20px;text-align:center;flex-shrink:0}',
7096            '.ait-abnm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;',
7097            'white-space:nowrap;font-size:.9rem}',
7098            '.ait-absz{flex-shrink:0;font-size:.78rem;color:#6c757d;',
7099            'font-variant-numeric:tabular-nums}',
7100            /* ablage-sort-v1: Datumsspalte + Sortierleiste */
7101            '.ait-abdt{flex-shrink:0;font-size:.78rem;color:#6c757d;white-space:nowrap;',
7102            'font-variant-numeric:tabular-nums;min-width:118px;text-align:right}',
7103            '.ait-absz{min-width:64px;text-align:right}',
7104            '.ait-abrow-weg .ait-abdt{color:#adb5bd}',
7105            '.ait-absortz{display:flex;align-items:center;gap:.25rem;padding:.3rem .6rem;',
7106            'border-bottom:1px solid #e3e6ec;background:#f3f5f9;position:sticky;top:0;',
7107            'z-index:2;font-size:.75rem;color:#6c757d}',
7108            '.ait-absort{border:0;background:transparent;color:#6c757d;padding:.1rem .45rem;',
7109            'border-radius:.3rem;font-size:.75rem;cursor:pointer;white-space:nowrap}',
7110            '.ait-absort:hover{background:#e8ecf3;color:#212529}',
7111            '.ait-absort-aktiv,.ait-absort-aktiv:hover{color:#0d6efd;font-weight:600;background:#e8f0ff}',
7112            '.ait-abphead{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;',
7113            'border-bottom:1px solid #eef0f4;background:#fafbfd}',
7114            '.ait-abtxt{white-space:pre-wrap;word-break:break-word;font-size:.82rem;',
7115            'padding:.75rem;margin:0}',
7116            '.ait-abpdf{width:100%;height:100%;min-height:50vh;border:0}',
7117            /* ai-team-buero-v1: Tabellenraster. position:sticky auf der
7118               Kopfzeile UND der Zeilennummer — beim Scrollen durch eine
7119               Preisliste ist sonst nach zwanzig Zeilen nicht mehr
7120               erkennbar, welche Spalte welche ist. */
7121            '.ait-abblaetter{display:flex;gap:.25rem;flex-wrap:wrap;padding:.4rem .5rem;',
7122            'border-bottom:1px solid #eef0f4;background:#fafbfd;position:sticky;top:0;z-index:3}',
7123            '.ait-abblatt{border:1px solid #d9dee7;background:#fff;border-radius:.3rem;',
7124            'padding:.15rem .55rem;font-size:.8rem;cursor:pointer;color:#495057}',
7125            '.ait-abblatt:hover{background:#eef4ff}',
7126            '.ait-abblatt-aktiv{background:#0d6efd;border-color:#0d6efd;color:#fff}',
7127            '.ait-abtabwrap{overflow:auto;max-height:52vh}',
7128            '.ait-abtab{border-collapse:separate;border-spacing:0;font-size:.8rem;',
7129            'width:max-content;min-width:100%}',
7130            '.ait-abtab th,.ait-abtab td{border-right:1px solid #eef0f4;',
7131            'border-bottom:1px solid #eef0f4;padding:.25rem .5rem;',
7132            'max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
7133            '.ait-abtab thead th{position:sticky;top:0;background:#f4f6fa;z-index:2;',
7134            'font-weight:600;text-align:left}',
7135            '.ait-abtab tbody tr:hover td{background:#f7f9fc}',
7136            '.ait-abnr{position:sticky;left:0;background:#fafbfd;color:#98a2b3;',
7137            'font-size:.72rem;text-align:right;font-variant-numeric:tabular-nums;',
7138            'z-index:1;min-width:34px}',
7139            '.ait-abtab thead .ait-abnr{z-index:4;background:#f4f6fa}',
7140            '.ait-abempty{display:flex;align-items:center;justify-content:center;',
7141            'height:100%;color:#8a94a6;font-size:.9rem;padding:2rem;text-align:center}',
7142            '@media(max-width:991px){.ait-abbody{grid-template-columns:1fr;height:auto;grid-template-rows:none;max-height:none}',
7143            '.ait-ablinks{max-height:44vh;border-right:0;border-bottom:1px solid #e3e6ec}',
7144            '.ait-abrechts{max-height:44vh}}',
7145        ].join('');
7146        document.head.appendChild(s);
7147        // Enter in der Suche löst sie aus — ohne das muss man den Knopf treffen.
7148        document.addEventListener('keydown', ev => {
7149            if (ev.key === 'Enter' && ev.target && ev.target.id === 'abSuche') {
7150                ev.preventDefault(); this._abSuchen();
7151            }
7152        });
7153    }
7154
7155    // ---------- Reiter 2: Fähigkeiten ----------
7156    // Vorbild ist das Command Center (64 Skills, einem Agenten
7157    // zugeordnet). Dort ist ein Skill Python-Code — das ist hier N
7157ICHT
7158    // uebernehmbar, ein Mandant kann keinen Code auf unserem Server
7159    // hinterlegen. Ein Skill ist deshalb eine ARBEITSANWEISUNG in
7160    // Worten, wie bei den Claude-Skills. So kann der Inhaber ohne
7161    // Programmierkenntnis eigene entwickeln.
7162
7163    static _akteFaehigkeiten(m) {
7164        // ai-team-ext-ui-v1: die eigenen Erweiterungen stehen im selben
7165        // Reiter — dort beantwortet der Inhaber ohnehin die Frage, was
7166        // dieser Mitarbeiter kann.
7167        const _ext = this._akteErweiterungen(m);
7168        return _ext + this._akteFaehigkeitenBestand(m);
7169    }
7170
7171    static _akteFaehigkeitenBestand(m) {
7172        const alle = this._skills || [];
7173        const vorl = this._skVorlagen || [];
7174        const zug = ((m.config || {}).skills) || [];
7175        const meine = alle.filter(s => zug.includes(s.id));
7176        const rest = alle.filter(s => !zug.includes(s.id));
7177        const grenze = ((this._skGrenzen || {}).je_mitarbeiter) || 12;
7178
7179        const karte = (s, drin) =>
7180            '<div class="border rounded p-2 mb-2 d-flex align-items-start gap-2'
7181            + (s.aktiv ? '' : ' opacity-50') + '">'
7182            + '<div class="flex-grow-1 min-w-0">'
7183            + '<div class="fw-semibold text-truncate">' + esc(s.name)
7184            + (s.quelle === 'vorlage'
7185                ? ' <span class="badge bg-light text-muted fw-normal">' + t('Vorlage') + '</span>'
7186                : '')
7187            + (s.aktiv ? '' : ' <span class="badge bg-secondary">' + t('inaktiv') + '</span>')
7188            + '</div>'
7189            + (s.beschreibung
7190                ? '<div class="small text-muted">' + esc(s.beschreibung) + '</div>' : '')
7191            + '<div class="small text-muted mt-1" style="white-space:pre-wrap;'
7192            + 'max-height:4.5em;overflow:hidden">' + esc(s.anleitung || '') + '</div>'
7193            + '</div>'
7194            + '<div class="d-flex flex-column gap-1">'
7195            /* ai-team-akte-v1b: eine INAKTIVE Faehigkeit laesst sich nicht
7196               beibringen - sie wuerde im Systemprompt gar nicht erscheinen.
7197               Ein Knopf, der nichts bewirkt, ist schlechter als keiner. */
7198            + '<button class="btn btn-sm ' + (drin ? 'btn-outline-danger' : 'btn-outline-primary')
7199            + '" data-action="' + (drin ? 'sk-ab' : 'sk-zu') + '" data-id="' + esc(s.id) + '"'
7200            + ((!drin && !s.aktiv) ? ' disabled title="' + esc(t('Erst aktivieren')) + '"' : '')
7201            + '>'
7202            + (drin ? '<i class="fas fa-minus me-1"></i>' + t('Abnehmen')
7203                    : '<i class="fas fa-plus me-1"></i>' + t('Beibringen')) + '</button>'
7204            + '<button class="btn btn-sm btn-outline-secondary" '
7205            + 'data-action="sk-bearbeiten" data-id="' + esc(s.id) + '">'
7206            + '<i class="fas fa-pen me-1"></i>' + t('Ändern') + '</button>'
7207            + '</div></div>';
7208
7209        const links = '<div class="card"><div class="card-body">'
7210            + '<h6><i class="fas fa-check-circle me-2 text-success"></i>'
7211            + esc(t('Was') + ' ' + m.name + ' ' + t('kann')) + '</h6>'
7212            + '<div class="text-muted small mb-2">'
7213            + esc(t('Diese Vorgehensweisen kennt er und hält sich daran. Sie geben ihm keine zusätzlichen Rechte.'))
7214            + '</div>'
7215            + (meine.length
7216                ? meine.map(s => karte(s, true)).join('')
7217                : '<div class="alert alert-light border small mb-0">'
7218                  + esc(t('Er arbeitet nach allgemeinem Verständnis. Bringen Sie ihm rechts bei, wie Sie es gemacht haben wollen.'))
7219                  + '</div>')
7220            + '<div class="small text-muted mt-2">'
7221            + esc(meine.length + ' ' + t('von') + ' ' + grenze + ' ' + t('belegt'))
7222            + '</div></div></div>';
7223
7224        const offeneVorl = vorl.filter(v => !v.uebernommen);
7225        const rechts = '<div class="card"><div class="card-body">'
7226            + '<div class="d-flex align-items-center mb-2">'
7227            + '<h6 class="mb-0 me-auto"><i class="fas fa-book me-2"></i>'
7228            + t('Verfügbare Fähigkeiten') + '</h6>'
7229            + '<button class="btn btn-sm btn-primary" data-action="sk-neu">'
7230            + '<i class="fas fa-plus me-1"></i>' + t('Neu entwickeln') + '</button></div>'
7231            + (rest.length ? rest.map(s => karte(s, false)).join('')
7232                : '<div class="small text-muted mb-2">'
7233                  + esc(t('Alle vorhandenen Fähigkeiten sind ihm bereits beigebracht.')) + '</div>')
7234            + (offeneVorl.length
7235                ? '<hr><div class="small text-muted mb-2">'
7236                  + esc(t('Fertige Vorlagen — übernehmen und bei Bedarf anpassen:')) + '</div>'
7237                  + this._vorlagenListe(offeneVorl)
7238                : '')
7239            + '</div></div>';
7240
7241        return '<div class="row g-3"><div class="col-12 col-lg-6">' + links
7242            + '</div><div class="col-12 col-lg-6">' + rechts + '</div></div>';
7243    }
7244
7245    /* ai-team-vier-v1 — Vorlagenliste.
7246       Es gibt 51 Vorlagen; fuer eine Rolle passen davon 4 bis 10. Bisher
7247       standen alle untereinander: die passenden oben, danach ueber 40,
7248       die den Inhaber nichts angehen. Eine Liste, die man wegscrollen
7249       muss, wird beim naechsten Mal gar nicht mehr gelesen.
7250       Die passenden bleiben offen stehen. Der Rest kommt hinter ein
7251       <details> — bewusst nativ und nicht mit eigenem Zustand: die Akte
7252       rendert sich bei jeder Aktion neu, ein selbstgebauter Aufklapp-
7253       Zustand ginge dabei verloren. */
7254    static _vorlagenListe(offeneVorl) {
7255        const passend = offeneVorl.filter(v => v.empfohlen);
7256        const rest = offeneVorl.filter(v => !v.empfohlen);
7257        const block = liste => liste.map(v =>
7258                      '<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">'
7259                      + '<div class="flex-grow-1 min-w-0">'
7260                      + '<div class="small fw-semibold text-truncate">' + esc(v.name)
7261                      + (v.empfohlen
7262                          ? ' <span class="badge bg-success-subtle text-success fw-normal">'
7263                            + t('passend') + '</span>' : '')
7264                      + '</div>'
7265                      + '<div class="small text-muted text-truncate">' + esc(v.beschreibung)
7266                      + '</div></div>'
7267                      + '<button class="btn btn-sm btn-outline-primary flex-shrink-0" '
7268                      + 'data-action="sk-vorlage" data-id="' + esc(v.key) + '">'
7269                      + t('Übernehmen') + '</button></div>').join('');
7270
7271        if (!rest.length) return block(passend);
7272        return block(passend)
7273            + '<details class="mt-2">'
7274            + '<summary class="small text-muted" style="cursor:pointer">'
7275            + esc(rest.length + ' ' + t('weitere Vorlagen anderer Rollen'))
7276            + '</summary>'
7277            + '<div class="mt-2">' + block(rest) + '</div>'
7278            + '</details>';
7279    }
7280
7281    static async _skLaden() {
7282        const rk = ((this._akteDaten || {}).member || {}).role_key || '';
7283        try {
7284            const d = await api('/api/ai-team/skills?role_key=' + encodeURIComponent(rk));
7285            // ai-team-ext-ui-v1: parallel, damit die Akte nicht auf einen
7286            // zweiten Aufruf wartet. Faellt er aus, bleibt der Block leer.
7287            try {
7288                if (!this._extDaten) {
7289                    this._extDaten = await api('/api/ai-team/extensions');
7290                }
7291            } catch (e) { this._extDaten = this._extDaten || { extensions: [] }; }
7292            this._skills = d.skills || [];
7293            this._skVorlagen = d.vorlagen || [];
7294            this._skGrenzen = d.grenzen || {};
7295        } catch (e) {
7296            this._skills = []; this._skVorlagen = []; this._skGrenzen = {};
7297        }
7298    }
7299
7300    static async _skZuordnen(skillId, dazu) {
7301        const m = ((this._akteDaten || {}).member) || {};
7302        const zug = ((m.config || {}).skills) || [];
7303        const neu = dazu ? zug.concat([skillId]) : zug.filter(x => x !== skillId);
7304        try {
7305            await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
7306                      + '/skills', 'PUT', { skills: neu });
7307        } catch (e) { return this._modalFehler(e); }
7308        await this._akteOeffnen(this._akteId);
7309        this._akteTab = 'koennen';
7310        this._render();
7311    }
7312
7313    static async _skVorlage(key) {
7314        try {
7315            await api('/api/ai-team/skills', 'POST', { vorlage: key });
7316        } catch (e) { return this._modalFehler(e); }
7317        await this._skLaden();
7318        this._akteTab = 'koennen';
7319        this._render();
7320    }
7321
7322    static _skFormular(skillId) {
7323        const s = (this._skills || []).find(x => x.id === skillId) || {};
7324        const g = this._skGrenzen || {};
7325        this._skBearbeite = skillId || null;
7326        this._modal('<div class="modal-header"><h5 class="modal-title">'
7327            + (skillId ? t('Fähigkeit ändern') : t('Neue Fähigkeit entwickeln'))
7328            + '</h5><button type="button" class="btn-close" data-action="modal-zu"></button></div>'
7329            + '<div class="modal-body">'
7330            + '<div class="mb-2"><label class="form-label small">' + t('Name') + '</label>'
7331            + '<input id="skName" class="form-control" maxlength="' + (g.name || 80) + '" '
7332            + 'value="' + esc(s.name || '') + '" placeholder="'
7333            + esc(t('z. B. Rückrufe abarbeiten')) + '"></div>'
7334            + '<div class="mb-2"><label class="form-label small">' + t('Wofür ist das?')
7335            + '</label><input id="skBesch" class="form-control" maxlength="'
7336            + (g.beschreibung || 200) + '" value="' + esc(s.beschreibung || '') + '" '
7337            + 'placeholder="' + esc(t('Ein Satz zur Einordnung')) + '"></div>'
7338            + '<div class="mb-2"><label class="form-label small">'
7339            + t('Anleitung — wie soll er vorgehen?') + '</label>'
7340            + '<textarea id="skAnl" class="form-control" rows="10" maxlength="'
7341            + (g.anleitung || 4000) + '" placeholder="'
7342            + esc(t('1. Sieh zuerst …\n2. Achte darauf, dass …\n3. Wenn etwas unklar ist, frage nach.'))
7343            + '">' + esc(s.anleitung || '') + '</textarea>'
7344            + '<div class="form-text">'
7345            + esc(t('Schreiben Sie es so auf, wie Sie es einem neuen Mitarbeiter erklären würden. Er hält sich daran — Rechte bekommt er dadurch keine.'))
7346            + '</div></div>'
7347            + (skillId
7348                ? '<div class="form-check"><input class="form-check-input" type="checkbox" '
7349                  + 'id="skAktiv"' + (s.aktiv !== false ? ' checked' : '') + '>'
7350                  + '<label class="form-check-label small" for="skAktiv">'
7351                  + t('Aktiv — wirkt bei allen, denen sie beigebracht wurde') + '</label></div>'
7352                : '')
7353            + '</div>'
7354            + '<div class="modal-footer">'
7355            + (skillId
7356                ? '<button class="btn btn-outline-danger me-auto" data-action="sk-loeschen" '
7357                  + 'data-id="' + esc(skillId) + '">
7357<i class="fas fa-trash me-1"></i>'
7358                  + t('Löschen') + '</button>' : '')
7359            + '<button class="btn btn-secondary" data-action="modal-zu">' + t('Abbrechen')
7360            + '</button>'
7361            + '<button class="btn btn-primary" data-action="sk-speichern">'
7362            + t('Speichern') + '</button></div>');
7363    }
7364
7365    static async _skSpeichern() {
7366        const w = id => (document.getElementById(id) || {}).value || '';
7367        const daten = { name: w('skName'), beschreibung: w('skBesch'),
7368                        anleitung: w('skAnl') };
7369        const ak = document.getElementById('skAktiv');
7370        if (ak) daten.aktiv = ak.checked;
7371        try {
7372            if (this._skBearbeite) {
7373                await api('/api/ai-team/skills/' + encodeURIComponent(this._skBearbeite),
7374                          'PUT', daten);
7375            } else {
7376                await api('/api/ai-team/skills', 'POST', daten);
7377            }
7378        } catch (e) { return this._modalFehler(e); }
7379        this._modal('');
7380        await this._skLaden();
7381        this._akteTab = 'koennen';
7382        this._render();
7383    }
7384
7385    static async _skLoeschen(skillId) {
7386        if (!confirm(t('Diese Fähigkeit wirklich löschen? Sie wird auch allen Mitarbeitern abgenommen, die sie gelernt haben.')))
7387            return;
7388        try {
7389            await api('/api/ai-team/skills/' + encodeURIComponent(skillId), 'DELETE');
7390        } catch (e) { return this._modalFehler(e); }
7391        this._modal('');
7392        await this._akteOeffnen(this._akteId);
7393        await this._skLaden();
7394        this._akteTab = 'koennen';
7395        this._render();
7396    }
7397
7398    static _renderAkte(host) {
7399        const d = this._akteDaten;
7400        if (!d || !d.member) { this._akteId = null; return this._render(); }
7401        const m = d.member;
7402        const [farbe, lbl] = STATUS_ANZEIGE[m.status] || ['#6c757d', m.status];
7403        const anw = (m.config || {}).anweisungen || [];
7404        const vert = (m.config || {}).vertrauen || {};
7405        /* limit-v1: Tageskontingente je Aktionsklasse. */
7406        const lim = (m.config || {}).limits || {};
7407        const ak = (this._katalog || {}).aktionsklassen || {};
7408        const stufen = (this._katalog || {}).stufen || [];
7409
7410        const kopf = '<div class="card mb-3"><div class="card-body d-flex align-items-center flex-wrap gap-2">'
7411            /* ai-team-gesichter-v1: In der Akte grosser, mit Knopf zum
7412               Wechseln - hier ist der Ort, an dem der Inhaber seinen
7413               Mitarbeiter einrichtet. */
7414            + '<div class="position-relative flex-shrink-0" style="cursor:pointer" '
7415            + 'data-action="gesicht-waehlen" data-id="' + esc(m.id) + '" '
7416            + 'title="' + esc(t('Gesicht ändern')) + '">'
7417            + this._gesichtBild(m, 56, ROLLEN_FARBEN[m.role_key] || '#0d6efd')
7418            + '<span class="position-absolute bottom-0 end-0 bg-white rounded-circle '
7419            + 'd-flex align-items-center justify-content-center border" '
7420            + 'style="width:20px;height:20px">'
7421            + '<i class="fas fa-pen" style="font-size:.6rem;color:#495057"></i></span>'
7422            + '</div>'
7423            + '<div class="ms-2 me-auto"><h5 class="mb-0">' + esc(m.name) + '</h5>'
7424            + '<div class="text-muted small">'
7425            + esc(m.titel || this._rolleTitel(m.role_key))
7426            + (m.titel_eigen ? ' (' + esc(this._rolleTitel(m.role_key)) + ')' : '') + ' · '
7427            + t('eingestellt am') + ' ' + esc(this._datum(m.created_at)) + '</div></div>'
7428            + '<span class="badge me-2" style="background:' + farbe + '">' + esc(t(lbl)) + '</span>'
7429            + (m.status === 'pausiert'
7430                ? '<button class="btn btn-sm btn-success" data-action="fortsetzen" data-id="' + esc(m.id) + '" data-arg="'
7431                  + (m.probezeit_bis ? 'probezeit' : 'aktiv') + '">' + t('Fortsetzen') + '</button>'
7432                : '<button class="btn btn-sm btn-warning" data-action="pause" data-id="' + esc(m.id) + '">'
7433                  + '<i class="fas fa-pause me-1"></i>' + t('Pausieren') + '</button>')
7434            + (m.status === 'probezeit'
7435                ? '<button class="btn btn-sm btn-outline-primary" data-action="probezeit-beenden" data-id="' + esc(m.id) + '">'
7436                  + t('Probezeit beenden') + '</button>' : '')
7437            + (AiTeam._istMitarbeiter() ? ''   /* kiteam-recht-v1: Inhabersache */
7438               : '<button class="btn btn-sm btn-outline-danger" data-action="entlassen" data-id="' + esc(m.id) + '">'
7439                 + t('Entlassen') + '</button>')
7440            + '<button class="btn btn-sm btn-outline-secondary" data-action="tab" data-id="board">'
7441            + t('Zurück zum Team') + '</button>'
7442            + '</div>'
7443            + (m.status === 'probezeit' && m.probezeit_bis
7444                ? '<div class="card-footer small text-muted">' + t('Probezeit bis') + ' ' + esc(this._datum(m.probezeit_bis))
7445                  + ' — ' + t('in dieser Zeit sendet er nichts nach außen, alles bleibt Entwurf.') + '</div>' : '')
7446            + '</div>';
7447
7448        /* aiteam-bugfix-ui-v1: der Server berechnet activity_gekappt und
7449           tasks_gekappt eigens - beide wurden bisher verworfen. Eine Liste,
7450           die weniger zeigt als vorhanden ist, ohne das zu sagen, ist genau
7451           die stille Kappung, die wir im Backend verboten haben. */
7452        /* ai-team-einstieg-v1: Ohne diesen Satz sieht eine Akte mit
7453           leerem Firmenprofil genauso aus wie eine mit gepflegtem —
7454           nur die Antworten sind schlechter, und niemand weiss warum.
7455           Er steht in der Akte und nicht auf jeder Teamkarte: vierzehn
7456           Mal derselbe Hinweis ist keiner mehr. */
7457        const betriebHinweis = (d.betrieb_bekannt === false)
7458            ? '<div class="alert alert-warning d-flex align-items-center gap-2 py-2">'
7459              + '<i class="fas fa-circle-info"></i>'
7460              + '<div class="me-auto small">'
7461              + t('{n} kennt Ihr Unternehmen noch nicht — Sie müssen es in jedem Gespräch neu erklären.')
7462                  .replace('{n}', esc(m.name))
7463              + '</div>'
7464              + '<button class="btn btn-sm btn-warning flex-shrink-0" '
7465              + 'data-action="tab" data-id="unternehmen">'
7466              + t('Unternehmen beschreiben') + '</button></div>'
7467            : '';
7468
7469        const heute = '<h6>' + t('Heute / zuletzt') + '</h6>'
7470            + (d.activity_gekappt
7471                ? '<div class="alert alert-warning small py-1 px-2 mb-2">'
7472                  + t('Nur die neuesten Einträge - im Reiter „Aktivität“ sehen Sie mehr.')
7473                  + '</div>' : '')
7474            + (d.activity && d.activity.length
7475                ? this._aktTabelle(d.activity, true)
7476                : '<div class="text-muted small">' + t('Noch keine Aktivität. Sie entsteht, sobald der Mitarbeiter im Takt arbeitet (alle 15 Minuten).') + '</div>');
7477
7478        const anweisungen = '<h6>' + t('Anweisungen') + '</h6>'
7479            + '<ul class="list-group mb-2">'
7480            + (anw.length ? anw.map((a, i) =>
7481                '<li class="list-group-item d-flex justify-content-between align-items-center">'
7482                + '<span>' + esc(a) + '</span>'
7483                + '<button class="btn btn-sm btn-outline-danger" data-action="anweisung-del" '
7484                + 'data-id="' + esc(m.id) + '" data-arg="' + i + '">&times;</button></li>').join('')
7485                : '<li class="list-group-item text-muted small">' + t('Noch keine Anweisungen.') + '</li>')
7486            + '</ul>'
7487            + '<div class="input-group"><input id="aiAnwNeu" class="form-control" maxlength="500" '
7488            + 'placeholder="' + esc(t('Neue Anweisung, z. B.: Angebote immer erst mir zeigen')) + '">'
7489            + '<button class="btn btn-primary" data-action="anweisung-add" data-id="' + esc(m.id) + '">'
7490            + t('Hinzufügen') + '</button></div>';
7491
7492        /* aiv-gruppen-v1: Michaels Befund "das Layout ist unuebersichtlich".
7493           Drei Ursachen, alle hier behoben:
7494
7495           (1) REIHENFOLGE. services/ai_team.py listet die Klassen bewusst nach
7496               Risiko (lesen -> intern -> kommunikation -> geld -> loeschen).
7497               Flask serialisiert JSON aber mit sort_keys=True, die Ordnung kam
7498               also ALPHABETISCH an: "Geld & Rechtliches" stand zwischen
7499               "Entwuerfe erstellen" und "Wertpapier-Orders". Die Reihenfolge
7500               steht deshalb HIER explizit und ist gegen jede Serialisierung
7501               immun - statt JSON_SORT_KEYS global umzustellen, was jede
7502               andere Antwort der App mit betreffen wuerde.
7503
7504           (2) WIEDERHOLUNG. Der 160-Zeichen-Satz zum Tageskontingent stand in
7505               JEDER Klasse mit Limit, also zweimal, und war damit der groesste
7506               Textblock der ganzen Tabelle. Er steht jetzt EINMAL als Fussnote.
7507
7508           (3) JARGON. "hoechstens mit Freigabe" stand fuenfmal unter der
7509               Beschriftung, obwohl das Auswahlfeld die hoeheren Stufen
7510               ohnehin nicht enthaelt (optionen filtert auf maxIdx). Der
7511               Hinweis steht jetzt kurz DORT, wo der Nutzer die fehlende
7512               Option sucht: direkt unter dem Auswahlfeld.
7513
7514           DIE RESTGRUPPE IST PFLICHT, KEIN SCHMUCK: ergaenzt der Server eine
7515           Klasse (so kam "handel" per AKTIONSKLASSEN["handel"] = {...} dazu),
7516           landet sie in "Weitere" statt lautlos zu verschwinden. Genau die
7517           Fehlerklasse, die uns bei Menuepunkten schon zweimal getroffen hat.
7518
7519           NICHT ANGETASTET: data-vertrauen / data-limit an den Feldern.
7520           _vertrauenSpeichern sammelt sie mit einem GLOBALEN querySelectorAll
7521           ein, ist also von der Tabellenstruktur unabhaengig - solange die
7522           Attribute stehen bleiben, bleibt Speichern unveraendert. */
7523        const VERT_GRUPPEN = [
7524            { titel: 'Im Haus', zusatz: 'bleibt zwischen Ihnen und ihm',
7525              keys: ['lesen', 'entwurf', 'intern'] },
7526            { titel: 'Nach außen', zusatz: 'Ihre Kunden bekommen es zu sehen',
7527              keys: ['kommunikation', 'anruf', 'telefonie', 'veroeffentlichen'] },
7528            { titel: 'Heikel', zusatz: 'hier entscheidet immer ein Mensch',
7529              keys: ['geld', 'handel', 'loeschen_rueckfrage', 'loeschen'] }
7530        ];
7531        let vertLimitGezeigt = false;
7532        const vertZeile = (k) => {
7533            const regel = ak[k];
7534            if (!regel) return '';
7535            /* Die Beschriftungen tragen ihre Beispiele in einer Klammer
7536               ("Geld & Rechtliches (Rechnung, Mahnung, ...)"). In einer
7537               Zeile ergibt das eine lange Zeile; als zweite, graue Zeile
7538               ergibt es eine ruhige Spalte. */
7539            const mAuf = /^([^(]+?)\s*\((.+)\)\s*$/.exec(regel.label || '');
7540            const haupt = mAuf ? mAuf[1] : (regel.label || k);
7541            const zusatz = mAuf ? mAuf[2] : '';
7542            const zusatzHtml = zusatz
7543                ? '<div class="small text-muted">' + esc(t(zusatz)) + '</div>' : '';
7544            if (regel.max === 'gesperrt') {
7545                // Sichtbar, aber gesperrt-grau MIT Begruendung: zeigen,
7546                // was das System NICHT tut, schafft Vertrauen (UX 3.3).
7547                return '<div class="aiv-vs-zeile"><div class="aiv-vs-text">'
7548                    + '<span class="aiv-vs-name text-muted">' + esc(t(haupt)) + '</span>' + zusatzHtml
7549                    + '</div><div class="aiv-vs-ctrl"><span class="aiv-vs-sperre">'
7550                    + '<i class="fas fa-lock me-1"></i>'
7551                    + t('gesperrt — löscht nie') + '</span></div></div>';
7552            }
7553            const wert = vert[k] || regel.default;
7554            const maxIdx = stufen.indexOf(regel.max);
7555            const optionen = stufen.filter((s, i) => i <= maxIdx && s !== 'gesperrt');
7556            /* limit-v1 (unveraendert gueltig): Das Zahlenfeld steht BEWUSST
7557               immer da statt nur bei gewaehlter Stufe. Ein Feld, das beim
7558               Umschalten erscheint und verschwindet, ist eine Zustandsfalle,
7559               und der Wert waere unsichtbar, sobald jemand voruebergehend auf
7560               "mit Freigabe" stellt.
7561               NEU aiv-gruppen-v1: es wird nur noch ZURUECKGENOMMEN, wenn die
7562               Stufe es gerade nicht nutzt. Das macht ohne einen einzigen
7563               Listener sichtbar, ob die Zahl gerade wirkt - reines CSS ueber
7564               option:checked, siehe aiteam.css. Faellt :has() im Browser aus,
7565               ist das Feld schlicht normal sichtbar wie bisher. */
7566            const hatLimit = !!regel.limit_default;
7567            if (hatLimit) vertLimitGezeigt = true;
7568            const limWert = (lim[k] === undefined || lim[k] === null)
7569                ? regel.limit_default : lim[k];
7570            const limFeld = hatLimit
7571                /* Die Beschriftung ist bewusst kurz ("max. 10 pro Tag") statt
7572                   voll ("höchstens 10 E-Mails/SMS pro Tag"): WORAUF sich die
7573                   Zahl bezieht, steht schon in derselben Zeile links. Der
7574                   Serverwert limit_label wandert in den title, geht also
7575                   nicht verloren. Gemessener Grund: in der 40%-Spalte
7576                   brauchte die lange Fassung ~300px bei ~190px Platz, und
7577                   das geerbte "overflow-wrap: anywhere" brach sie dann
7578                   MITTEN IM WORT um ("E-Mails/S MS pro Tag"). */
7579                ? '<span class="aiv-lim" '
7580                  + 'title="' + esc(t(regel.limit_label || 'pro Tag')) + '">'
7581                  + '<span>' + t('max.') + '</span>'
7582                  + '<input type="number" min="1" max="1000" step="1" '
7583                  + 'class="form-control form-control-sm text-end" '
7584                  + 'data-limit="' + esc(k) + '" '
7585                  + 'value="' + esc(String(limWert)) + '">'
7586                  + '<span>' + t('/Tag') + '</span>'
7587                  + '</span>'
7588                : '';
7589            /* aiv-liste-v1: Der Deckel-Hinweis stand als EIGENE Zeile unter
7590               dem Auswahlfeld und machte fuenf von elf Zeilen dreizeilig.
7591               Er wandert in den title des Auswahlfeldes: dass es hoeher
7592               nicht geht, sieht man ohnehin beim Aufklappen (optionen
7593               filtert auf maxIdx), und die Gruppe "Heikel" sagt es in
7594               Worten. Der Hinweis geht damit nicht verloren, kostet aber
7595               keine Zeilenhoehe mehr. */
7596            const deckelTitel = (regel.max !== 'autonom')
7597                ? ' title="' + esc(t('Mehr ist für diese Klasse nicht möglich.')) + '"'
7598                : '';
7599            return '<div class="aiv-vs-zeile"><div class="aiv-vs-text">'
7600                + '<span class="aiv-vs-name">' + esc(t(haupt)) + '</span>' + zusatzHtml
7601                + '</div><div class="aiv-vs-ctrl' + (wert === 'autonom_limit' ? ' hat-limit' : '') + '">'
7602                + '<select class="form-select form-select-sm" data-vertrauen="' + esc(k) + '"' + deckelTitel + '>'
7603                + optionen.map(s => '<option value="' + esc(s) + '"' + (s === wert ? ' selected' : '') + '>'
7604                    + esc(t(this._stufeLabel(s))) + '</option>').join('')
7605                + '</select>' + limFeld + '</div></div>';
7606        };
7607        const vertGezeigt = {};
7608        const vertBloecke = VERT_GRUPPEN.map(g => {
7609            const zeilen = g.keys.filter(k => ak[k]).map(k => { vertGezeigt[k] = 1; return vertZeile(k); }).join('');
7610            if (!zeilen) return '';
7611            return '<div class="aiv-vs-gruppe">' + esc(t(g.titel))
7612                + (g.zusatz ? '<span>' + esc(t(g.zusatz)) + '</span>' : '') + '</div>' + zeilen;
7613        }).join('');
7614        const vertRest = Object.keys(ak).filter(k => !vertGezeigt[k]);
7615        const vertRestBlock = vertRest.length
7616            ? '<div class="aiv-vs-gruppe">' + t('Weitere') + '</div>'
7617              + vertRest.map(k => vertZeile(k)).join('')
7618            : '';
7619        const vertFussnote = vertLimitGezeigt
7620            ? '<div class="text-muted small mt-2">'
7621              + t('Zu „autonom mit Limit“: bis zur eingetragenen Zahl handelt er am Tag selbstständig, danach legt er Ihnen jeden weiteren Fall zur Freigabe vor. Gesperrt wird er nie.')
7622              + '</div>'
7623            : '';
7624
7625        const vertrauen = this._akteZugriffe(m)
7626            + '<hr class="my-4">'
7627            + '<h6>' + t('Wie selbstständig ich handeln darf') + '</h6>'
7628            + '<div class="text-muted small mb-2">' + t('Was darf er wie selbstständig? Gesperrte Klassen zeigen, was das System grundsätzlich NICHT tut.') + '</div>'
7629            + '<div class="aiv-vs">'
7630            + vertBloecke + vertRestBlock
7631            + '</div>'
7632            + vertFussnote
7633            + '<button class="btn btn-primary btn-sm mt-2" data-action="vertrauen-speichern" data-id="' + esc(m.id) + '">'
7634            + t('Vertrauensstufen speichern') + '</button>'
7635            + '<div id="aiVertHinweis" class="small text-muted mt-2"></div>';
7636
7637        // gedaechtnis-kuratierung-v1: Die Liste zeigt IMMER alles — auch
7638        // das, was die Bewertung aussortiert hat. Nur so kann der Inhaber
7639        // eine Fehleinschaetzung ueberhaupt sehen und zurueckdrehen.
7640        // Zustand steht nie nur in der Farbe, sondern immer auch im Text.
7641        const memZeile = (g) => {
7642            const nachrangig = g.relevant === false;
7643            const rang = (g.bewertung != null) ? g.bewertung : g.importance;
7644            const marke = nachrangig
7645                ? '<span class="badge bg-warning text-dark me-2">'
7646                  + t('Nachrangig — steht nicht in der Anweisung') + '</span>'
7647                : (g.bewertung != null
7648                    ? '<span class="badge bg-success-subtle text-success-emphasis me-2">'
7649                      + t('In der Anweisung') + ' · ' + esc(rang) + '</span>'
7650                    : '<span class="badge bg-light text-dark me-2">'
7651                      + t('Noch nicht bewertet') + '</span>');
7652            const grund = g.bewertung_grund
7653                ? '<div class="text-muted small mt-1">' + t('Einschätzung der KI')
7654                  + ': ' + esc(g.bewertung_grund) + '</div>' : '';
7655            const zurueck = nachrangig
7656                ? '<button class="btn btn-sm btn-outline-secondary me-1" '
7657                  + 'data-action="memory-behalten" data-id="' + esc(m.id) + '" '
7658                  + 'data-arg="' + esc(g.id) + '">' + t('Doch behalten') + '</button>' : '';
7659            return '<li class="list-group-item'
7660                + (nachrangig ? ' bg-light' : '') + '">'
7661                + '<div class="d-flex justify-content-between align-items-start">'
7662                + '<div class="me-2"><div>' + marke
7663                + '<span class="badge bg-light text-dark me-2">' + esc(g.category) + '</span></div>'
7664                + '<div class="mt-1">' + esc(g.content) + '</div>' + grund + '</div>'
7665                + '<div class="text-nowrap">' + zurueck
7666                + '<button class="btn btn-sm btn-outline-danger" data-action="memory-del" '
7667                + 'data-id="' + esc(m.id) + '" data-arg="' + esc(g.id) + '">&times;</button></div>'
7668                + '</div></li>';
7669        };
7670        const memAnz = (d.memory || []).length;
7671        const memNach = (d.memory || []).filter(g => g.relevant === false).length;
7672        const ged = '<h6>' + t('Gedächtnis') + '</h6>'
7673            + '<div class="text-muted small mb-2">'
7674            + t('Was sich der Mitarbeiter gemerkt hat. Eine KI bewertet regelmäßig, was davon in seine Arbeitsanweisung gehört — gelöscht wird dabei nichts.')
7675            + '</div>'
7676            + (memAnz
7677                ? '<div class="d-flex justify-content-between align-items-center mb-2">'
7678                  + '<div class="small text-muted">' + esc(memAnz) + ' ' + t('Einträge')
7679                  + (memNach ? ' · ' + esc(memNach) + ' ' + t('nachrangig') : '') + '</div>'
7680                  + '<button class="btn btn-sm btn-outline-primary" data-action="memory-kurieren" '
7681                  + 'data-id="' + esc(m.id) + '">' + t('Jetzt bewerten lassen') + '</button></div>'
7682                  + '<ul class="list-group">' + d.memory.map(memZeile).join('') + '</ul>'
7683                : '<div class="text-muted small">' + t('Noch leer.') + '</div>');
7684
7685        const berichte = '<h6>' + t('Tagesberichte') + '</h6>'
7686            + ((d.reports || []).length
7687                ? d.reports.map(r => this._berichtKarte(r)).join('')
7688                : '<div class="text-muted small">' + t('Noch keine Berichte — der erste entsteht am Abend des ersten Arbeitstags.') + '</div>');
7689
7690        // ai-team-akte-v1: vier Reiter statt 13 gleichgewichteter Karten.
7691        // Reihenfolge nach Benutzungshaeufigkeit: sprechen taeglich,
7692        // einstellen einmal.
7693        const tab = this._akteTab || 'chat';
7694        let inhalt;
7695        if (tab === 'postfach') {          // ai-team-postfach-tab-v1
7696            inhalt = this._akteTabPostfach(m);
7697        } else if (tab === 'dateien') {    // ai-team-ablage-ui-v1
7698            inhalt = this._akteTabDateien(m);
7699        } else if (tab === 'koennen') {
7700            inhalt = this._akteFaehigkeiten(m);
7701        } else if (tab === 'einst') {
7702            inhalt = '<div class="row g-3">'
7703                + '<div class="col-12 col-lg-6">'
7704                + this._akteTitel(m)
7705                + this._akteFachtext(m)
7706                + this._akteAusstattung(m)
7707                /* rollen-zugriff-v1: "Worauf ich zugreifen darf" steht jetzt
7708                   im Vertrauens-Block rechts. Michaels Vorgabe: die
7709                   Funktionen, die eine Stelle braucht, gehoeren unter
7710                   Vertrauen -- WORAUF und WIE SELBSTSTAENDIG sind zwei
7711                   Haelften derselben Frage und standen bisher in zwei
7712                   Karten in zwei Spalten. */
7713                + '<div class="card"><div class="card-body">' + this._akteOrg(m) + '</div></div>'
7714                + '<div class="card mt-3"><div class="card-body">' + anweisungen + '</div></div>'
7715                + '</div>'
7716                + '<div class="col-12 col-lg-6">'
7717                + '<div class="card"><div class="card-body">' + vertrauen + '</div></div>'
7718                + '<div class="card mt-3"><div class="card-body">' + this._akteWerkzeuge(d) + '</div></div>'
7719                + '</div></div>';
7720        } else if (tab === 'auftraege') {
7721            /* ai-team-vier-v1: was er zu tun hat — einmalig (Auftragsbuch)
7722               und wiederkehrend (Dauerauftraege). Nebeneinander, weil der
7723               eine Dauerauftrag genau die Eintraege erzeugt, die links im
7724               Auftragsbuch landen. */
7725            inhalt = '<div class="row g-3">'
7726                + '<div class="col-12 col-lg-6">'
7727                + '<div class="card"><div class="card-body">' + this._akteAuftraege(m) + '</div></div>'
7728                + '</div>'
7729                + '<div class="col-12 col-lg-6">'
7730                + '<div class="card"><div class="card-body">' + this._akteRoutinen(m) + '</div></div>'
7731                + '</div></div>';
7732        } else if (tab === 'verlauf') {
7733            /* Nur noch Vergangenheit: was heute lief, was er sich gemerkt
7734               hat, und die Tagesberichte. */
7735            inhalt = '<div class="row g-3">'
7736                + '<div class="col-12 col-lg-6">'
7737                + '<div class="card"><div class="card-body">' + heute + '</div></div>'
7738                + '<div class="card mt-3"><div class="card-body">' + berichte + '</div></div>'
7739                + '</div>'
7740                + '<div class="col-12 col-lg-6">'
7741                + '<div class="card"><div class="card-body">' + ged + '</div></div>'
7742                + '</div></div>';
7743        } else {
7744            inhalt = this._akteTabChat(m);
7745        }
7746
7747        host.innerHTML = kopf
7748            + betriebHinweis
7749            + this._akteReiterLeiste()
7750            + inhalt
7751            + '<div class="text-muted small mt-3">' + esc(m.name) + ' '
7752            + t('ist ein KI-Mitarbeiter. Er handelt nur im Rahmen der von Ihnen gesetzten Vertrauensstufen.') + '</div>';
7753        /* ai-team-gfx-v1: Die Akte zeigt dieselbe Aktivitaetsliste wie
7754           der Reiter (ueber _aktTabelle) und braucht deshalb dieselbe
7755           Kappungsmessung — sonst steht dort an jedem Eintrag ein
7756           "mehr anzeigen", auch wo nichts abgeschnitten ist.
7757           In eigenem try: eine Personalakte darf nie an Kosmetik
7758           scheitern. */
7759        try { this._aktKappung(host); } catch (e) { /* nur Darstellung */ }
7760        /* ai-team-mobil-v1: Auf schmalen Schirmen ist die Reiterleiste
7761           seitlich schiebbar (aiteam.css). Ohne diesen Nachlauf stuende
7762           der aktive Reiter ausserhalb des Sichtfelds — man landet in
7763           den Einstellungen und liest oben "Gespraech". */
7764        try {
7765            const aktR = host.querySelector('.nav-tabs .nav-link.active');
7766            const leiste = aktR && aktR.closest('.nav-tabs');
7767            if (leiste && leiste.scrollWidth > leiste.clientWidth) {
7768                leiste.scrollLeft = Math.max(0, aktR.offsetLeft
7769                    - (leiste.clientWidth - aktR.offsetWidth) / 2);
7770            }
7771        } catch (e) { /* nur Darstellung */ }
7772    }
7773
7774    // ================= ai-team-ausstattung-ui-v1 =================
7775    // Michaels Befund 10.08.: "beim Office Manager fehlt mir die
7776    // Moeglichkeit die SMTP-Daten einzutragen ... wo sollen die Daten
7777    // gespeichert werden ... API-Schluessel eines LLM individuell".
7778    //
7779    // GRUNDGEDANKE: Der Mitarbeiter sagt SELBST, was ihm fehlt — auf
7780    // seiner Karte und in seiner Akte. Ein Mitarbeiter, der still nichts
7781    // tut, ist der schlechteste Zustand.
7782
7783    static _bedarf(empId) {
7784        return ((this._setup || {}).je_mitarbeiter || {})[empId] || [];
7785    }
7786
7787    static _bedarfHinweis(empId) {
7788        const blk = this._bedarf(empId).filter(x => x.schwere === 'blockiert');
7789        if (!blk.length) return '';
7790        return '<div class="alert alert-warning py-2 px-2 mt-2 mb-0" style="font-size:.8rem">'
7791            + '<i class="fas fa-triangle-exclamation me-1"></i>'
7792            + esc(t('Mir fehlt noch') + ': ' + blk.map(x => x.label).join(', '))
7793            + '</div>';
7794    }
7795
7796    static _resTitel(typ) {
7797        const s = ((this._spec || {}).typen || {})[typ] || {};
7798        return s.label || typ;
7799    }
7800
7801    static _resIcon(typ) {
7802        const s = ((this._spec || {}).typen || {})[typ] || {};
7803        return s.icon || 'fa-cube';
7804    }
7805
7806    static _resBeschreibung(r) {
7807        const c = r.config || {};
7808        if (r.typ === 'postfach') return c.benutzer || c.absender || '';
7809        if (r.typ === 'llm') {
7810            const arten = (this._spec || {}).llm_betriebsarten || {};
7811            const lbl = (arten[c.betriebsart] || {}).label || c.betriebsart || '';
7812            return lbl + (c.modell ? ' · ' + c.modell : '');
7813        }
7814        if (r.typ === 'ablage') {
7815            const arten = (this._spec || {}).ablage_arten || {};
7816            return (arten[c.art] || {}).label || c.art || '';
7817        }
7818        return '';
7819    }
7820
7821    static async _specLaden() {
7822        if (this._spec) return this._spec;
7823        const d = await api('/api/ai-team/resources');
7824        this._spec = d.spec || {};
7825        this._ressourcen = d.resources || [];
7826        this._setup = d.setup || null;
7827        return this._spec;
7828    }
7829
7830    /* ================================================================
7831       ai-team-ext-ui-v1 — Eigene Erweiterungen
7832
7833       Der Mandant hinterlegt hier eine Adresse, die seine Mitarbeiter als
7834       Werkzeug nutzen koennen. Der Code dahinter laeuft NICHT bei uns —
7835       das steht bewusst als erster Satz auf der Seite, damit niemand
7836       erwartet, hier ein Programm eingeben zu koennen.
7837       ================================================================ */
7838    // ================================================ ai-quelle-ui-v1
7839    // Datenquellen hatten bisher KEINE Oberflaeche: anlegen ging nur im
7840    // Gespraech mit einem Mitarbeiter, der den Zugriff "web" hat. Wer
7841    // nur nachsehen wollte, was eine Quelle gerade liefert, hatte gar
7842    // keinen Weg. Der Dienst war vollstaendig — es fehlte die Sicht.
7843
7844    static _qStand(q) {
7845        if (!q.aktiv) return { text: t('abgeschaltet'), farbe: 'secondary' };
7846        if (q.letzter_fehler) return { text: t('Fehler'), farbe: 'danger' };
7847        if (!q.letzter_lauf) return { text: t('noch nie gelaufen'), farbe: 'secondary' };
7848        return { text: t('aktiv'), farbe: 'success' };
7849    }
7850
7851    static async _renderQuellen(host) {
7852        const el = document.getElementById('aiTeamInhalt');
7853        if (!el) return;
7854        el.innerHTML = '<div class="text-muted p-3">' + t('Lade …') + '</div>';
7855        let d;
7856        try {
7857            d = await api('/api/ai-team/quellen');
7858        } catch (e) {
7859            el.innerHTML = '<div class="alert alert-warning">'
7860                + esc(e.message || String(e)) + '</div>';
7861            return;
7862        }
7863        this._qDaten = d;
7864        const liste = d.quellen || [];
7865        const g = d.grenzen || {};
7866
7867        const kopf = '<div class="d-flex align-items-start mb-3">'
7868            + '<div class="me-auto">'
7869            + '<h5 class="mb-1">' + t('Datenquellen') + '</h5>'
7870            + '<div class="text-muted" style="font-size:.9rem;max-width:64ch">'
7871            + t('Eine Datenquelle liest regelmäßig eine fremde Webseite aus '
7872              + 'und legt das Ergebnis ab. Ihre Telefon-Assistenten können '
7873              + 'darauf zugreifen, und Sie selbst erreichen es über eine '
7874              + 'eigene Adresse mit Schlüssel. Der Abruf von außen löst '
7875              + 'nie einen neuen Lauf aus — er liefert immer den '
7876              + 'abgelegten Stand.')
7877            + '</div></div>'
7878            + (liste.length >= (g.je_mandant || 20) ? ''
7879                : '<button class="btn btn-primary" data-action="q-neu">'
7880                  + '<i class="fas fa-plus me-1"></i>' + t('Datenquelle anlegen')
7881                  + '</button>')
7882            + '</div>';
7883
7884        if (!liste.length) {
7885            el.innerHTML = kopf
7886                + '<div class="text-center text-muted py-5 rounded-3" '
7887                + 'style="background:#fff;border:1px solid #e9ecef">'
7888                + '<i class="fas fa-database fa-2x mb-3 d-block opacity-50"></i>'
7889                + t('Noch keine Datenquelle angelegt.') + '<br>'
7890                + '<span style="font-size:.9rem">'
7891                + t('Beispiel: der Spielplan eines Theaters, die Öffnungs'
7892                  + 'zeiten einer Filiale, eine Preisliste.')
7893                + '</span></div>'
7894                + '<div id="aiTeamQForm" class="mt-3"></div>';
7895            return;
7896        }
7897
7898        const karte = (q) => {
7899            const st = this._qStand(q);
7900            const adr = (d.basis_url || '') + encodeURIComponent(q.name);
7901            return '<div class="p-3 mb-2 rounded-3" style="background:#fff;'
7902                + 'border:1px solid #e9ecef' + (q.aktiv ? '' : ';opacity:.65') + '">'
7903                + '<div class="d-flex align-items-start">'
7904                + '<div class="me-auto" style="min-width:0">'
7905                + '<div class="fw-semibold">' + esc(q.titel || q.name)
7906                + ' <code class="ms-1" style="font-size:.8rem">' + esc(q.name) + '</code>'
7907                + ' <span class="badge bg-' + st.farbe + ' ms-1">' + st.text + '</span>'
7908                + '</div>'
7909                + (q.beschreibung ? '<div class="text-muted mt-1" style="font-size:.88rem">'
7910                    + esc(q.beschreibung) + '</div>' : '')
7911                + '<div class="text-muted mt-2" style="font-size:.82rem">'
7912                + '<i class="fas fa-clock me-1"></i>'
7913                + (q.letzter_lauf
7914                    ? t('zuletzt') + ' ' + esc(this._zeitKurz(q.letzter_lauf))
7915                    : t('noch nie'))
7916                + ' · <i class="fas fa-list-ol me-1"></i>'
7917                + esc(String(q.anzahl == null ? '—' : q.anzahl)) + ' ' + t('Datensätze')
7918                + ' · <i class="fas fa-rotate me-1"></i>' + t('alle')
7919                + ' ' + esc(String(q.takt_minuten || 360)) + ' ' + t('Min')
7920                + '</div>'
7921                + (q.letzter_fehler
7922                    ? '<div class="mt-2 text-danger" style="font-size:.84rem">'
7923                      + '<i class="fas fa-triangle-exclamation me-1"></i>'
7924                      + esc(q.letzter_fehler) + '</div>' : '')
7925                + '<div class="mt-2 d-flex align-items-center" style="font-size:.8rem">'
7926                + '<i class="fas fa-link me-1 text-muted"></i>'
7927                + '<code class="text-truncate" style="max-width:52ch">' + esc(adr) + '</code>'
7928                + '</div>'
7929                + '</div>'
7930                + '<div class="ms-3 text-nowrap">'
7931                + '<button class="btn btn-sm btn-outline-secondary me-1" data-action="q-holen" '
7932                + 'data-id="' + esc(q.id) + '" title="' + t('Jetzt neu holen') + '">'
7933                + '<i class="fas fa-rotate"></i></button>'
7934                + '<button class="btn btn-sm btn-outline-secondary me-1" data-action="q-bearb" '
7935                + 'data-id="' + esc(q.id) + '" title="' + t('Bearbeiten') + '">'
7936                + '<i class="fas fa-pen"></i></button>'
7937                + '<button class="btn btn-sm btn-outline-secondary me-1" data-action="q-an" '
7938                + 'data-id="' + esc(q.id) + '" data-arg="' + (q.aktiv ? '0' : '1') + '" '
7939                + 'title="' + (q.aktiv ? t('Abschalten') : t('Einschalten')) + '">'
7940                + '<i class="fas fa-power-off"></i></button>'
7941                + '<button class="btn btn-sm btn-outline-secondary me-1" data-action="q-schluessel" '
7942                + 'data-id="' + esc(q.id) + '" title="' + t('Neuen Schlüssel setzen') + '">'
7943                + '<i class="fas fa-key"></i></button>'
7944                + '<button class="btn btn-sm btn-outline-danger" data-action="q-del" '
7945                + 'data-id="' + esc(q.id) + '" title="' + t('Löschen') + '">'
7946                + '<i class="fas fa-trash"></i></button>'
7947                + '</div></div></div>';
7948        };
7949
7950        el.innerHTML = kopf + liste.map(karte).join('')
7951            + '<div class="text-muted mt-2" style="font-size:.8rem">'
7952            + liste.length + ' ' + t('von') + ' ' + (g.je_mandant || 20) + '</div>'
7953            + '<div id="aiTeamQForm" class="mt-3"></div>';
7954    }
7955
7956    /** Formular. Adressen und Felder als wiederholbare Zeilen — der
7957     *  Dienst nimmt Listen entgegen, ein einzelnes Feld waere gelogen. */
7958    static _qForm(qid) {
7959        const q = qid
7960            ? ((this._qDaten || {}).quellen || []).find(x => x.id === qid) || {}
7961            : {};
7962        const box = document.getElementById('aiTeamQForm');
7963        if (!box) return;
7964        const adressen = (q.quellen && q.quellen.length ? q.quellen : ['']);
7965        const felder = (q.felder && q.felder.length ? q.felder
7966            : [{ name: '', selektor: '', attribut: '' }]);
7967
7968        const adrZeile = (v, i) => '<div class="input-group input-group-sm mb-1" id="qadr' + i + '">'
7969            + '<input class="form-control q-adr" placeholder="https://…" value="' + esc(v || '') + '">'
7970            + '<button class="btn btn-outline-secondary" data-action="q-zeile-del" '
7971            + 'data-id="qadr' + i + '"><i class="fas fa-xmark"></i></button></div>';
7972        const feldZeile = (f, i) => '<div class="input-group input-group-sm mb-1" id="qfeld' + i + '">'
7973            + '<input class="form-control q-f-name" style="max-width:22%" placeholder="' + t('Feldname') + '" value="' + esc(f.name || '') + '">'
7974            + '<input class="form-control q-f-sel" placeholder="CSS-Selektor, z. B. a[href*=&quot;/detail/&quot;]" value="' + esc(f.selektor || '') + '">'
7975            + '<input class="form-control q-f-attr" style="max-width:18%" placeholder="' + t('Attribut (leer = Text)') + '" value="' + esc(f.attribut || '') + '">'
7976            + '<button class="btn btn-outline-secondary" data-action="q-zeile-del" '
7977            + 'data-id="qfeld' + i + '"><i class="fas fa-xmark"></i></button></div>';
7978
7979        box.innerHTML = '<div class="p-3 rounded-3" style="background:#fff;border:1px solid #e9ecef">'
7980            + '<h6 class="mb-3">' + (qid ? t('Datenquelle bearbeiten') : t('Neue Datenquelle')) + '</h6>'
7981            + '<div class="row g-2 mb-2">'
7982            + '<div class="col-md-4"><label class="form-label small">' + t('Kurzname (in der Adresse)') + '</label>'
7983            + '<input id="qName" class="form-control form-control-sm" value="' + esc(q.name || '') + '"'
7984            + (qid ? ' disabled' : '') + ' placeholder="spielplan"></div>'
7985            + '<div class="col-md-5"><label class="form-label small">' + t('Bezeichnung') + '</label>'
7986            + '<input id="qTitel" class="form-control form-control-sm" value="' + esc(q.titel || '') + '"></div>'
7987            + '<div class="col-md-3"><label class="form-label small">' + t('Takt (Minuten)') + '</label>'
7988            + '<input id="qTakt" type="number" min="30" class="form-control form-control-sm" '
7989            + 'value="' + esc(String(q.takt_minuten || 360)) + '"></div>'
7990            + '</div>'
7991            + '<div class="mb-2"><label class="form-label small">' + t('Beschreibung') + '</label>'
7992            + '<input id="qBeschr" class="form-control form-control-sm" value="' + esc(q.beschreibung || '') + '"></div>'
7993            + '<div class="mb-2"><label class="form-label small">' + t('Adressen') + '</label>'
7994            + '<div id="qAdr">' + adressen.map(adrZeile).join('') + '</div>'
7995            + '<button class="btn btn-sm btn-outline-secondary" data-action="q-adr-add">'
7996            + '<i class="fas fa-plus me-1"></i>' + t('Adresse') + '</button></div>'
7997            + '<div class="mb-2"><label class="form-label small">' + t('Felder') + '</label>'
7998            + '<div id="qFelder">' + felder.map(feldZeile).join('') + '</div>'
7999            + '<button class="btn btn-sm btn-outline-secondary" data-action="q-feld-add">'
8000            + '<i class="fas fa-plus me-1"></i>' + t('Feld') + '</button></div>'
8001            + '<div id="qFehler" class="text-danger small mb-2"></div>'
8002            + '<button class="btn btn-primary btn-sm" data-action="q-speichern" '
8003            + 'data-id="' + esc(qid || '') + '">' + t('Speichern') + '</button>'
8004            + '</div>';
8005        box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
8006    }
8007
8008    static _qZeile(art) {
8009        const i = 'n' + Date.now();
8010        const box = document.getElementById(art === 'adr' ? 'qAdr' : 'qFelder');
8011        if (!box) return;
8012        const div = document.createElement('div');
8013        div.className = 'input-group input-group-sm mb-1';
8014        div.id = (art === 'adr' ? 'qadr' : 'qfeld') + i;
8015        div.innerHTML = art === 'adr'
8016            ? '<input class="form-control q-adr" placeholder="https://…">'
8017              + '<button class="btn btn-outline-secondary" data-action="q-zeile-del" data-id="' + div.id + '"><i class="fas fa-xmark"></i></button>'
8018            : '<input class="form-control q-f-name" style="max-width:22%" placeholder="' + t('Feldname') + '">'
8019              + '<input class="form-control q-f-sel" placeholder="' + t('CSS-Selektor') + '">'
8020              + '<input class="form-control q-f-attr" style="max-width:18%" placeholder="' + t('Attribut') + '">'
8021              + '<button class="btn btn-outline-secondary" data-action="q-zeile-del" data-id="' + div.id + '"><i class="fas fa-xmark"></i></button>';
8022        box.appendChild(div);
8023    }
8024
8025    static _qSammeln() {
8026        const wert = (id) => (document.getElementById(id) || {}).value || '';
8027        const adressen = Array.from(document.querySelectorAll('#qAdr .q-adr'))
8028            .map(i =>
8028 i.value.trim()).filter(Boolean);
8029        const felder = Array.from(document.querySelectorAll('#qFelder .input-group'))
8030            .map(z => ({
8031                name: (z.querySelector('.q-f-name') || {}).value || '',
8032                selektor: (z.querySelector('.q-f-sel') || {}).value || '',
8033                attribut: (z.querySelector('.q-f-attr') || {}).value || '',
8034            }))
8035            .filter(f => f.name.trim() && f.selektor.trim());
8036        return {
8037            name: wert('qName').trim(),
8038            titel: wert('qTitel').trim(),
8039            beschreibung: wert('qBeschr').trim(),
8040            takt_minuten: parseInt(wert('qTakt'), 10) || 360,
8041            quellen: adressen,
8042            felder: felder,
8043        };
8044    }
8045
8046    static async _qSpeichern(qid) {
8047        const daten = this._qSammeln();
8048        const fehlerBox = document.getElementById('qFehler');
8049        const zeig = (m) => { if (fehlerBox) fehlerBox.textContent = m; };
8050        zeig('');
8051        try {
8052            if (qid) {
8053                delete daten.name;   // Kurzname steckt in der Adresse
8054                await api('/api/ai-team/quellen/' + encodeURIComponent(qid), 'PUT', daten);
8055                await this._renderQuellen();
8056            } else {
8057                const r = await api('/api/ai-team/quellen', 'POST', daten);
8058                await this._renderQuellen();
8059                // Der Schluessel erscheint GENAU EINMAL — er muss deutlich
8060                // stehen bleiben, nicht in einer Meldung verschwinden.
8061                this._modal('<h5>' + t('Datenquelle angelegt') + '</h5>'
8062                    + '<p class="small">' + t('Dieser Schlüssel wird nur jetzt angezeigt. '
8063                      + 'Bewahren Sie ihn auf — später lässt er sich nur ersetzen, '
8064                      + 'nicht wieder anzeigen.') + '</p>'
8065                    + '<div class="p-2 rounded" style="background:#f6f8fa">'
8066                    + '<code style="word-break:break-all">' + esc(r.schluessel || '') + '</code></div>'
8067                    + '<button class="btn btn-secondary mt-3" data-action="modal-zu">'
8068                    + t('Schließen') + '</button>');
8069            }
8070        } catch (e) { zeig(e.message || String(e)); }
8071    }
8072
8073    static async _qAktiv(qid, an) {
8074        try {
8075            await api('/api/ai-team/quellen/' + encodeURIComponent(qid), 'PUT', { aktiv: an });
8076            await this._renderQuellen();
8077        } catch (e) { alert(e.message || String(e)); }
8078    }
8079
8080    static async _qHolen(qid) {
8081        try {
8082            const r = await api('/api/ai-team/quellen/' + encodeURIComponent(qid)
8083                + '/ausfuehren', 'POST', {});
8084            await this._renderQuellen();
8085            if (r && r.hinweis) alert(r.hinweis);
8086        } catch (e) { alert(e.message || String(e)); }
8087    }
8088
8089    static _qSchluesselFragen(qid) {
8090        this._modal('<h5>' + t('Neuen Schlüssel setzen?') + '</h5>'
8091            + '<p class="small">' + t('Der bisherige Schlüssel gilt danach nicht '
8092              + 'mehr. Alles, was diese Datenquelle abruft, muss auf den neuen '
8093              + 'Schlüssel umgestellt werden.') + '</p>'
8094            + '<button class="btn btn-warning" data-action="q-schluessel-ja" '
8095            + 'data-id="' + esc(qid) + '">' + t('Neuen Schlüssel setzen') + '</button>'
8096            + '<button class="btn btn-secondary ms-2" data-action="modal-zu">'
8097            + t('Abbrechen') + '</button>');
8098    }
8099
8100    static async _qSchluesselNeu(qid) {
8101        try {
8102            const r = await api('/api/ai-team/quellen/' + encodeURIComponent(qid)
8103                + '/schluessel', 'POST', {});
8104            this._modal('<h5>' + t('Neuer Schlüssel') + '</h5>'
8105                + '<p class="small">' + t('Nur jetzt sichtbar.') + '</p>'
8106                + '<div class="p-2 rounded" style="background:#f6f8fa">'
8107                + '<code style="word-break:break-all">' + esc(r.schluessel || '') + '</code></div>'
8108                + '<button class="btn btn-secondary mt-3" data-action="modal-zu">'
8109                + t('Schließen') + '</button>');
8110        } catch (e) { alert(e.message || String(e)); }
8111    }
8112
8113    static _qDelFragen(qid) {
8114        const q = ((this._qDaten || {}).quellen || []).find(x => x.id === qid) || {};
8115        this._modal('<h5>' + t('Datenquelle löschen?') + '</h5>'
8116            + '<p>' + esc(q.titel || q.name || '') + '</p>'
8117            + '<p class="small text-muted">' + t('Der abgelegte Stand geht mit '
8118              + 'verloren. Telefon-Assistenten, die darauf zugreifen, finden '
8119              + 'danach nichts mehr.') + '</p>'
8120            + '<button class="btn btn-danger" data-action="q-del-ja" '
8121            + 'data-id="' + esc(qid) + '">' + t('Löschen') + '</button>'
8122            + '<button class="btn btn-secondary ms-2" data-action="modal-zu">'
8123            + t('Abbrechen') + '</button>');
8124    }
8125
8126    static async _qDel(qid) {
8127        try {
8128            await api('/api/ai-team/quellen/' + encodeURIComponent(qid), 'DELETE');
8129            this._modal('');
8130            await this._renderQuellen();
8131        } catch (e) { alert(e.message || String(e)); }
8132    }
8133
8134    // ============================================ ai-ablage-uben-ui-v1
8135    static _abUbenFragen(pfad, name) {
8136        this._modal('<h5>' + t('Umbenennen') + '</h5>'
8137            + '<input id="abUbenName" class="form-control" value="' + esc(name || '') + '">'
8138            + '<div class="form-text">' + t('Die Dateiendung bleibt erhalten.') + '</div>'
8139            + '<button class="btn btn-primary mt-3" data-action="ab-uben-ja" '
8140            + 'data-id="' + esc(pfad) + '">' + t('Umbenennen') + '</button>'
8141            + '<button class="btn btn-secondary mt-3 ms-2" data-action="modal-zu">'
8142            + t('Abbrechen') + '</button>');
8143        setTimeout(() => {
8144            const i = document.getElementById('abUbenName');
8145            if (i) { i.focus(); i.select(); }
8146        }, 120);
8147    }
8148
8149    static async _abUben(pfad) {
8150        const n = (document.getElementById('abUbenName') || {}).value || '';
8151        if (!n.trim()) return;
8152        try {
8153            await api('/api/ai-team/employees/' + encodeURIComponent(this._akteId)
8154                + '/ablage/umbenennen', 'POST', { pfad: pfad, name: n.trim() });
8155            this._modal('');
8156            await this._abLaden((this._ab || {}).pfad || '');
8157        } catch (e) { alert(e.message || String(e)); }
8158    }
8159
8160    static async _renderErweiterungen(host) {
8161        const el = document.getElementById('aiTeamInhalt');
8162        if (!el) return;
8163        el.innerHTML = '<div class="text-muted p-3">' + t('Lade …') + '</div>';
8164        let d;
8165        try {
8166            d = await api('/api/ai-team/extensions');
8167        } catch (e) {
8168            el.innerHTML = '<div class="alert alert-warning">'
8169                + esc(e.message || String(e)) + '</div>';
8170            return;
8171        }
8172        this._extDaten = d;
8173        const liste = d.extensions || [];
8174        const g = d.grenzen || {};
8175
8176        const kopf = '<div class="d-flex align-items-start mb-3">'
8177            + '<div class="me-auto">'
8178            + '<h5 class="mb-1">' + t('Eigene Erweiterungen') + '</h5>'
8179            + '<div class="text-muted" style="font-size:.9rem;max-width:60ch">'
8180            + t('Hier hinterlegen Sie eigene Werkzeuge für Ihre Mitarbeiter. '
8181              + 'Die Erweiterung selbst läuft auf Ihrem eigenen Server — wir '
8182              + 'rufen die angegebene Adresse auf und geben dem Mitarbeiter '
8183              + 'die Antwort. Aus Sicherheitsgründen sind nur öffentlich '
8184              + 'erreichbare https-Adressen zulässig.')
8185            + '</div></div>'
8186            + '<button class="btn btn-primary" data-action="ext-neu">'
8187            + '<i class="fas fa-plus me-1"></i>' + t('Erweiterung anlegen')
8188            + '</button></div>';
8189
8190        if (!liste.length) {
8191            el.innerHTML = kopf
8192                + '<div class="text-center text-muted py-5 rounded-3" '
8193                + 'style="background:#fff;border:1px solid #e9ecef">'
8194                + '<i class="fas fa-puzzle-piece fa-2x mb-3 d-block opacity-50"></i>'
8195                + t('Noch keine Erweiterung angelegt.') + '<br>'
8196                + '<span style="font-size:.9rem">'
8197                + t('Beispiel: eine Adresse, die aktuelle Informationen von '
8198                  + 'Ihrer Website liefert.') + '</span></div>'
8199                + '<div id="aiTeamExtForm" class="mt-3"></div>';
8200            return;
8201        }
8202
8203        const karte = (e) => {
8204            const liest = e.klasse === 'lesen';
8205            return '<div class="p-3 mb-2 rounded-3" style="background:#fff;'
8206                + 'border:1px solid #e9ecef' + (e.aktiv ? '' : ';opacity:.6') + '">'
8207                + '<div class="d-flex align-items-start">'
8208                + '<div class="me-auto" style="min-width:0">'
8209                + '<div class="fw-semibold">' + esc(e.titel || e.name)
8210                + ' <code class="ms-1" style="font-size:.8rem">' + esc(e.name) + '</code>'
8211                + (e.aktiv ? '' : ' <span class="badge bg-secondary ms-1">'
8212                    + t('abgeschaltet') + '</span>')
8213                + '</div>'
8214                + '<div class="text-muted mt-1" style="font-size:.88rem">'
8215                + esc(e.beschreibung) + '</div>'
8216                + '<div class="mt-2" style="font-size:.82rem">'
8217                + '<span class="badge ' + (liest ? 'bg-light text-dark' : 'bg-warning text-dark')
8218                + '">' + (liest ? t('Liest nur') : t('Verändert etwas')) + '</span>'
8219                + '<span class="text-muted ms-2">' + esc(e.methode) + ' '
8220                + esc((e.url || '').slice(0, 60)) + '</span>'
8221                + (e.kopfzeilen_namen && e.kopfzeilen_namen.length
8222                    ? '<span class="text-muted ms-2"><i class="fas fa-key me-1"></i>'
8223                      + esc(e.kopfzeilen_namen.join(', ')) + '</span>' : '')
8224                + '</div>'
8225                // ai-team-ext-zuw-v1: ohne Zuweisung nutzt sie niemand —
8226                // das muss hier stehen, nicht nur in der Personalakte.
8227                + '<div class="mt-2" style="font-size:.82rem">'
8228                + ((e.genutzt_von && e.genutzt_von.length)
8229                    ? '<span class="text-muted"><i class="fas fa-user me-1"></i>'
8230                      + t('Zugewiesen an') + ' ' + esc(e.genutzt_von.join(', '))
8231                      + '</span>'
8232                    : '<span class="text-warning-emphasis">'
8233                      + '<i class="fas fa-triangle-exclamation me-1"></i>'
8234                      + t('Noch niemandem zugewiesen — kein Mitarbeiter kann '
8235                        + 'sie nutzen. Zuweisen im Team unter „Fähigkeiten“.')
8236                      + '</span>')
8237                + '</div></div>'
8238                + '<div class="d-flex gap-1 flex-shrink-0">'
8239                + '<button class="btn btn-sm btn-outline-secondary" '
8240                + 'data-action="ext-testen" data-id="' + esc(e.id) + '">'
8241                + t('Testen') + '</button>'
8242                + '<button class="btn btn-sm btn-outline-secondary" '
8243                + 'data-action="ext-umschalten" data-id="' + esc(e.id) + '" '
8244                + 'data-arg="' + (e.aktiv ? '0' : '1') + '">'
8245                + (e.aktiv ? t('Abschalten') : t('Einschalten')) + '</button>'
8246                + '<button class="btn btn-sm btn-outline-secondary" '
8247                + 'data-action="ext-bearbeiten" data-id="' + esc(e.id) + '">'
8248                + t('Bearbeiten') + '</button>'
8249                + '<button class="btn btn-sm btn-outline-danger" '
8250                + 'data-action="ext-loeschen" data-id="' + esc(e.id) + '">'
8251                + t('Löschen') + '</button>'
8252                + '</div></div></div>';
8253        };
8254
8255        el.innerHTML = kopf
8256            + liste.map(karte).join('')
8257            + '<div class="text-muted mt-2" style="font-size:.82rem">'
8258            + liste.length + ' ' + t('von') + ' ' + (g.je_mandant || '—') + ' '
8259            + t('Erweiterungen angelegt.') + ' '
8260            + t('Je Mitarbeiter sind höchstens') + ' ' + (g.je_mitarbeiter || '—')
8261            + ' ' + t('zuweisbar.') + '</div>'
8262            + '<div id="aiTeamExtForm" class="mt-3"></div>';
8263    }
8264
8265    static _extFormular(extId) {
8266        const d = this._extDaten || {};
8267        const e = (d.extensions || []).find(x => x.id === extId) || {};
8268        const box = document.getElementById('aiTeamExtForm');
8269        if (!box) return;
8270        const params = e.parameter || [];
8271        const f = (id, label, wert, hinweis, typ) =>
8272            '<div class="mb-3"><label class="form-label fw-semibold" '
8273            + 'style="font-size:.9rem">' + label + '</label>'
8274            + '<input type="' + (typ || 'text') + '" class="form-control" id="' + id
8275            + '" value="' + esc(wert || '') + '">'
8276            + (hinweis ? '<div class="form-text">' + hinweis + '</div>' : '')
8277            + '</div>';
8278        box.innerHTML = '<div class="p-3 rounded-3" style="background:#fff;'
8279            + 'border:1px solid #e9ecef">'
8280            + '<h6 class="mb-3">'
8281            + (extId ? t('Erweiterung bearbeiten') : t('Neue Erweiterung'))
8282            + '</h6>'
8283            + f('extTitel', t('Anzeigename'), e.titel,
8284                t('Wie sie in der Liste heißt, z. B. „Website auslesen“.'))
8285            + (extId ? '' : f('extName', t('Technischer Name'), e.name,
8286                t('Kleinbuchstaben, Ziffern, Unterstrich — z. B. website_lesen. '
8287                + 'Unter diesem Namen ruft der Mitarbeiter sie auf. '
8288                + 'Später nicht mehr änderbar.')))
8289            + '<div class="mb-3"><label class="form-label fw-semibold" '
8290            + 'style="font-size:.9rem">' + t('Wofür ist sie da?') + '</label>'
8291            + '<textarea class="form-control" id="extBeschreibung" rows="2">'
8292            + esc(e.beschreibung || '') + '</textarea>'
8293            + '<div class="form-text">'
8294            + t('Daran erkennt der Mitarbeiter, wann er sie einsetzt. '
8295              + 'Dieser Text geht in jedes Gespräch.') + '</div></div>'
8296            + f('extUrl', t('Adresse (https)'), e.url,
8297                t('Muss öffentlich erreichbar sein. Adressen im internen Netz '
8298                + 'werden abgewiesen.'))
8299            + '<div class="row">'
8300            + '<div class="col-md-6 mb-3"><label class="form-label fw-semibold" '
8301            + 'style="font-size:.9rem">' + t('Methode') + '</label>'
8302            + '<select class="form-select" id="extMethode">'
8303            + (d.methoden || ['GET', 'POST']).map(mm =>
8304                '<option' + (e.methode === mm ? ' selected' : '') + '>' + mm
8305                + '</option>').join('')
8306            + '</select></div>'
8307            + '<div class="col-md-6 mb-3"><label class="form-label fw-semibold" '
8308            + 'style="font-size:.9rem">' + t('Wirkung') + '</label>'
8309            + '<select class="form-select" id="extWirkung">'
8310            + (d.wirkungen || []).map(w =>
8311                '<option value="' + esc(w.key) + '"'
8312                + ((e.wirkung || 'veraendert') === w.key ? ' selected' : '')
8313                + '>' + esc(w.label) + '</option>').join('')
8314            + '</select>'
8315            + '<div class="form-text">'
8316            + t('„Verändert etwas“ ist die sichere Vorgabe — dann entscheidet '
8317              + 'die Vertrauensstufe des Mitarbeiters.') + '</div></div>'
8318            + '</div>'
8319            + '<div class="mb-3"><label class="form-label fw-semibold" '
8320            + 'style="font-size:.9rem">' + t('Parameter (JSON)') + '</label>'
8321            + '<textarea class="form-control font-monospace" id="extParameter" '
8322            + 'rows="4" style="font-size:.85rem">'
8323            + esc(JSON.stringify(params, null, 1)) + '</textarea>'
8324            + '<div class="form-text">'
8325            + t('Liste von Objekten mit name, typ, beschreibung, pflicht.')
8326            + '</div></div>'
8327            + '<div class="mb-3"><label class="form-label fw-semibold" '
8328            + 'style="font-size:.9rem">' + t('Kopfzeilen (JSON)') + '</label>'
8329            + '<textarea class="form-control font-monospace" id="extKopfzeilen" '
8330            + 'rows="2" style="font-size:.85rem">'
8331            + esc(JSON.stringify(
8332                (e.kopfzeilen_namen || []).reduce((a, k) => (a[k] = '', a), {}),
8333                null, 1))
8334            + '</textarea>'
8335            + '<div class="form-text">'
8336            + t('Für Zugangstoken. Werte werden verschlüsselt gespeichert und '
8337              + 'nie wieder angezeigt — leer lassen behält den bisherigen Wert.')
8338            + '</div></div>'
8339            + '<div id="extFehler"></div>'
8340            + '<button class="btn btn-primary" data-action="ext-speichern" '
8341            + 'data-id="' + esc(extId || '') + '">' + t('Speichern') + '</button>'
8342            + '</div>';
8343        box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
8344    }
8345
8346    static _extWerte(extId) {
8347        const v = (id) => (document.getElementById(id) || {}).value || '';
8348        const daten = {
8349            titel: v('extTitel'),
8350            beschreibung: v('extBeschreibung'),
8351            url: v('extUrl'),
8352            methode: v('extMethode'),
8353            wirkung: v('extWirkung'),
8354        };
8355        if (!extId) daten.name = v('extName');
8356        for (const [feld, id] of [['parameter', 'extParameter'],
8357                                  ['kopfzeilen', 'extKopfzeilen']]) {
8358            const roh = v(id).trim();
8359            if (!roh) { daten[feld] = feld === 'parameter' ? [] : {}; continue; }
8360            try {
8361                daten[feld] = JSON.parse(roh);
8362            } catch (e) {
8363                return { fehler: t('Die Angabe unter „') + feld
8364                    + t('“ ist kein gültiges JSON.') };
8365            }
8366        }
8367        return { daten };
8368    }
8369
8370    static async _extSpeichern(extId) {
8371        const box = document.getElementById('extFehler');
8372        const zeig = (txt, art) => {
8373            if (box) box.innerHTML = '<div class="alert alert-' + (art || 'danger')
8374                + ' py-2">' + esc(txt) + '</div>';
8375        };
8376        const { daten, fehler } = this._extWerte(extId);
8377        if (fehler) return zeig(fehler);
8378        try {
8379            if (extId) {
8380                await api('/api/ai-team/extensions/' + encodeURIComponent(extId),
8381                          'PUT', daten);
8382            } else {
8383                await api('/api/ai-team/extensions', 'POST', daten);
8384            }
8385            await this._renderErweiterungen();
8386        } catch (e) {
8387            zeig(e.message || String(e));
8388        }
8389    }
8390
8391    static async _extUmschalten(extId, an) {
8392        try {
8393            await api('/api/ai-team/extensions/' + encodeURIComponent(extId),
8394                      'PUT', { aktiv: String(an) === '1' });
8395            await this._renderErweiterungen();
8396        } catch (e) { alert(e.message || String(e)); }
8397    }
8398
8399    static async _extLoeschen(extId) {
8400        const e = ((this._extDaten || {}).extensions || [])
8401            .find(x => x.id === extId) || {};
8402        if (!confirm(t('Erweiterung „') + (e.titel || e.name)
8403                     + t('“ wirklich löschen? Sie wird allen Mitarbeitern '
8404                       + 'entzogen, denen sie zugewiesen ist.'))) return;
8405        try {
8406            await api('/api/ai-team/extensions/' + encodeURIComponent(extId),
8407                      'DELETE');
8408            await this._renderErweiterungen();
8409        } catch (er) { alert(er.message || String(er)); }
8410    }
8411
8412    static async _extTesten(extId) {
8413        const e = ((this._extDaten || {}).extensions || [])
8414            .find(x => x.id === extId) || {};
8415        const werte = {};
8416        for (const p of (e.parameter || [])) {
8417            const w = prompt(t('Testwert für „') + p.name + '“'
8418                + (p.beschreibung ? ' (' + p.beschreibung + ')' : ''), '');
8419            if (w === null) return;
8420            if (w !== '') werte[p.name] = w;
8421        }
8422        const box = document.getElementById('aiTeamExtForm');
8423        if (box) box.innerHTML = '<div class="text-muted">' + t('Rufe auf …')
8424            + '</div>';
8425        try {
8426            const d = await api('/api/ai-team/extensions/'
8427                + encodeURIComponent(extId) + '/test', 'POST', { werte });
8428            const r = d.ergebnis || {};
8429            const fehler = r.fehler;
8430            if (box) box.innerHTML = '<div class="p-3 rounded-3" style="background:#fff;'
8431                + 'border:1px solid ' + (fehler ? '#f5c2c7' : '#badbcc') + '">'
8432                + '<div class="fw-semibold mb-2">'
8433                + (fehler ? t('Der Probeaufruf ist fehlgeschlagen')
8434                          : t('Der Probeaufruf hat funktioniert'))
8435                + '</div>'
8436                + (fehler ? '<div class="text-danger">' + esc(fehler) + '</div>'
8437                   : '<div class="text-muted mb-2" style="font-size:.85rem">HTTP '
8438                     + esc(String(r.status)) + ' · ' + esc(String(r.dauer_ms))
8439                     + ' ms</div>'
8440                     + '<pre class="mb-0 p-2 rounded" style="background:#f8f9fa;'
8441                     + 'font-size:.8rem;max-height:220px;overflow:auto">'
8442                     + esc(JSON.stringify(r.daten !== undefined ? r.daten : r.text,
8443                                          null, 1) || '').slice(0, 4000)
8444                     + '</pre>')
8445                + '</div>';
8446        } catch (er) {
8447            if (box) box.innerHTML = '<div class="alert alert-danger">'
8448                + esc(er.message || String(er)) + '</div>';
8449        }
8450    }
8451
8452    /** Zuweisung in der Personalakte — im Reiter "Fähigkeiten", weil dort
8453     *  bereits steht, WAS der Mitarbeiter kann. */
8454    static _akteErweiterungen(m) {
8455        const alle = ((this._extDaten || {}).extensions || [])
8456            .filter(e => e.aktiv);
8457        const zug = ((m.config || {}).erweiterungen) || [];
8458        if (!alle.length) {
8459            return '<div class="p-3 rounded-3 mb-3" style="background:#fff;'
8460                + 'border:1px solid #e9ecef">'
8461                + '<div class="fw-semibold mb-1">' + t('Eigene Erweiterungen')
8462                + '</div>'
8463                + '<div class="text-muted" style="font-size:.88rem">'
8464                + t('Es ist noch keine Erweiterung angelegt. Sie finden das '
8465                  + 'im Reiter „Erweiterungen“.') + '</div></div>';
8466        }
8467        return '<div class="p-3 rounded-3 mb-3" style="background:#fff;'
8468            + 'border:1px solid #e9ecef">'
8469            + '<div class="fw-semibold mb-1">' + t('Eigene Erweiterungen')
8470            + '</div>'
8471            + '<div class="text-muted mb-3" style="font-size:.88rem">'
8472            + t('Werkzeuge, die Sie selbst eingerichtet haben. Nur zugewiesene '
8473              + 'kann dieser Mitarbeiter nutzen.') + '</div>'
8474            + alle.map(e =>
8475                '<div class="form-check mb-2">'
8476                + '<input class="form-check-input" type="checkbox" '
8477                + 'id="ext_' + esc(e.id) + '" data-extbox="1" '
8478                + 'value="' + esc(e.id) + '"'
8479                + (zug.indexOf(e.id) >= 0 ? ' checked' : '') + '>'
8480                + '<label class="form-check-label" for="ext_' + esc(e.id) + '">'
8481                + esc(e.titel || e.name)
8482                + ' <span class="text-muted" style="font-size:.85rem">— '
8483                + esc(e.beschreibung) + '</span></label></div>').join('')
8484            + '<button class="btn btn-sm btn-primary mt-2" '
8485            + 'data-action="ext-zuweisen" data-id="' + esc(m.id) + '">'
8486            + t('Zuweisung speichern') + '</button>'
8487            + '<span id="extZuwHinweis" class="ms-2 text-muted" '
8488            + 'style="font-size:.85rem"></span>'
8489            + '</div>';
8490    }
8491
8492    static async _extZuweisen(empId) {
8493        const ids = Array.from(document.querySelectorAll('[data-extbox]'))
8494            .filter(b => b.checked).map(b => b.value);
8495        const h = document.getElementById('extZuwHinweis');
8496        try {
8497            await api('/api/ai-team/employees/' + encodeURIComponent(empId)
8498                      + '/extensions', 'PUT', { extensions: ids });
8499            if (h) h.textContent = t('Gespeichert.');
8500            if (this._akteDaten && this._akteDaten.member) {
8501                this._akteDaten.member.config =
8502                    Object.assign({}, this._akteDaten.member.config || {},
8503                                  { erweiterungen: ids });
8504            }
8505        } catch (e) {
8506            if (h) h.textContent = e.message || String(e);
8507        }
8508    }
8509
8510    static async _renderZugaenge(host) {
8511        host.innerHTML = '<div class="text-muted">' + t('Lade …') + '</div>';
8512        try { await this._specLaden(); }
8513        catch (e) {
8514            host.innerHTML = '<div class="alert alert-danger">' + esc(e.message) + '</div>';
8515            return;
8516        }
8517        if (this._resForm) return this._renderResForm(host);
8518
8519        const einf = '<div class="card mb-3"><div class="card-body">'
8520            + '<h6 class="mb-1"><i class="fas fa-key me-2"></i>' + t('Zugänge Ihrer Firma') + '</h6>'
8521            + '<div class="text-muted small">'
8522            + t('Ein Zugang wird einmal eingerichtet und dann einzelnen Mitarbeitern zugewiesen — '
8523                + 'wie ein Postfach in einer echten Firma. Zugangsdaten werden verschlüsselt '
8524                + 'gespeichert und nie wieder im Klartext angezeigt.')
8525            + '</div></div></div>';
8526
8527        const gruppen = ['postfach', 'llm', 'ablage'].map(typ => {
8528            const liste = this._ressourcen.filter(r => r.typ === typ);
8529            const spec = ((this._spec || {}).typen || {})[typ] || {};
8530            const karten = liste.length
8531                ? '<div class="row g-2">' + liste.map(r => this._resKarte(r)).join('') + '</div>'
8532                : '<div class="text-muted small fst-italic">' + t('Noch nichts eingerichtet.') + '</div>';
8533            return '<div class="card mb-3"><div class="card-body">'
8534                + '<div class="d-flex align-items-center mb-2">'
8535                + '<h6 class="mb-0 me-auto"><i class="fas ' + esc(this._resIcon(typ)) + ' me-2"></i>'
8536                + esc(spec.label || typ)
8537                + '<span class="badge bg-light text-dark ms-2">' + liste.length + '</span></h6>'
8538                + (typ === 'postfach'
8539                    ? '<button class="btn btn-sm btn-outline-secondary me-2" data-action="res-import-smtp">'
8540                      + '<i class="fas fa-download me-1"></i>' + t('Aus meinem Konto übernehmen') + '</button>' : '')
8541                + '<button class="btn btn-sm btn-primary" data-action="res-neu" data-id="' + esc(typ) + '">'
8542                + '<i class="fas fa-plus me-1"></i>' + t('Hinzufügen') + '</button></div>'
8543                + '<div class="text-muted small mb-2">' + esc(spec.hilfe || '') + '</div>'
8544                + karten + '</div></div>';
8545        }).join('');
8546
8547        host.innerHTML = einf + gruppen + this._adsBlock()
8548            + this._depotBlock() + this._videoBlock() + this._domBlock()
8549            + (this._resTest ? this._resTestHtml() : '');
8550        // Die Werbekonten kommen aus einer eigenen Quelle. Erst zeichnen,
8551        // dann nachladen - sonst starrt der Nutzer auf eine leere Seite,
8552        // waehrend zwei Abrufe nacheinander laufen.
8553        if (!this._adsKatalog) this._adsLaden();
8554        if (!this._videoKatalog) this._videoLaden();  // video-ki-v1
8555        if (!this._depotKatalog) this._depotLaden();
8556        if (!this._domStand) this._domLaden();
8557    }
8558
8559    /* ================================================================
8560       ads-ui-v1 — Werbekonten (Google Ads, Meta Ads)
8561
8562       Warum die hier stehen und nicht bei den Betriebsmitteln: ein
8563       Postfach wird EINEM Mitarbeiter zugewiesen, ein Werbekonto gilt
8564       fuer die ganze Firma. Die Zuweisung waere also eine Luege.
8565       Es gibt je Plattform genau EIN Konto - mehr waere eine Auswahl,
8566       die im Gespraech niemand treffen kann.
8567       ================================================================ */
8568    /* ================================================================
8569       depot-ki-v1 — Depot & Börse
8570
8571       Warum hier und nicht bei den Betriebsmitteln: ein Postfach wird
8572       EINEM Mitarbeiter zugewiesen, ein Depot gehört der Firma. Es
8573       steht deshalb neben den Werbekonten, aus derselben Überlegung.
8574
8575       DER BETRIEBSMODUS IST DIE WICHTIGSTE ANGABE AUF DIESER KARTE.
8576       Papierdepot oder echtes Geld entscheidet über alles, was danach
8577       passiert. Er steht deshalb als farbige Plakette mit Wort UND
8578       Zeichen an jeder Karte, nicht im Kleingedruckten — und die
8579       Farbe allein trägt die Aussage nie.
8580       ================================================================ */
8581    static async _depotLaden(zeichnen = true) {
8582        try {
8583            const d = await api('/api/ai-team/depot');
8584            this._depotKonten = d.konten || [];
8585            this._depotKatalog = d.katalog || {};
8586            this._depotEchtgeld = !!d.echtgeld_frei;
8587            this._depotRichtlinie = d.richtlinie || {};
8588            this._depotFelder = d.felder || {};
8589            this._depotFehler = '';
8590        } catch (e) {
8591            this._depotKatalog = {};
8592            this._depotFehler = e.message;
8593        }
8594        if (zeichnen && this._tab === 'zugaenge') this._render();
8595    }
8596
8597    static _depotModusPlakette(k) {
8598        const echt = k.modus === 'echt';
8599        const farbe = echt ? '#b42318' : '#1a7f37';
8600        const ico = echt ? 'fa-euro-sign' : 'fa-flask';
8601        const wort = echt ? t('Echtes Geld') : t('Papierdepot');
8602        return '<span class="badge" style="background:' + farbe + '">'
8603            + '<i class="fas ' + ico + ' me-1"></i>' + esc(wort) + '</span>';
8604    }
8605
8606    static _depotBlock() {
8607        if (this._depotForm) return this._depotFormHtml();
8608        const kat = this._depotKatalog;
8609        const kopf = '<div class="d-flex align-items-center mb-2">'
8610            + '<h6 class="mb-0 me-auto"><i class="fas fa-chart-line me-2"></i>'
8611            + t('Depot & Börse') + '</h6></div>'
8612            + '<div class="text-muted small mb-2">'
8613            + t('Verbinden Sie Ihr Depot oder Ihre Krypto-Börse. Der '
8614                + 'Investment-Analyst liest damit Bestand und Kurse, rechnet '
8615                + 'Steuer und Risiken — und bereitet Orders vor, die '
8616                + 'ausschließlich Sie freigeben.')
8617            + '</div>';
8618        let inhalt;
8619        if (!kat) {
8620            inhalt = '<div class="text-muted small">' + t('Lade …') + '</div>';
8621        } else if (this._depotFehler) {
8622            inhalt = '<div class="alert alert-warning mb-0 py-2 small">'
8623                + esc(this._depotFehler) + '</div>';
8624        } else {
8625            inhalt = (this._depotEchtgeld ? '' :
8626                '<div class="alert alert-info py-2 small mb-2">'
8627                + '<i class="fas fa-circle-info me-1"></i>'
8628                + t('Der Handel mit echtem Geld ist derzeit systemweit nicht '
8629                    + 'freigeschaltet. Auf einem Papierdepot arbeitet der '
8630                    + 'Analyst vollständig; ein Echtgeld-Depot kann er lesen, '
8631                    + 'aber keine Order auslösen.') + '</div>')
8632                + '<div class="row g-2">'
8633                + (this._depotKonten || []).map(k => this._depotKarte(k)).join('')
8634                + '</div>'
8635                + this._richtlinieBlock();
8636        }
8637        return '<div class="card mb-3"><div class="card-body">'
8638            + kopf + inhalt + '</div></div>';
8639    }
8640
8641    static _depotKarte(k) {
8642        const spec = (this._depotKatalog || {})[k.plattform] || {};
8643        const ico = k.plattform === 'kraken' ? 'fa-bitcoin-sign' : 'fa-building-columns';
8644        // Zustand IMMER mit Wort und Zeichen, nie nur über Farbe.
8645        let stand;
8646        if (!k.verbunden) {
8647            stand = '<span class="text-muted"><i class="fas fa-circle-minus me-1"></i>'
8648                + t('Nicht verbunden') + '</span>';
8649        } else if (!k.aktiv) {
8650            stand = '<span class="text-danger"><i class="fas fa-triangle-exclamation me-1"></i>'
8651                + t('Hinterlegt, aber gesperrt') + '</span>';
8652        } else {
8653            stand = '<span class="text-success"><i class="fas fa-circle-check me-1"></i>'
8654                + t('Verbunden und geprüft') + '</span> '
8655                + this._depotModusPlakette(k);
8656        }
8657        // Der Grund einer Sperre gehört sichtbar an die Karte — sonst
8658        // sucht der Inhaber den Fehler bei sich.
8659        const grund = (k.verbunden && !k.aktiv && k.gesperrt_grund)
8660            ? '<div class="alert alert-danger py-2 small mt-2 mb-0">'
8661              + '<i class="fas fa-shield-halved me-1"></i>'
8662              + esc(k.gesperrt_grund) + '</div>' : '';
8663        const knoepfe = k.verbunden
8664            ? '<button class="btn btn-sm btn-outline-secondary" data-action="depot-test" data-id="'
8665                + esc(k.plattform) + '"><i class="fas fa-plug me-1"></i>'
8666                + t('Verbindung prüfen') + '</button>'
8667              + '<button class="btn btn-sm btn-outline-primary" data-action="depot-bearbeiten" data-id="'
8668                + esc(k.plattform) + '">' + t('Ändern') + '</button>'
8669              + '<button class="btn btn-sm btn-outline-danger ms-auto" data-action="depot-trennen" data-id="'
8670                + esc(k.plattform) + '">' + t('Trennen') + '</button>'
8671            : '<button class="btn btn-sm btn-primary" data-action="depot-bearbeiten" data-id="'
8672                + esc(k.plattform) + '"><i class="fas fa-plus me-1"></i>'
8673                + t('Verbinden') + '</button>';
8674        return '<div class="col-md-6"><div class="border rounded p-3 h-100">'
8675            + '<div class="fw-semibold mb-1"><i class="fas ' + ico + ' me-2"></i>'
8676            + esc(k.label) + '</div>'
8677            + '<div class="small mb-2">' + stand + '</div>'
8678            + (k.aktiv && k.api_key
8679                ? '<div class="text-muted small mb-2">' + t('Schlüssel')
8680                  + ': <code>' + esc(String(k.api_key).slice(0, 6)) + '…</code></div>'
8681                : '')
8682            + '<div class="text-muted small mb-2">' + esc(spec.hilfe || '') + '</div>'
8683            + '<div class="d-flex gap-2 flex-wrap">' + knoepfe + '</div>'
8684            + grund + '</div></div>';
8685    }
8686
8687    static _depotFormHtml() {
8688        const pf = this._depotForm;
8689        const spec = (this._depotKatalog || {})[pf] || {};
8690        const felder = (spec.felder || []).map(f =>
8691            '<div class="mb-2"><label class="form-label small mb-1">'
8692            + esc(f.label) + (f.pflicht ? ' *' : '') + '</label>'
8693            + '<input type="' + (f.geheim ? 'password' : 'text')
8694            + '" class="form-control form-control-sm" id="depotf-' + esc(f.name)
8695            + '" autocomplete="off">'
8696            + (f.hilfe ? '<div class="form-text small">' + esc(f.hilfe) + '</div>' : '')
8697            + '</div>').join('');
8698        // Die Modus-Wahl steht ganz oben und ist im Zweifel die einzige
8699        // Angabe, die der Nutzer wirklich versteht.
8700        const modi = (spec.modi || []).map(m =>
8701            '<div class="form-check">'
8702            + '<input class="form-check-input" type="radio" name="depotmodus" '
8703            + 'id="depotm-' + esc(m.name) + '" value="' + esc(m.name) + '"'
8704            + (m.name === (spec.modus_default || 'papier') ? ' checked' : '')
8705            + (spec.modi.length < 2 ? ' disabled' : '') + '>'
8706            + '<label class="form-check-label small" for="depotm-' + esc(m.name) + '">'
8707            + '<strong>' + esc(m.label) + '</strong> — ' + esc(m.hilfe) + '</label>'
8708            + '</div>').join('');
8709        return '<div class="card mb-3"><div class="card-body">'
8710            + '<h6 class="mb-3"><i class="fas fa-chart-line me-2"></i>'
8711            + esc(spec.label || pf) + ' ' + t('verbinden') + '</h6>'
8712            + '<div class="alert alert-warning py-2 small">'
8713            + '<i class="fas fa-shield-halved me-1"></i>'
8714            + t('Verwenden Sie einen Schlüssel OHNE das Recht, Geld auszuzahlen. '
8715                + 'Wir prüfen das und nehmen einen Schlüssel mit Auszahlungsrecht '
8716                + 'nicht in Betrieb.') + '</div>'
8717            + (spec.modi && spec.modi.length
8718                ? '<div class="mb-3"><label class="form-label small mb-1">'
8719                  + t('Betriebsart') + '</label>' + modi + '</div>' : '')
8720            + felder
8721            + '<div class="d-flex gap-2 mt-3">'
8722            + '<button class="btn btn-sm btn-primary" data-action="depot-speichern">'
8723            + t('Speichern und prüfen') + '</button>'
8724            + '<button class="btn btn-sm btn-outline-secondary" data-action="depot-abbrechen">'
8725            + t('Abbrechen') + '</button></div>'
8726            + (this._depotProbe ? this._depotProbeHtml() : '')
8727            + '</div></div>';
8728    }
8729
8730    static _depotProbeHtml() {
8731        const p = this._depotProbe || {};
8732        const ok = !!p.ok;
8733        return '<div class="alert ' + (ok ? 'alert-success' : 'alert-danger')
8734            + ' py-2 small mt-3 mb-0"><i class="fas '
8735            + (ok ? 'fa-circle-check' : 'fa-circle-xmark') + ' me-1"></i>'
8736            + esc(p.meldung || p.fehler || '') + '</div>';
8737    }
8738
8739    static _richtlinieBlock() {
8740        const r = this._depotRichtlinie || {};
8741        const f = this._depotFelder || {};
8742        const aus = r.notschalter;
8743        const zeile = (k, einheit) =>
8744            '<div class="col-md-6 mb-2"><label class="form-label small mb-1">'
8745            + esc(f[k] || k) + '</label>'
8746            + '<div class="input-group input-group-sm">'
8747            + '<input type="number" class="form-control" id="rl-' + esc(k) + '" value="'
8748            + esc(String(r[k] == null ? '' : r[k])) + '">'
8749            + '<span class="input-group-text">' + esc(einheit) + '</span></div></div>';
8750        return '<hr class="my-3">'
8751            + '<div class="d-flex align-items-center mb-2">'
8752            + '<h6 class="mb-0 me-auto"><i class="fas fa-scale-balanced me-2"></i>'
8753            + t('Anlagerichtlinie') + '</h6></div>'
8754            + '<div class="text-muted small mb-2">'
8755            + t('Diese Regeln sind für den Analysten verbindlich. Ein Vorhaben, '
8756                + 'das dagegen verstößt, wird nicht ausgeführt — auch nicht, '
8757                + 'wenn Sie es im Gespräch erlauben. Ändern können Sie sie nur hier.')
8758            + '</div>'
8759            + (aus
8760                ? '<div class="alert alert-danger py-2 small">'
8761                  + '<i class="fas fa-hand me-1"></i><strong>'
8762                  + t('Der Handel ist angehalten.') + '</strong> '
8763                  + t('Alle Order-Werkzeuge lehnen ab.') + ' '
8764                  + '<button class="btn btn-sm btn-outline-light ms-2" '
8765                  + 'data-action="depot-notaus" data-id="0">'
8766                  + t('Wieder freigeben') + '</button></div>'
8767                : '<div class="mb-2"><button class="btn btn-sm btn-outline-danger" '
8768                  + 'data-action="depot-notaus" data-id="1">'
8769                  + '<i class="fas fa-hand me-1"></i>'
8770                  + t('Handel sofort anhalten') + '</button></div>')
8771            + '<div class="row">'
8772            + zeile('max_order_eur', '€')
8773            + zeile('max_position_prozent', '%')
8774            + zeile('mindest_liquiditaet_eur', '€')
8775            + zeile('band_prozent', '%')
8776            + zeile('max_orders_pro_tag', t('Stück'))
8777            + zeile('max_volumen_pro_tag_eur', '€')
8778            + '</div>'
8779            + '<div class="mb-2"><label class="form-label small mb-1">'
8780            + t('Sperrliste — Papiere, die nie vorgeschlagen werden (Komma getrennt)')
8781            + '</label><input type="text" class="form-control form-control-sm" '
8782            + 'id="rl-sperrliste" value="' + esc((r.sperrliste || []).join(', ')) + '"></div>'
8783            + '<button class="btn btn-sm btn-primary" data-action="depot-rl-speichern">'
8784            + t('Anlagerichtlinie speichern') + '</button>';
8785    }
8786
8787    static _depotBearbeiten(pf) {
8788        this._depotForm = pf;
8789        this._depotProbe = null;
8790        this._render();
8791    }
8792
8793    static async _depotSpeichern() {
8794        const pf = this._depotForm;
8795        const spec = (this._depotKatalog || {})[pf] || {};
8796        const felder = {};
8797        (spec.felder || []).forEach(f => {
8798            const el = document.getElementById('depotf-' + f.name);
8799            if (el && el.value.trim()) felder[f.name] = el.value.trim();
8800        });
8801        const gew = document.querySelector('input[name="depotmodus"]:checked');
8802        try {
8803            const d = await api('/api/ai-team/depot/' + encodeURIComponent(pf), {
8804                method: 'POST',
8805                body: JSON.stringify({
8806                    felder, modus: gew ? gew.value : (spec.modus_default || 'papier')
8807                })
8808            });
8809            this._depotKonten = d.konten || this._depotKonten;
8810            this._depotProbe = d.probe || null;
8811            // Nur bei erfolgreicher Probe schließen. Scheitert sie,
8812            // bleibt das Formular mit der Begründung stehen — sonst
8813            // müsste der Nutzer alles neu eintippen.
8814            if (d.probe && d.probe.ok) {
8815                this._depotForm = null;
8816                this._depotProbe = null;
8817                toast(t('Depot verbunden und geprüft.'), 'success');
8818                await this._depotLaden(false);
8819            }
8820        } catch (e) {
8821            this._depotProbe = { ok: false, fehler: e.message };
8822        }
8823        this._render();
8824    }
8825
8826    static async _depotTesten(pf) {
8827        try {
8828            const d = await api('/api/ai-team/depot/' + encodeURIComponent(pf) + '/test',
8829                { method: 'POST' });
8830            this._depotKonten = d.konten || this._depotKonten;
8831            const p = d.probe || {};
8832            toast(p.ok ? (p.meldung || t('Verbindung steht.'))
8833                       : (p.fehler || t('Prüfung fehlgeschlagen.')),
8834                  p.ok ? 'success' : 'error');
8835        } catch (e) {
8836            toast(e.message, 'error');
8837        }
8838        await this._depotLaden();
8839    }
8840
8841    static async _depotTrennen(pf) {
8842        if (!confirm(t('Zugang wirklich trennen? Sie können ihn jederzeit '
8843                       + 'wieder eintragen. Ihr Depot selbst bleibt unberührt.')))
8844            return;
8845        try {
8846            await api('/api/ai-team/depot/' + encodeURIComponent(pf),
8847                      { method: 'DELETE' });
8848            toast(t('Zugang getrennt.'), 'success');
8849        } catch (e) {
8850            toast(e.message, 'error');
8851        }
8852        await this._depotLaden();
8853    }
8854
8855    static async _depotNotaus(an) {
8856        const anhalten = String(an) === '1';
8857        if (!anhalten && !confirm(t('Handel wieder freigeben? Danach kann der '
8858                                    + 'Analyst Ihnen wieder Orders zur Freigabe '
8859                                    + 'vorlegen.'))) return;
8860        try {
8861            await api('/api/ai-team/depot/notschalter', {
8862                method: 'POST', body: JSON.stringify({ anhalten })
8863            });
8864            toast(anhalten ? t('Der Handel ist angehalten.')
8865                           : t('Der Handel ist wieder freigegeben.'), 'success');
8866        } catch (e) {
8867            toast(e.message, 'error');
8868        }
8869        await this._depotLaden();
8870    }
8871
8872    static async _depotRichtlinieSpeichern() {
8873        const zahl = (k) => {
8874            const el = document.getElementById('rl-' + k);
8875            if (!el || el.value === '') return undefined;
8876            const n = Number(el.value);
8877            return Number.isFinite(n) ? n : undefined;
8878        };
8879        const aenderungen = {};
8880        ['max_order_eur', 'max_position_prozent', 'mindest_liquiditaet_eur',
8881         'band_prozent', 'max_orders_pro_tag', 'max_volumen_pro_tag_eur']
8882            .forEach(k => { const v = zahl(k); if (v !== undefined) aenderungen[k] = v; });
8883        const sl = document.getElementById('rl-sperrliste');
8884        if (sl) {
8885            aenderungen.sperrliste = sl.value.split(',')
8886                .map(x => x.trim().toUpperCase()).filter(Boolean);
8887        }
8888        try {
8889            const d = await api('/api/ai-team/depot/richtlinie', {
8890                method: 'PUT', body: JSON.stringify({ aenderungen })
8891            });
8892            this._depotRichtlinie = d.richtlinie || this._depotRichtlinie;
8893            toast(t('Anlagerichtlinie gespeichert.'), 'success');
8894        } catch (e) {
8895            toast(e.message, 'error');
8896        }
8897        this._render();
8898    }
8899
8900    static async _adsLaden(zeichnen = true) {
8901        try {
8902            const d = await api('/api/ai-team/ads');
8903            this._adsKonten = d.konten || [];
8904            this._adsKatalog = d.katalog || {};
8905        } catch (e) {
8906            this._adsKatalog = {};
8907            this._adsFehler = e.message;
8908        }
8909        if (zeichnen && this._tab === 'zugaenge') this._render();
8910    }
8911
8912    /* ================================================================
8913       ai-app-domain-v1 — Eigene Domain fuer eine gebaute Website
8914
8915       Michaels Auftrag: eine mit dem KI-Team gebaute Website soll unter
8916       der EIGENEN Domain des Kunden erreichbar sein, nicht nur unter
8917       app.voice-one.ai/a/<slug>.
8918
8919       BEWUSST ZWEI SCHRITTE. Zwischen Eintragen und Aktivieren muss der
8920       Kunde bei seinem Domain-Anbieter einen DNS-Eintrag setzen. Ein
8921       einziges Formular, das beim Absenden am fehlenden A-Record
8922       scheitert, saehe wie ein Produktfehler aus - dabei fehlt nur ein
8923       Schritt, den nur der Kunde selbst gehen kann.
8924
8925       Der Zustand steht IMMER mit Wort und Zeichen an der Karte, nie
8926       allein ueber die Farbe.
8927       ================================================================ */
8928    static async _domLaden(zeichnen = true) {
8929        this._domStand = 'laedt';
8930        try {
8931            const d = await api('/api/ai-team/app-domains');
8932            this._domListe = d.domains || [];
8933            this._domApps = d.apps || [];
8934            this._domMax = d.max || 5;
8935            this._domIps = d.ips || [];
8936            this._domFehler = '';
8937        } catch (e) {
8938            this._domFehler = e.message;
8939        }
8940        this._domStand = 'da';
8941        if (zeichnen && this._tab === 'zugaenge') this._render();
8942    }
8943
8944    static _domStatusPille(d) {
8945        const map = {
8946            live: ['#1a7f37', 'fa-circle-check'],
8947            wartet_dns: ['#9a6700', 'fa-clock'],
8948            fehler: ['#b42318', 'fa-triangle-exclamation'],
8949            aus: ['#6c757d', 'fa-circle-pause'],
8950        };
8951        const [farbe, ico] = map[d.status] || ['#6c757d', 'fa-circle'];
8952        return '<span class="d-inline-flex align-items-center gap-1" '
8953            + 'style="font-size:.8rem;color:' + farbe + '">'
8954            + '<i class="fas ' + ico + '"></i>' + esc(t(d.status_text || d.status))
8955            + '</span>';
8956    }
8957
8958    static _domKarte(d) {
8959        if (this._domWeg === d.id) {
8960            return '<div class="col-12"><div class="border rounded-3 p-3" '
8961                + 'style="background:#fff8f0;border-color:#ffd9a8!important">'
8962                + '<div class="fw-semibold mb-1">' + t('Domain wirklich entfernen?')
8963                + '</div><div class="text-muted small mb-2">'
8964                + t('Die Website ist danach unter dieser Domain nicht mehr erreichbar. '
8965                    + 'Die Dateien und die interne Adresse bleiben erhalten.')
8966                + '</div><div class="d-flex gap-2">'
8967                + '<button class="btn btn-sm btn-danger btn-mit-text" data-action="dom-entfernen-ja" '
8968                + 'data-id="' + esc(d.id) + '">' + t('Ja, entfernen') + '</button>'
8969                + '<button class="btn btn-sm btn-outline-secondary" '
8970                + 'data-action="dom-entfernen-ab">' + t('Abbrechen') + '</button>'
8971                + '</div></div></div>';
8972        }
8973        const live = d.status === 'live';
8974        const knopf = live
8975            ? '<a class="btn btn-sm btn-outline-primary" target="_blank" rel="noopener" '
8976              + 'href="' + esc(d.url) + '"><i class="fas fa-arrow-up-right-from-square me-1"></i>'
8977              + t('Öffnen') + '</a>'
8978            : '<button class="btn btn-sm btn-primary" data-action="dom-aktivieren" '
8979              + 'data-id="' + esc(d.id) + '">'
8980              + (this._domLaeuft === d.id
8981                  ? '<span class="spinner-border spinner-border-sm me-1"></span>' + t('Prüfe …')
8982                  : '<i class="fas fa-circle-play me-1"></i>' + t('Prüfen und aktivieren'))
8983              + '</button>';
8984        const anleitung = (!live && this._domIps && this._domIps.length)
8985            ? '<div class="mt-2 p-2 rounded" style="background:#f8f9fa">'
8986              + '<div class="small fw-semibold mb-1">'
8987              + t('Diese Einträge bei Ihrem Domain-Anbieter setzen:') + '</div>'
8988              + '<div class="small font-monospace">'
8989              + this._domIps.map(ip => esc((ip.indexOf(':') >= 0 ? 'AAAA' : 'A')
8990                    + '   @   ' + ip)).join('<br>')
8991              + '</div></div>'
8992            : '';
8993        return '<div class="col-12"><div class="border rounded-3 p-3">'
8994            + '<div class="d-flex align-items-center flex-wrap gap-2">'
8995            + '<div class="me-auto"><div class="fw-semibold">' + esc(d.domain) + '</div>'
8996            + '<div class="text-muted" style="font-size:.82rem">'
8997            + t('Ordner') + ': ' + esc(d.ordner || '—') + '</div></div>'
8998            + this._domStatusPille(d) + knopf
8999            + '<button class="btn btn-sm btn-outline-secondary" data-action="dom-entfernen" '
9000            + 'data-id="' + esc(d.id) + '" title="' + t('Entfernen') + '">'
9001            + '<i class="fas fa-trash"></i></button></div>'
9002            + (d.meldung ? '<div class="small mt-2 ' + (d.status === 'fehler'
9003                ? 'text-danger' : 'text-muted') + '">' + esc(d.meldung) + '</div>' : '')
9004            + anleitung
9005            + this._domAutoZeile(d)
9006            + '</div></div>';
9007    }
9008
9009    // domain-auto-v1: Selbstschaltung.
9010    //
9011    // Sie wird NUR gezeigt, solange die Domain noch nicht laeuft - bei
9012    // einer erreichbaren Seite waere ein Schalter "automatisch
9013    // aktivieren" sinnlos und nur verwirrend.
9014    //
9015    // Der Zustand wird IMMER mit Wort und Zeichen gesagt, nie nur mit
9016    // Farbe. Und wenn die Frist abgelaufen ist, steht das ausdruecklich
9017    // da - sonst wartet der Inhaber auf etwas, das nicht mehr kommt.
9018    static _domAutoZeile(d) {
9019        if (d.status === 'live') return '';
9020        const an = !!d.auto;
9021        const abgelaufen = an && d.status === 'wartet_dns' && !d.auto_laeuft;
9022        const bis = d.auto_bis
9023            ? new Date(d.auto_bis).toLocaleDateString('de-DE') : '';
9024        let hinweis;
9025        if (!an) {
9026            hinweis = t('Sie aktivieren selbst, sobald der DNS-Eintrag steht.');
9027        } else if (abgelaufen) {
9028            hinweis = t('Die Wartezeit ist abgelaufen. Bitte prüfen Sie den '
9029                + 'DNS-Eintrag und aktivieren Sie von Hand.');
9030        } else {
9031            hinweis = t('Wir sehen regelmäßig nach und schalten die Domain '
9032                + 'frei, sobald der DNS-Eintrag da ist. Sie bekommen dann '
9033                + 'eine E-Mail.')
9034                + (bis ? ' ' + t('Wir sehen bis zum') + ' ' + esc(bis)
9035                    + ' ' + t('nach.') : '');
9036        }
9037        // Der Zielzustand steht in data-arg, er wird NICHT aus dem
9038        // Schalter gelesen: der Klick-Handler ruft ev.preventDefault(),
9039        // damit springt eine Checkbox optisch zurueck. Dasselbe Muster
9040        // nutzt bereits 'routine-an'.
9041        const ziel = an ? '0' : '1';
9042        return '<div class="mt-2 pt-2 border-top d-flex align-items-start gap-2" '
9043            + 'data-action="dom-auto" data-id="' + esc(d.id) + '" '
9044            + 'data-arg="' + ziel + '" style="cursor:pointer">'
9045            + '<div class="form-check form-switch mb-0 pe-none">'
9046            + '<input class="form-check-input" type="checkbox" role="switch" '
9047            + 'tabindex="-1"' + (an ? ' checked' : '') + '>'
9048            + '</div><div class="small flex-grow-1">'
9049            + '<span class="fw-semibold">'
9050            + t('Automatisch aktivieren, sobald der DNS-Eintrag zeigt')
9051            + '</span><div class="text-muted" style="font-size:.8rem">'
9052            + (abgelaufen ? '<i class="fas fa-clock me-1"></i>' : '')
9053            + hinweis + '</div></div></div>';
9054    }
9055
9056    static _domFormHtml() {
9057        const f = this._domForm;
9058        const vorschlaege = (this._domApps || []).map(a =>
9059            '<option value="' + esc(a.ordner) + '">' + esc(a.name || a.ordner)
9060            + '</option>').join('');
9061        return '<div class="border rounded-3 p-3 mb-2" style="background:#f8f9fa">'
9062            + (f.fehler ? '<div class="alert alert-danger py-2 small">'
9063                + esc(f.fehler) + '</div>' : '')
9064            + '<div class="row g-3">'
9065            + '<div class="col-md-6"><label class="form-label small mb-1">'
9066            + t('Ihre Domain') + ' <span class="text-danger">*</span></label>'
9067            + '<input class="form-control form-control-sm" id="domf_domain" '
9068            + 'placeholder="meine-firma.de" autocomplete="off" '
9069            + 'value="' + esc(f.domain || '') + '">'
9070            + '<div class="form-text small">'
9071            + t('Ohne https:// und ohne www — das wird automatisch mit eingerichtet.')
9072            + '</div></div>'
9073            + '<div class="col-md-6"><label class="form-label small mb-1">'
9074            + t('Ordner in der Ablage') + ' <span class="text-danger">*</span></label>'
9075            + '<input class="form-control form-control-sm" id="domf_ordner" '
9076            + 'list="domf_ordnerliste" placeholder="Apps/meine-website" '
9077            + 'autocomplete="off" value="' + esc(f.ordner || '') + '">'
9078            + '<datalist id="domf_ordnerliste">' + vorschlaege + '</datalist>'
9079            + '<div class="form-text small">'
9080            + t('Der Ordner, in dem die Website liegt. Dort muss eine index.html sein.')
9081            + '</div></div></div>'
9082            + '<div class="d-flex gap-2 mt-3">'
9083            + '<button class="btn btn-primary btn-sm" data-action="dom-speichern">'
9084            + t('Eintragen') + '</button>'
9085            + '<button class="btn btn-outline-secondary btn-sm" data-action="dom-abbrechen">'
9086            + t('Abbrechen') + '</button></div></div>';
9087    }
9088
9089    static _domBlock() {
9090        const liste = this._domListe || [];
9091        const voll = liste.length >= (this._domMax || 5);
9092        const kopf = '<div class="d-flex align-items-center mb-2">'
9093            + '<h6 class="mb-0 me-auto"><i class="fas fa-globe me-2"></i>'
9094            + t('Eigene Domain für Ihre Website')
9095            + '<span class="badge bg-light text-dark ms-2">' + liste.length + '</span></h6>'
9096            + (this._domForm || voll ? ''
9097                : '<button class="btn btn-sm btn-primary" data-action="dom-neu">'
9098                  + '<i class="fas fa-plus me-1"></i>' + t('Domain hinzufügen') + '</button>')
9099            + '</div>'
9100            + '<div class="text-muted small mb-2">'
9101            + t('Eine Website, die Ihr KI-Mitarbeiter gebaut hat, unter Ihrer eigenen '
9102                + 'Adresse veröffentlichen. Sie tragen Domain und Ordner ein, setzen bei '
9103                + 'Ihrem Domain-Anbieter den angezeigten DNS-Eintrag — den Rest '
9104                + 'einschließlich Verschlüsselung richten wir automatisch ein.')
9105            + '</div>';
9106        let inhalt;
9107        if (this._domStand !== 'da') {
9108            inhalt = '<div class="text-muted small">' + t('Lade …') + '</div>';
9109        } else if (this._domFehler) {
9110            inhalt = '<div class="alert alert-warning mb-0 py-2 small">'
9111                + esc(this._domFehler) + '</div>';
9112        } else if (!liste.length && !this._domForm) {
9113            inhalt = '<div class="text-muted small fst-italic">'
9114                + t('Noch keine eigene Domain eingerichtet.') + '</div>';
9115        } else {
9116            inhalt = '<div class="row g-2">'
9117                + liste.map(d => this._domKarte(d)).join('') + '</div>';
9118        }
9119        return '<div class="card mb-3"><div class="card-body">'
9120            + kopf + (this._domForm ? this._domFormHtml() : '') + inhalt
9121            + (voll ? '<div class="text-muted small mt-2">'
9122                + t('Mehr Domains sind derzeit nicht vorgesehen.') + '</div>' : '')
9123            + '</div></div>';
9124    }
9125
9126    static async _domSpeichern() {
9127        const f = this._domForm;
9128        if (!f) return;
9129        const el = (i) => (document.getElementById(i) || {}).value || '';
9130        f.domain = el('domf_domain').trim();
9131        f.ordner = el('domf_ordner').trim();
9132        f.fehler = '';
9133        if (!f.domain || !f.ordner) {
9134            f.fehler = t('Bitte Domain und Ordner angeben.');
9135            return this._render();
9136        }
9137        try {
9138            await api('/api/ai-team/app-domains', 'POST',
9139                      { domain: f.domain, ordner: f.ordner });
9140            this._domForm = null;
9141            await this._domLaden(false);
9142        } catch (e) {
9143            f.fehler = e.message;
9144        }
9145        this._render();
9146    }
9147
9148    static async _domAktivieren(id) {
9149        // Der Ablauf dauert bis zu einer Minute (DNS, Zertifikat, nginx).
9150        // Ohne sichtbare Rueckmeldung drueckt der Nutzer mehrfach - und
9151        // jeder Versuch kostet Kontingent bei der Zertifikatsstelle.
9152        if (this._domLaeuft) return;
9153        this._domLaeuft = id;
9154        this._render();
9155        try {
9156            const d = await api('/api/ai-team/app-domains/'
9157                                + encodeURIComponent(id) + '/aktivieren', 'POST', {});
9158            this._domListe = (this._domListe || []).map(x =>
9159                (x.id === id && d.domain) ? d.domain : x);
9160        } catch (e) {
9161            this._domFehler = e.message;
9162        }
9163        this._domLaeuft = null;
9164        await this._domLaden(false);
9165        this._render();
9166    }
9167
9168    static async _domAuto(id, an) {
9169        // Scheitert der Aufruf, wird der ECHTE Stand nachgeladen - sonst
9170        // zeigte die Oberflaeche etwas an, was serverseitig nicht gilt.
9171        try {
9172            const d = await api('/api/ai-team/app-domains/'
9173                + encodeURIComponent(id) + '/auto', 'POST', { an: !!an });
9174            this._domListe = (this._domListe || []).map(x =>
9175                (x.id === id && d.domain) ? d.domain : x);
9176            this._render();
9177        } catch (e) {
9178            this._domFehler = e.message;
9179            await this._domLaden(false);
9180            this._render();
9181        }
9182    }
9183
9184    static async _domEntfernen(id) {
9185        try {
9186            await api('/api/ai-team/app-domains/' + encodeURIComponent(id), 'DELETE');
9187            this._domWeg = null;
9188            await this._domLaden(false);
9189        } catch (e) {
9190            this._domFehler = e.message;
9191            this._domWeg = null;
9192        }
9193        this._render();
9194    }
9195
9196
9197    /* ================================================================
9198       video-ki-v1 — Videoerzeugung (BytePlus ModelArk / Seedance)
9199
9200       Steht neben den Werbekonten, aus derselben Überlegung: ein
9201       Videokonto gehört der Firma, nicht einem Mitarbeiter. Jeder
9202       Mandant trägt SEINEN BytePlus-Schlüssel ein (Michaels Vorgabe
9203       24.09.2026); die Clips laufen über SEIN Guthaben. Der Schlüssel
9204       wird nie wieder angezeigt, die Karte zeigt nur Masken.
9205       ================================================================ */
9206    static async _videoLaden(zeichnen = true) {
9207        try {
9208            const d = await api('/api/ai-team/video');
9209            this._videoKonten = d.konten || [];
9210            this._videoKatalog = d.katalog || {};
9211            this._videoClips = d.clips || [];
9212            this._videoMonat = d.monat_usd || 0;
9213            this._videoFehler = '';
9214        } catch (e) {
9215            this._videoKatalog = {};
9216            this._videoFehler = e.message;
9217        }
9218        if (zeichnen && this._tab === 'zugaenge') this._render();
9219    }
9220
9221    static _videoBlock() {
9222        if (this._videoForm) return this._videoFormHtml();
9223        const kat = this._videoKatalog;
9224        const kopf = '<div class="d-flex align-items-center mb-2">'
9225            + '<h6 class="mb-0 me-auto"><i class="fas fa-film me-2"></i>'
9226            + t('Videoerzeugung') + '</h6></div>'
9227            + '<div class="text-muted small mb-2">'
9228            + t('Seedance über Ihr eigenes BytePlus-Konto. Einmal verbunden, kann der '
9229                + 'Marketing-Mitarbeiter Videoclips aus Text und Bildern erzeugen; '
9230                + 'fertige Clips landen in seiner Dateiablage. Die Kosten je Clip '
9231                + 'gehen über Ihr BytePlus-Guthaben, ein Monatsdeckel bremst.')
9232            + '</div>';
9233        let inhalt;
9234        if (!kat) {
9235            inhalt = '<div class="text-muted small">' + t('Lade …') + '</div>';
9236        } else if (this._videoFehler) {
9237            inhalt = '<div class="alert alert-warning mb-0 py-2 small">'
9238                + esc(this._videoFehler) + '</div>';
9239        } else {
9240            inhalt = '<div class="row g-2">'
9241                + (this._videoKonten || []).map(k => this._videoKarte(k)).join('')
9242                + '</div>' + this._videoClipsHtml();
9243        }
9244        return '<div class="card mb-3"><div class="card-body">'
9245            + kopf + inhalt + '</div></div>';
9246    }
9247
9248    static _videoKarte(k) {
9249        const spec = (this._videoKatalog || {})[k.plattform] || {};
9250        const stand = k.verbunden
9251            ? '<span class="text-success"><i class="fas fa-circle-check me-1"></i>'
9252              + t('Verbunden') + '</span>'
9253              + (k.letzter_fehler
9254                  ? ' <span class="text-danger small">— ' + esc(k.letzter_fehler) + '</span>'
9255                  : '')
9256            : '<span class="text-muted"><i class="fas fa-circle-minus me-1"></i>'
9257              + t('Nicht verbunden') + '</span>';
9258        const details = k.verbunden
9259            ? '<div class="text-muted small">' + esc(t('Standardmodell') + ': '
9260                + (k.modell_label || k.modell || '')) + '<br>'
9261              + esc(t('Monatsdeckel') + ': ' + (Number(k.budget_usd_monat) > 0
9262                    ? k.budget_usd_monat + ' USD' : t('kein Deckel'))
9263                    + ' · ' + t('Verbraucht diesen Monat') + ': '
9264                    + (this._videoMonat || 0).toFixed(2) + ' USD')
9265              + '</div>'
9266            : '';
9267        const knoepfe = k.verbunden
9268            ? '<button class="btn btn-sm btn-outline-secondary" data-action="video-test" data-id="'
9269                + esc(k.plattform) + '"><i class="fas fa-plug me-1"></i>'
9270                + t('Verbindung prüfen') + '</button>'
9271              + '<button class="btn btn-sm btn-outline-primary" data-action="video-bearbeiten" data-id="'
9272                + esc(k.plattform) + '">' + t('Ändern') + '</button>'
9273              + '<button class="btn btn-sm btn-outline-danger ms-auto" data-action="video-trennen" data-id="'
9274                + esc(k.plattform) + '">' + t('Trennen') + '</button>'
9275            : '<button class="btn btn-sm btn-primary" data-action="video-bearbeiten" data-id="'
9276                + esc(k.plattform) + '"><i class="fas fa-plus me-1"></i>'
9277                + t('Verbinden') + '</button>';
9278        return '<div class="col-12"><div class="card h-100 border">'
9279            + '<div class="card-body py-3">'
9280            + '<div class="d-flex align-items-center mb-1">'
9281            + '<i class="fas fa-clapperboard me-2"></i>'
9282            + '<strong class="me-auto">' + esc(k.label || k.plattform) + '</strong>'
9283            + '<span class="badge bg-light text-dark border">' + esc(k.region || '') + '</span></div>'
9284            + '<div class="small mb-1">' + stand + '</div>' + details
9285            + '<div class="text-muted small mt-1">' + esc(spec.hilfe || '') + '</div>'
9286            + '<div class="d-flex gap-2 mt-2">' + knoepfe + '</div>'
9287            + '</div></div></div>';
9288    }
9289
9290    static _videoClipsHtml() {
9291        const clips = this._videoClips || [];
9292        if (!clips.length) return '';
9293        const farbe = { succeeded: 'text-success', failed: 'text-danger',
9294                        verloren: 'text-danger', expired: 'text-danger' };
9295        const zeilen = clips.slice(0, 8).map(c =>
9296            '<tr><td class="small">' + esc(c.angelegt || '') + '</td>'
9297            + '<td class="small">' + esc(c.titel || '') + '</td>'
9298            + '<td class="small ' + (farbe[c.status] || 'text-muted') + '">'
9299            + esc(c.status_text || c.status) + '</td>'
9300            + '<td class="small text-end">' + esc(String(c.kosten_usd != null ? c.kosten_usd : ''))
9301            + (c.kosten_sind_schaetzung ? ' ~' : '') + '</td>'
9302            + '<td class="small text-muted">' + esc(c.ablage_pfad || c.fehler || '') + '</td></tr>'
9303        ).join('');
9304        return '<div class="mt-3"><div class="small fw-semibold mb-1">' + t('Letzte Clips') + '</div>'
9305            + '<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr>'
9306            + '<th class="small">' + t('Angelegt') + '</th><th class="small">' + t('Titel') + '</th>'
9307            + '<th class="small">' + t('Status') + '</th><th class="small text-end">USD</th>'
9308            + '<th class="small">' + t('Ablage / Fehler') + '</th></tr></thead>'
9309            + '<tbody>' + zeilen + '</tbody></table></div></div>';
9310    }
9311
9312    static _videoBearbeiten(pf) {
9313        const spec = (this._videoKatalog || {})[pf];
9314        if (!spec) return;
9315        const k = (this._videoKonten || []).find(x => x.plattform === pf) || {};
9316        const werte = {};
9317        (spec.felder || []).forEach(f => {
9318            // Das Geheimfeld wird NIE vorbelegt. Leer lassen = alten Wert behalten.
9319            werte[f.name] = f.geheim ? '' : (k[f.name] || f.vorgabe || '');
9320        });
9321        this._videoForm = { plattform: pf, werte: werte, fehler: '', hatte: !!k.verbunden };
9322        this._render();
9323    }
9324
9325    static _videoFormHtml() {
9326        const f = this._videoForm;
9327        const spec = (this._videoKatalog || {})[f.plattform] || {};
9328        const felder = (spec.felder || []).map(fd => {
9329            const wert = f.werte[fd.name] || '';
9330            let eingabe;
9331            if (fd.auswahl && fd.auswahl.length) {
9332                eingabe = '<select class="form-select form-select-sm" id="vidf_' + esc(fd.name) + '">'
9333                    + fd.auswahl.map(o => '<option value="' + esc(o.wert) + '"'
9334                        + (o.wert === wert ? ' selected' : '') + '>' + esc(o.label) + '</option>').join('')
9335                    + '</select>';
9336            } else {
9337                const platz = fd.geheim && f.hatte ? t('unverändert lassen') : '';
9338                eingabe = '<input class="form-control form-control-sm" id="vidf_' + esc(fd.name) + '"'
9339                    + ' type="' + (fd.geheim ? 'password' : 'text') + '"'
9340                    + ' value="' + esc(wert) + '" placeholder="' + esc(platz) + '" autocomplete="off">';
9341            }
9342            return '<div class="col-md-6"><label class="form-label small mb-1">'
9343                + esc(fd.label) + (fd.pflicht ? ' <span class="text-danger">*</span>' : '')
9344                + '</label>' + eingabe
9345                + (fd.hilfe ? '<div class="form-text small">' + esc(fd.hilfe) + '</div>' : '')
9346                + '</div>';
9347        }).join('');
9348        return '<div class="card mb-3"><div class="card-body">'
9349            + '<h6 class="mb-1"><i class="fas fa-film me-2"></i>'
9350            + esc(spec.label || f.plattform) + ' ' + t('verbinden') + '</h6>'
9351            + '<div class="text-muted small mb-3">'
9352            + esc(spec.hilfe || '') + ' '
9353            + t('Der Schlüssel wird verschlüsselt gespeichert und nie wieder im Klartext '
9354                + 'angezeigt — auch der KI-Mitarbeiter sieht ihn nicht. Nach dem Speichern '
9355                + 'wird die Verbindung sofort geprüft.')
9356            + '</div>'
9357            + (f.fehler ? '<div class="alert alert-danger py-2 small">' + esc(f.fehler) + '</div>' : '')
9358            + '<div class="row g-3">' + felder + '</div>'
9359            + '<div class="d-flex gap-2 mt-3">'
9360            + '<button class="btn btn-primary btn-sm" data-action="video-speichern">'
9361            + t('Speichern und prüfen') + '</button>'
9362            + '<button class="btn btn-outline-secondary btn-sm" data-action="video-abbrechen">'
9363            + t('Abbrechen') + '</button></div>'
9364            + '</div></div>';
9365    }
9366
9367    static async _videoSpeichern() {
9368        const f = this._videoForm;
9369        if (!f) return;
9370        const spec = (this._videoKatalog || {})[f.plattform] || {};
9371        const felder = {};
9372        (spec.felder || []).forEach(fd => {
9373            const el = document.getElementById('vidf_' + fd.name);
9374            const wert = el ? (el.value || '').trim() : '';
9375            if (fd.geheim && !wert && f.hatte) return;
9376            felder[fd.name] = wert;
9377        });
9378        f.fehler = '';
9379        try {
9380            const d = await api('/api/ai-team/video/' + encodeURIComponent(f.plattform),
9381                                'POST', { felder: felder });
9382            this._videoKonten = d.konten || this._videoKonten;
9383            this._videoForm = null;
9384            this._render();
9385            const p = d.probe || {};
9386            this._adsTest = p.ok
9387                ? { schritte: (p.schritte || []).concat([{ name: t('Ergebnis'), ok: true,
9388                        text: t('Das Videokonto ist verbunden.') }]) }
9389                : { schritte: (p.schritte || []).concat([{ name: t('Ergebnis'), ok: false,
9390                        text: p.fehler || t('Die Verbindung konnte nicht geprüft werden.') }]) };
9391            this._modal(this._adsTestHtml());
9392        } catch (e) {
9393            f.fehler = e.message;
9394            this._render();
9395        }
9396    }
9397
9398    static async _videoTesten(pf) {
9399        this._adsTest = { schritte: [{ name: t('Prüfung läuft'), ok: true,
9400                                       text: t('Einen Moment bitte …') }] };
9401        this._modal(this._adsTestHtml());
9402        try {
9403            const p = await api('/api/ai-team/video/' + encodeURIComponent(pf) + '/test', 'POST');
9404            this._adsTest = { schritte: (p.schritte || []).concat([
9405                { name: t('Ergebnis'), ok: !!p.ok,
9406                  text: p.ok ? t('Das Videokonto ist verbunden.')
9407                             : (p.fehler || t('Nicht verbunden.')) }]) };
9408        } catch (e) {
9409            this._adsTest = { schritte: [{ name: t('Prüfung'), ok: false, text: e.message }] };
9410        }
9411        this._modal(this._adsTestHtml());
9412    }
9413
9414    static _videoTrennenFragen(pf) {
9415        this._modal('<div class="modal-header"><h5 class="modal-title">'
9416            + t('Videokonto trennen') + '</h5>'
9417            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
9418            + '<div class="modal-body"><p>'
9419            + esc(t('Soll die Verbindung zu BytePlus wirklich getrennt werden?')) + '</p>'
9420            + '<div class="alert alert-info py-2 small mb-0">'
9421            + esc(t('Bereits erzeugte Clips bleiben in der Ablage. Der Mitarbeiter kann '
9422                    + 'danach keine neuen Clips mehr anlegen, bis Sie wieder verbinden.'))
9423            + '</div></div>'
9424            + '<div class="modal-footer">'
9425            + '<button class="btn btn-outline-secondary" data-action="modal-zu">'
9426            + t('Abbrechen') + '</button>'
9427            + '<button class="btn btn-danger" data-action="video-trennen-ja" data-id="'
9428            + esc(pf) + '">' + t('Trennen') + '</button></div>');
9429    }
9430
9431    static async _videoTrennen(pf) {
9432        this._modal('');
9433        try {
9434            const d = await api('/api/ai-team/video/' + encodeURIComponent(pf), 'DELETE');
9435            this._videoKonten = d.konten || this._videoKonten;
9436            this._render();
9437        } catch (e) { this._modalFehler(e); }
9438    }
9439
9440    static _adsBlock() {
9441        if (this._adsWahl) return this._adsWahlHtml();
9442        if (this._adsForm) return this._adsFormHtml();
9443        const kat = this._adsKatalog;
9444        const kopf = '<div class="d-flex align-items-center mb-2">'
9445            + '<h6 class="mb-0 me-auto"><i class="fas fa-bullhorn me-2"></i>'
9446            + t('Werbekonten') + '</h6></div>'
9447            + '<div class="text-muted small mb-2">'
9448            + t('Google Ads und Meta Ads. Einmal verbunden, kann der '
9449                + 'Marketing-Mitarbeiter die Zahlen lesen und — nach Ihrer '
9450                + 'Freigabe — Budgets anpassen oder Kampagnen pausieren.')
9451            + '</div>';
9452        let inhalt;
9453        if (!kat) {
9454            inhalt = '<div class="text-muted small">' + t('Lade …') + '</div>';
9455        } else if (this._adsFehler) {
9456            inhalt = '<div class="alert alert-warning mb-0 py-2 small">'
9457                + esc(this._adsFehler) + '</div>';
9458        } else {
9459            inhalt = '<div class="row g-2">'
9460                + (this._adsKonten || []).map(k => this._adsKarte(k)).join('')
9461                + '</div>';
9462        }
9463        return '<div class="card mb-3"><div class="card-body">'
9464            + kopf + inhalt + '</div></div>';
9465    }
9466
9467    static _adsKarte(k) {
9468        const spec = (this._adsKatalog || {})[k.plattform] || {};
9469        const icon = k.plattform === 'google' ? 'fa-google' : 'fa-meta';
9470        // Der Zustand wird IMMER mit Wort UND Zeichen gezeigt, nie nur
9471        // ueber Farbe - sonst ist er fuer einen Teil der Nutzer unsichtbar.
9472        let stand;
9473        if (!k.verbunden) {
9474            stand = '<span class="text-muted"><i class="fas fa-circle-minus me-1"></i>'
9475                + t('Nicht verbunden') + '</span>';
9476        } else if (k.herkunft === 'hauskonto') {
9477            stand = '<span class="text-success"><i class="fas fa-circle-check me-1"></i>'
9478                + t('Verbunden') + '</span> <span class="text-muted small">— '
9479                + t('über das voiceOne-Firmenkonto') + '</span>';
9480        } else {
9481            stand = '<span class="text-success"><i class="fas fa-circle-check me-1"></i>'
9482                + t('Verbunden') + '</span>';
9483        }
9484        const kennung = k.plattform === 'google'
9485            ? (k.customer_id || '') : (k.ad_account_id || '');
9486        const knoepfe = k.verbunden
9487            ? '<button class="btn btn-sm btn-outline-secondary" data-action="ads-test" data-id="'
9488                + esc(k.plattform) + '"><i class="fas fa-plug me-1"></i>'
9489                + t('Verbindung prüfen') + '</button>'
9490              // ads-oauth-v1c: Auch bei bestehender Verbindung anbieten.
9491              // Ohne das kann ein Mandant, der ueber das Firmenkonto
9492              // laeuft, NIE auf sein eigenes Werbekonto wechseln - der
9493              // Verbinden-Weg waere fuer ihn unerreichbar.
9494              + (spec.oauth
9495                  ? '<button class="btn btn-sm btn-outline-primary" data-action="ads-oauth"'
9496                    + ' data-id="' + esc(k.plattform) + '" data-wechsel="1">'
9497                    + '<i class="fab fa-google me-1"></i>'
9498                    + t(k.herkunft === 'hauskonto'
9499                        ? 'Eigenes Konto verbinden' : 'Anderes Konto verbinden')
9500                    + '</button>' : '')
9501              + '<button class="btn btn-sm btn-outline-primary" data-action="ads-bearbeiten" data-id="'
9502                + esc(k.plattform) + '">' + t('Ändern') + '</button>'
9503              + (k.herkunft === 'mandant'
9504                  ? '<button class="btn btn-sm btn-outline-danger ms-auto" data-action="ads-trennen" data-id="'
9505                    + esc(k.plattform) + '">' + t('Trennen') + '</button>' : '')
9506            : (spec.oauth
9507                // ads-oauth-v1: Der Knopf erscheint nur, wenn der Server
9508                // bestaetigt, dass die Rueckadresse eingerichtet ist.
9509                // Sonst liefe der Kunde in redirect_uri_mismatch - ein
9510                // Knopf, der zu einer Google-Fehlerseite fuehrt, ist
9511                // schlimmer als kein Knopf.
9512                ? '<button class="btn btn-sm btn-primary" data-action="ads-oauth">'
9513                  + '<i class="fab fa-google me-1"></i>'
9514                  + t('Mit Google verbinden') + '</button>'
9515                  + '<button class="btn btn-sm btn-link text-muted"'
9516                  + ' data-action="ads-bearbeiten" data-id="' + esc(k.plattform) + '">'
9517                  + t('Zugangsdaten von Hand eintragen') + '</button>'
9518                : '<button class="btn btn-sm btn-primary" data-action="ads-bearbeiten" data-id="'
9519                  + esc(k.plattform) + '"><i class="fas fa-plus me-1"></i>'
9520                  + t('Verbinden') + '</button>');
9521        return '<div class="col-12 col-xl-6"><div class="card h-100 border">'
9522            + '<div class="card-body py-3">'
9523            + '<div class="d-flex align-items-center mb-1">'
9524            + '<i class="fab ' + icon + ' me-2"></i>'
9525            + '<strong class="me-auto">' + esc(k.label || k.plattform) + '</strong></div>'
9526            + '<div class="small mb-1">' + stand + '</div>'
9527            + (kennung ? '<div class="text-muted small">' + esc(t('Konto') + ': ' + kennung)
9528                       + '</div>' : '')
9529            + '<div class="text-muted small mt-1">' + esc(spec.hilfe || '') + '</div>'
9530            + '<div class="d-flex gap-2 mt-2">' + knoepfe + '</div>'
9531            + '</div></div></div>';
9532    }
9533
9534    static _adsBearbeiten(pf) {
9535        const spec = (this._adsKatalog || {})[pf];
9536        if (!spec) return;
9537        const k = (this._adsKonten || []).find(x => x.plattform === pf) || {};
9538        const werte = {};
9539        (spec.felder || []).forEach(f => {
9540            // Ein geheimes Feld wird NIE vorbelegt - die Maske ist keine
9541            // Eingabe. Wer nichts eintraegt, behaelt den alten Wert.
9542            werte[f.name] = f.geheim ? '' : (k[f.name] || '');
9543        });
9544        this._adsForm = { plattform: pf, werte: werte, fehler: '',
9545                          hatte: k.herkunft === 'mandant' };
9546        this._render();
9547    }
9548
9549    static _adsFormHtml() {
9550        const f = this._adsForm;
9551        const spec = (this._adsKatalog || {})[f.plattform] || {};
9552        const felder = (spec.felder || []).map(fd => {
9553            const wert = f.werte[fd.name] || '';
9554            const platz = fd.geheim && f.hatte ? t('unverändert lassen') : '';
9555            return '<div class="col-md-6"><label class="form-label small mb-1">'
9556                + esc(fd.label) + (fd.pflicht ? ' <span class="text-danger">*</span>' : '')
9557                + '</label>'
9558                + '<input class="form-control form-control-sm" id="adsf_' + esc(fd.name) + '"'
9559                + ' type="' + (fd.geheim ? 'password' : 'text') + '"'
9560                + ' value="' + esc(wert) + '" placeholder="' + esc(platz) + '"'
9561                + ' autocomplete="off">'
9562                + (fd.hilfe ? '<div class="form-text small">' + esc(fd.hilfe) + '</div>' : '')
9563                + '</div>';
9564        }).join('');
9565        return '<div class="card mb-3"><div class="card-body">'
9566            + '<h6 class="mb-1"><i class="fas fa-bullhorn me-2"></i>'
9567            + esc(spec.label || f.plattform) + ' ' + t('verbinden') + '</h6>'
9568            + '<div class="text-muted small mb-3">'
9569            + t('Die Zugangsdaten werden verschlüsselt gespeichert und nie wieder '
9570                + 'im Klartext angezeigt — auch der KI-Mitarbeiter sieht sie nicht. '
9571                + 'Nach dem Speichern wird die Verbindung sofort geprüft.')
9572            + '</div>'
9573            + (f.fehler ? '<div class="alert alert-danger py-2 small">' + esc(f.fehler)
9574                        + '</div>' : '')
9575            + '<div class="row g-3">' + felder + '</div>'
9576            + '<div class="d-flex gap-2 mt-3">'
9577            + '<button class="btn btn-primary btn-sm" data-action="ads-speichern">'
9578            + t('Speichern und prüfen') + '</button>'
9579            + '<button class="btn btn-outline-secondary btn-sm" data-action="ads-abbrechen">'
9580            + t('Abbrechen') + '</button></div>'
9581            + '</div></div>';
9582    }
9583
9584    static async _adsSpeichern() {
9585        const f = this._adsForm;
9586        if (!f) return;
9587        const spec = (this._adsKatalog || {})[f.plattform] || {};
9588        const felder = {};
9589        (spec.felder || []).forEach(fd => {
9590            const el = document.getElementById('adsf_' + fd.name);
9591            const wert = el ? (el.value || '').trim() : '';
9592            // Leeres Geheimfeld bei bestehendem Zugang = unveraendert.
9593            if (fd.geheim && !wert && f.hatte) return;
9594            felder[fd.name] = wert;
9595        });
9596        f.fehler = '';
9597        try {
9598            const d = await api('/api/ai-team/ads/' + encodeURIComponent(f.plattform),
9599                                'POST', { felder: felder });
9600            this._adsKonten = d.konten || this._adsKonten;
9601            this._adsForm = null;
9602            this._render();
9603            const p = d.probe || {};
9604            this._adsTest = p.ok
9605                ? { schritte: (p.schritte || []).concat([{ name: t('Ergebnis'), ok: true,
9606                        text: t('Das Werbekonto ist verbunden.') }]) }
9607                : { schritte: (p.schritte || []).concat([{ name: t('Ergebnis'), ok: false,
9608                        text: p.fehler || t('Die Verbindung konnte nicht geprüft werden.') }]) };
9609            this._modal(this._adsTestHtml());
9610        } catch (e) {
9611            f.fehler = e.message;
9612            this._render();
9613        }
9614    }
9615
9616    static async _adsTesten(pf) {
9617        this._adsTest = { schritte: [{ name: t('Prüfung läuft'), ok: true,
9618                                       text: t('Einen Moment bitte …') }] };
9619        this._modal(this._adsTestHtml());
9620        try {
9621            const p = await api('/api/ai-team/ads/' + encodeURIComponent(pf) + '/test', 'POST');
9622            this._adsTest = { schritte: (p.schritte || []).concat([
9623                { name: t('Ergebnis'), ok: !!p.ok,
9624                  text: p.ok ? t('Das Werbekonto ist verbunden.')
9625                             : (p.fehler || t('Nicht verbunden.')) }]) };
9626        } catch (e) {
9627            this._adsTest = { schritte: [{ name: t('Prüfung'), ok: false, text: e.message }] };
9628        }
9629        this._modal(this._adsTestHtml());
9630    }
9631
9632    /* ================================================================
9633       ads-oauth-v1 — Verbinden per Google-Anmeldung
9634
9635       DER KUNDE WAEHLT AUS SEINEN EIGENEN KONTEN. Der Altbestand in
9636       app.py schrieb jedem verbindenden Nutzer eine feste Kundennummer
9637       (unser Verwaltungskonto) in den Datensatz - wer verband, sah
9638       unsere Zahlen. Hier kommt die Liste von Google, mit dem Schluessel
9639       des Kunden, und es wird nichts voreingetragen.
9640
9641       DER SCHLUESSEL LAEUFT NIE DURCH DEN BROWSER: app.py protokolliert
9642       jeden API-Body. Der Browser sieht nur Kontonummern und Namen.
9643       ================================================================ */
9644    static async _adsOauthStart() {
9645        try {
9646            const d = await api('/api/ai-team/ads/google/oauth/start', 'POST');
9647            if (!d.url) throw new Error(t('Keine Adresse erhalten.'));
9648            // Volle Weiterleitung statt Popup: ein Popup wird auf dem
9649            // Handy und von Blockern verschluckt, und Google warnt bei
9650            // eingebetteten Ansichten.
9651            window.location.href = d.url;
9652        } catch (e) {
9653            this._adsFehler = e.message;
9654            this._render();
9655        }
9656    }
9657
9658    /** Rueckkehr von Google. Holt die Kontoliste und zeigt die Auswahl. */
9659    static async _adsOauthRueckkehr(state) {
9660        this._tab = 'zugaenge';
9661        if (state === 'fehler') {
9662            this._adsWahl = null;
9663            this._adsFehler = t('Die Google-Anmeldung wurde abgebrochen '
9664                + 'oder abgelehnt. Es wurde nichts gespeichert.');
9665            this._render();
9666            return;
9667        }
9668        this._adsWahl = { state: state, konten: null, fehler: '', laedt: true };
9669        this._render();
9670        try {
9671            const d = await api('/api/ai-team/ads/google/oauth/'
9672                                + encodeURIComponent(state));
9673            this._adsWahl = { state: state, konten: d.konten || [],
9674                              fehler: d.fehler || '', laedt: false,
9675                              gewaehlt: (d.konten || []).length === 1
9676                                  ? String((d.konten[0] || {}).customer_id || '') : '' };
9677        } catch (e) {
9678            this._adsWahl = null;
9679            this._adsFehler = e.message;
9680        }
9681        this._render();
9682    }
9683
9684    static _adsWahlHtml() {
9685        const w = this._adsWahl || {};
9686        const kopf = '<div class="card mb-3"><div class="card-body">'
9687            + '<h6 class="mb-1"><i class="fab fa-google me-2"></i>'
9688            + t('Werbekonto auswählen') + '</h6>';
9689        if (w.laedt) {
9690            return kopf + '<div class="text-muted small">'
9691                + t('Konten werden abgerufen …') + '</div></div></div>';
9692        }
9693        if (w.fehler || !(w.konten || []).length) {
9694            return kopf
9695                + '<div class="alert alert-warning py-2 small mb-3">'
9696                + esc(w.fehler || t('Zu diesem Google-Konto wurde kein '
9697                    + 'Werbekonto gefunden.')) + '</div>'
9698                + '<button class="btn btn-outline-secondary btn-sm" data-action="ads-wahl-ab">'
9699                + t('Schließen') + '</button></div></div>';
9700        }
9701        const zeilen = (w.konten || []).map((k, i) => {
9702            const nr = String(k.customer_id || '');
9703            const schild = (k.manager
9704                    ? '<span class="badge bg-light text-dark ms-2">'
9705                      + t('Verwaltungskonto') + '</span>' : '')
9706                + (k.test ? '<span class="badge bg-light text-dark ms-2">'
9707                      + t('Testkonto') + '</span>' : '');
9708            return '<label class="list-group-item d-flex align-items-start">'
9709                + '<input class="form-check-input me-2 mt-1" type="radio" name="adswahl"'
9710                + ' value="' + esc(nr) + '"'
9711                + ((w.gewaehlt === nr || (!w.gewaehlt && i === 0)) ? ' checked' : '') + '>'
9712                + '<div><strong>' + esc(k.name || t('Ohne Namen')) + '</strong>'
9713                + schild
9714                + '<div class="text-muted small">' + esc(nr)
9715                + (k.waehrung ? ' · ' + esc(k.waehrung) : '') + '</div></div></label>';
9716        }).join('');
9717        return kopf
9718            + '<div class="text-muted small mb-3">'
9719            + t('Die Anmeldung hat funktioniert. Wählen Sie das Werbekonto, '
9720                + 'das der KI-Mitarbeiter auswerten soll. Ein '
9721                + 'Verwaltungskonto (MCC) enthält selbst keine Kampagnen — '
9722                + 'wählen Sie in diesem Fall das darunterliegende Konto.')
9723            + '</div>'
9724            // Wer schon verbunden ist, soll wissen, dass er ersetzt.
9725            + ((this._adsKonten || []).some(x => x.plattform === 'google' && x.verbunden)
9726                ? '<div class="alert alert-light border py-2 small mb-3">'
9727                  + '<i class="fas fa-circle-info me-1"></i>'
9728                  + t('Das ersetzt die bisherige Verbindung. Ihre Kampagnen '
9729                      + 'bleiben unverändert.') + '</div>' : '')
9730            + '<div class="list-group mb-3">' + zeilen + '</div>'
9731            + '<div class="d-flex gap-2">'
9732            + '<button class="btn btn-primary btn-sm" data-action="ads-wahl-ok">'
9733            + t('Konto übernehmen') + '</button>'
9734            + '<button class="btn btn-outline-secondary btn-sm" data-action="ads-wahl-ab">'
9735            + t('Abbrechen') + '</button></div>'
9736            + '</div></div>';
9737    }
9738
9739    static async _adsWahlBestaetigen() {
9740        const w = this._adsWahl;
9741        if (!w) return;
9742        const el = document.querySelector('input[name="adswahl"]:checked');
9743        const nr = el ? el.value : '';
9744        if (!nr) { w.fehler = t('Bitte wählen Sie ein Konto.'); this._render(); return; }
9745        try {
9746            const d = await api('/api/ai-team/ads/google/oauth/'
9747                                + encodeURIComponent(w.state), 'POST',
9748                                { customer_id: nr });
9749            this._adsWahl = null;
9750            this._adsKonten = d.konten || this._adsKonten;
9751            this._render();
9752            const p = d.probe || {};
9753            this._adsTest = { schritte: (p.schritte || []).concat([
9754                { name: t('Ergebnis'), ok: !!p.ok,
9755                  text: p.ok
9756                      ? t('Das Werbekonto ist verbunden.')
9757                      : (p.fehler || t('Das Konto wurde hinterlegt, konnte '
9758                          + 'aber nicht geprüft werden.')) }]) };
9759            this._modal(this._adsTestHtml());
9760        } catch (e) {
9761            w.fehler = e.message;
9762            this._render();
9763        }
9764    }
9765
9766    static _adsTestHtml() {
9767        const e = this._adsTest || {};
9768        const zeilen = (e.schritte || []).map(x =>
9769            '<li class="list-group-item d-flex align-items-start">'
9770            + '<i class="fas ' + (x.ok ? 'fa-circle-check text-success' : 'fa-circle-xmark text-danger')
9771            + ' me-2 mt-1"></i><div><strong>' + esc(x.name) + '</strong>'
9772            + '<div class="small text-muted">' + esc(x.text) + '</div></div></li>').join('');
9773        return '<div class="modal-header"><h5 class="modal-title">'
9774            + t('Verbindungsprüfung') + '</h5>'
9775            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
9776            + '<div class="modal-body"><ul class="list-group list-group-flush">' + zeilen + '</ul>'
9777            + '<div class="d-flex justify-content-end mt-3">'
9778            + '<button class="btn btn-secondary" data-action="modal-zu">' + t('Schließen') + '</button>'
9779            + '</div></div>';
9780    }
9781
9782    static _adsTrennenFragen(pf) {
9783        const spec = (this._adsKatalog || {})[pf] || {};
9784        this._modal('<div class="modal-header"><h5 class="modal-title">'
9785            + t('Werbekonto trennen') + '</h5>'
9786            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
9787            + '<div class="modal-body"><p>'
9788            + esc(t('Soll die Verbindung zu') + ' ' + (spec.label || pf) + ' '
9789                  + t('wirklich getrennt werden?')) + '</p>'
9790            + '<div class="alert alert-info py-2 small mb-0">'
9791            + esc(t('Ihre Kampagnen bleiben unverändert — nur der Zugriff von '
9792                    + 'voiceOne wird entfernt. Sie können jederzeit wieder verbinden.'))
9793            + '</div></div>'
9794            + '<div class="modal-footer">'
9795            + '<button class="btn btn-outline-secondary" data-action="modal-zu">'
9796            + t('Abbrechen') + '</button>'
9797            + '<button class="btn btn-danger" data-action="ads-trennen-ja" data-id="'
9798            + esc(pf) + '">' + t('Trennen') + '</button></div>');
9799    }
9800
9801    static async _adsTrennen(pf) {
9802        this._modal('');
9803        try {
9804            const d = await api('/api/ai-team/ads/' + encodeURIComponent(pf), 'DELETE');
9805            this._adsKonten = d.konten || this._adsKonten;
9806            this._render();
9807        } catch (e) { this._modalFehler(e); }
9808    }
9809
9810    static _resKarte(r) {
9811        /* dokdesk1: Nur die Ablage bekommt "Öffnen". Ein Postfach oder
9812           ein Sprachmodell hat keinen Inhalt, den man durchblättern
9813           könnte — ein Knopf dort wäre eine Lüge. */
9814        const oeffnen = r.typ === 'ablage'
9815            ? '<button class="btn btn-sm btn-primary" data-action="dok-auf" data-id="'
9816              + esc(r.id) + '"><i class="fas fa-folder-open me-1"></i>'
9817              + t('Öffnen') + '</button>'
9818            : '';
9819        const test = '<button class="btn btn-sm btn-outline-secondary" data-action="res-test" data-id="'
9820            + esc(r.id) + '"><i class="fas fa-plug me-1"></i>' + t('Verbindung prüfen') + '</button>';
9821        const zugewiesen = (this._members || []).filter(m =>
9822            (((m.config || {}).ausstattung) || {})[r.typ] === r.id);
9823        return '<div class="col-12 col-xl-6"><div class="card h-100 border">'
9824            + '<div class="card-body py-3">'
9825            + '<div class="d-flex align-items-center mb-1">'
9826            + '<strong class="me-auto">' + esc(r.name) + '</strong>'
9827            + (r.aktiv ? '' : '<span class="badge bg-secondary">' + t('inaktiv') + '</span>')
9828            + '</div>'
9829            + '<div class="text-muted small">' + esc(this._resBeschreibung(r)) + '</div>'
9830            + '<div class="small mt-1">'
9831            + (zugewiesen.length
9832                ? '<i class="fas fa-user-check me-1 text-success"></i>' + esc(t('genutzt von') + ': '
9833                    + zugewiesen.map(m => m.name).join(', '))
9834                : '<i class="fas fa-user-slash me-1 text-muted"></i><span class="text-muted">'
9835                    + t('noch niemandem zugewiesen') + '</span>')
9836            + '</div>'
9837            + '<div class="d-flex gap-2 mt-2">' + oeffnen + test
9838            + '<button class="btn btn-sm btn-outline-primary" data-action="res-bearbeiten" data-id="'
9839            + esc(r.id) + '">' + t('Bearbeiten') + '</button>'
9840            + '<button class="btn btn-sm btn-outline-danger ms-auto" data-action="res-del" data-id="'
9841            + esc(r.id) + '">' + t('Entfernen') + '</button>'
9842            + '</div></div></div></div>';
9843    }
9844
9845    /* ================================================================
9846       dokdesk1 (28.08.2026) — Ablage-Inhalt OHNE Umweg über einen
9847       Mitarbeiter.
9848
9849       WARUM HIER UND NICHT IM REITER "DATEIEN": Der Datei-Browser der
9850       Akte hängt an this._akteId; ALLE fünf Aufrufe dort gehen auf
9851       /employees/<id>/ablage/*. Eine Ablage OHNE zugewiesenen
9852       Mitarbeiter ist darüber nicht erreichbar — und genau die
9853       empfiehlt der Dokumentenscanner für Firmenunterlagen ("niemand
9854       liest automatisch mit"). Gemessen: von 7 Ablagen war genau eine
9855       unzugewiesen und damit als einzige ohne Rückweg.
9856
9857       Der Zugänge-Reiter ist der einzige Ort, an dem überhaupt ALLE
9858       Ablagen auftauchen (at.resources ohne Typfilter). Deshalb sitzt
9859       der Einstieg an der Karte.
9860
9861       Serverseitig wird NICHTS neu gebaut: /api/ai-team/dokumente/
9862       {inhalt,datei} nehmen eine ablage_id statt einer emp_id und sind
9863       für den mobilen Weg bereits geprüft.
9864       ================================================================ */
9865    static async _dokAuf(rid) {
9866        const r = (this._ressourcen || []).find(x => x.id === rid) || {};
9867        this._dok = { id: rid, name: r.name || '', pfad: '',
9868                      daten: null, laden: true, fehler: '' };
9869        await this._dokLaden();
9870    }
9871
9872    static async _dokLaden() {
9873        const d = this._dok;
9874        if (!d) return;
9875        d.laden = true; d.fehler = '';
9876        this._modal(this._dokHtml());
9877        try {
9878            d.daten = await api('/api/ai-team/dokumente/inhalt?ablage='
9879                + encodeURIComponent(d.id)
9880                + '&pfad=' + encodeURIComponent(d.pfad || ''));
9881            d.name = d.daten.ablage || d.name;
9882            d.pfad = d.daten.pfad || '';
9883        } catch (e) {
9884            d.fehler = e.message; d.daten = null;
9885        }
9886        d.laden = false;
9887        /* Der Zustand kann sich waehrend des Ladens geaendert haben (der
9888           Nutzer klickt schneller als das Netz). Nur zeichnen, wenn das
9889           Modal noch DIESER Ablage gehoert. */
9890        if (this._dok === d) this._modal(this._dokHtml());
9891    }
9892
9893    static _dokEin(name) {
9894        const d = this._dok;
9895        if (!d) return;
9896        d.pfad = (d.pfad ? d.pfad + '/' : '') + name;
9897        this._dokLaden();
9898    }
9899
9900    static _dokHoch() {
9901        const d = this._dok;
9902        if (!d) return;
9903        const teile = (d.pfad || '').split('/').filter(Boolean);
9904        teile.pop();
9905        d.pfad = teile.join('/');
9906        this._dokLaden();
9907    }
9908
9909    static async _dokDatei(name) {
9910        const d = this._dok;
9911        if (!d) return;
9912        const pfad = (d.pfad ? d.pfad + '/' : '') + name;
9913        return this._mitLadeanzeige('dok-datei', name, async (stand) => {
9914        try {
9915            const roh = await this._blobHolen('/api/ai-team/dokumente/datei?ablage='
9916                + encodeURIComponent(d.id) + '&pfad=' + encodeURIComponent(pfad), name, stand);
9917            /* Die Route liefert bewusst IMMER application/octet-stream mit
9918               nosniff — eine hochgeladene HTML-Datei liefe sonst auf
9919               unserer eigenen Herkunft. Den Anzeigetyp setzt deshalb die
9920               Oberflaeche, und zwar aus einer FESTEN Endungs-Zuordnung. */
9921            const typen = { pdf: 'application/pdf', png: 'image/png',
9922                            jpg: 'image/jpeg', jpeg: 'image/jpeg',
9923                            webp: 'image/webp', gif: 'image/gif' };
9924            const end = (name.split('.').pop() || '').toLowerCase();
9925            if (typen[end]) {
9926                const url = URL.createObjectURL(new Blob([roh], { type: typen[end] }));
9927                const w = window.open(url, '_blank');
9928                /* Sind Pop-ups gesperrt, ist window.open null. Dann wird
9929                   heruntergeladen statt still nichts zu tun. */
9930                if (!w) await this._speichernAls(roh, name);
9931                setTimeout(() => URL.revokeObjectURL(url), 60000);
9932            } else {
9933                await this._speichernAls(roh, name);
9934            }
9935        } catch (e) { this._modalFehler(e); }
9936        });
9937    }
9938
9939    static _dokHtml() {
9940        const d = this._dok || {};
9941        const dat = d.daten || {};
9942        const teile = (d.pfad || '').split('/').filter(Boolean);
9943
9944        let koerper;
9945        if (d.laden) {
9946            koerper = '<div class="text-muted">' + t('Lade …') + '</div>';
9947        } else if (d.fehler) {
9948            koerper = '<div class="alert alert-danger mb-0">' + esc(d.fehler) + '</div>';
9949        } else {
9950            const eintraege = dat.eintraege || [];
9951            const zeilen = eintraege.map(e => {
9952                const ordner = !!e.ordner;
9953                const wann = e.geaendert
9954                    ? new Date(e.geaendert * 1000).toLocaleString('de-DE',
9955                        { day: '2-digit', month: '2-digit', year: 'numeric',
9956                          hour: '2-digit', minute: '2-digit' })
9957                    : '';
9958                return '<button type="button" class="list-group-item list-group-item-action '
9959                    + 'd-flex align-items-center text-start" data-action="'
9960                    + (ordner ? 'dok-ein' : 'dok-datei') + '" data-id="' + esc(e.name) + '">'
9961                    + '<i class="fas ' + (ordner ? 'fa-folder text-warning' : 'fa-file-lines text-secondary')
9962                    + ' me-2"></i>'
9963                    + '<span class="me-auto">' + esc(e.name) + '</span>'
9964                    + '<span class="small text-muted">'
9965                    + (ordner ? '' : esc(this._abGroesse(e.groesse) + ' · ' + wann))
9966                    + '</span></button>';
9967            }).join('');
9968            koerper = eintraege.length
9969                ? '<div class="list-group list-group-flush">' + zeilen + '</div>'
9970                  + (dat.gekappt ? '<div class="small text-muted mt-2">'
9971                      + t('Es werden nicht alle Einträge angezeigt.') + '</div>' : '')
9972                : '<div class="text-muted fst-italic">' + t('Dieser Ordner ist leer.') + '</div>';
9973        }
9974
9975        /* Wer mitliest, gehoert an die Ablage selbst — nicht in eine
9976           Dokumentation. Dieselbe Aussage wie im mobilen Weg. */
9977        const leser = dat.gelesen_von || [];
9978        const hinweis = d.laden || d.fehler ? '' :
9979            (leser.length
9980                ? '<div class="small mt-2" style="color:#b54708">'
9981                  + '<i class="fas fa-user-check me-1"></i>'
9982                  + esc(t('Alles in dieser Ablage kann') + ' ' + leser.join(', ')
9983                        + ' ' + t('lesen.')) + '</div>'
9984                : '<div class="small text-muted mt-2">'
9985                  + '<i class="fas fa-user-slash me-1"></i>'
9986                  + t('Diese Ablage ist keinem KI-Mitarbeiter zugewiesen — niemand liest automatisch mit.')
9987                  + '</div>');
9988
9989        const brotkrume = teile.length
9990            ? '<div class="d-flex align-items-center gap-2 mb-2">'
9991              + '<button class="btn btn-sm btn-outline-secondary" data-action="dok-hoch">'
9992              + '<i class="fas fa-arrow-left me-1"></i>' + t('Zurück') + '</button>'
9993              + '<span class="small text-muted">' + esc(teile.join(' / ')) + '</span></div>'
9994            : '';
9995
9996        return '<div class="modal-header"><h5 class="modal-title">'
9997            + '<i class="fas fa-folder-open me-2"></i>' + esc(d.name || t('Ablage')) + '</h5>'
9998            + '<button type="button" class="btn-close" data-action="dok-zu"></button></div>'
9999            + '<div class="modal-body">' + brotkrume + koerper + hinweis + '</div>'
10000            + '<div class="modal-footer"><button class="btn btn-secondary" data-action="dok-zu">'
10001            + t('Schließen') + '</button></div>';
10002    }
10003
10004    static _resTestHtml() {
10005        const e = this._resTest || {};
10006        const zeilen = (e.schritte || []).map(s =>
10007            '<li class="list-group-item d-flex align-items-start">'
10008            + '<i class="fas ' + (s.ok ? 'fa-circle-check text-success' : 'fa-circle-xmark text-danger')
10009            + ' me-2 mt-1"></i><div><strong>' + esc(s.name) + '</strong>'
10010            + '<div class="small text-muted">' + esc(s.text) + '</div></div></li>').join('');
10011        return '<div class="modal-header"><h5 class="modal-title">'
10012            + t('Verbindungsprüfung') + '</h5>'
10013            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
10014            + '<div class="modal-body"><ul class="list-group list-group-flush">' + zeilen + '</ul>'
10015            + '<div class="d-flex justify-content-end mt-3">'
10016            + '<button class="btn btn-secondary" data-action="modal-zu">' + t('Schließen') + '</button>'
10017            + '</div></div>';
10018    }
10019
10020    static _resFeld(feld, wert, spec) {
10021        const L = {
10022            imap_host: t('Posteingang-Server (IMAP)'), imap_port: t('Port Posteingang'),
10023            smtp_host: t('Postausgang-Server (SMTP)'), smtp_port: t('Port Postausgang'),
10024            benutzer: t('Benutzername'), passwort: t('Passwort'),
10025            absender: t('Absender-Adresse'), ssl: t('Verschlüsselte Verbindung (SSL/TLS)'),
10026            api_key: t('API-Schlüssel'), base_url: t('Adresse des Dienstes (base URL)'),
10027            url: t('WebDAV-Adresse'), bucket: t('Bucket'), region: t('Region'),
10028            access_key: t('Zugriffsschlüssel (Access Key)'),
10029            secret_key: t('Geheimer Schlüssel (Secret Key)'),
10030            endpoint: t('Endpunkt (bei Nicht-AWS)'), basis_pfad: t('Unterordner'),
10031        };
10032        const geheim = (spec.geheim || []).indexOf(feld) >= 0;
10033        if (feld === 'ssl') {
10034            return '<div class="col-md-6"><div class="form-check mt-4">'
10035                + '<input class="form-check-input" type="checkbox" id="resf_ssl"'
10036                + (wert === false ? '' : ' checked') + '>'
10037                + '<label class="form-check-label" for="resf_ssl">
10037' + esc(L.ssl) + '</label></div></div>';
10038        }
10039        return '<div class="col-md-6"><label class="form-label small">' + esc(L[feld] || feld)
10040            + '</label><input class="form-control form-control-sm" id="resf_' + esc(feld) + '"'
10041            + ' type="' + (geheim ? 'password' : 'text') + '"'
10042            + ' value="' + esc(wert === undefined || wert === null ? '' : String(wert)) + '"'
10043            + (geheim ? ' placeholder="' + t('unverändert lassen') + '"' : '') + '></div>';
10044    }
10045
10046    static _renderResForm(host) {
10047        const f = this._resForm;
10048        const spec = ((this._spec || {}).typen || {})[f.typ] || {};
10049        const cfg = f.config || {};
10050        let felder = spec.felder || [];
10051
10052        // Nur die Felder zeigen, die zur gewaehlten Unterart gehoeren —
10053        // ein Formular mit S3- UND WebDAV-Feldern nebeneinander ist nicht
10054        // bedienbar.
10055        let kopfwahl = '';
10056        if (f.typ === 'ablage') {
10057            const arten = (this._spec || {}).ablage_arten || {};
10058            kopfwahl = '<div class="mb-3"><label class="form-label small">' + t('Art der Ablage') + '</label>'
10059                + '<div class="list-group">' + Object.keys(arten).map(k =>
10060                    '<a href="#" class="list-group-item list-group-item-action'
10061                    + (cfg.art === k ? ' active' : '') + '" data-action="res-art" data-id="' + esc(k) + '">'
10062                    + '<strong>' + esc(arten[k].label) + '</strong>'
10063                    + '<div class="small' + (cfg.art === k ? '' : ' text-muted') + '">'
10064                    + esc(arten[k].hinweis) + '</div></a>').join('') + '</div></div>';
10065            const proArt = { intern: [], webdav: ['url', 'benutzer', 'passwort', 'basis_pfad'],
10066                             s3: ['bucket', 'region', 'access_key', 'secret_key', 'endpoint', 'basis_pfad'] };
10067            felder = proArt[cfg.art] || [];
10068        } else if (f.typ === 'llm') {
10069            const arten = (this._spec || {}).llm_betriebsarten || {};
10070            const anb = (this._spec || {}).llm_anbieter || {};
10071            kopfwahl = '<div class="mb-3"><label class="form-label small">' + t('Betriebsart') + '</label>'
10072                + '<div class="list-group">' + Object.keys(arten).map(k =>
10073                    '<a href="#" class="list-group-item list-group-item-action'
10074                    + (cfg.betriebsart === k ? ' active' : '') + '" data-action="res-art" data-id="' + esc(k) + '">'
10075                    + '<strong>' + esc(arten[k].label) + '</strong>'
10076                    + '<div class="small' + (cfg.betriebsart === k ? '' : ' text-muted') + '">'
10077                    + esc(arten[k].hinweis) + '</div></a>').join('') + '</div></div>';
10078            // schluessel-raus-v1: 'Eigener Schluessel' ist entfernt, es gibt
10079            // keine Felder mehr einzugeben.
10080            felder = [];
10081        }
10082
10083        const hinweis = (f.typ === 'llm' && cfg.betriebsart === 'inklusive')
10084            ? '<div class="alert alert-info py-2 small">'
10085              + t('Enthalten sind 1.000 KI-Aktionen pro Monat und Mitarbeiter. Reicht das nicht, '
10086                  + 'buchen Sie zusätzliches Kontingent unter „Mein KI-Team → Abrechnung“ dazu. '
10087                  + 'Es steht sofort zur Verfügung.') + '</div>' : '';
10088
10089        host.innerHTML = '<div class="d-flex align-items-center mb-3">'
10090            + '<button class="btn btn-outline-secondary btn-sm me-3" data-action="res-abbrechen">'
10091            + '<i class="fas fa-arrow-left me-1"></i>' + t('Zurück') + '</button>'
10092            + '<h5 class="mb-0">' + esc(f.id ? t('Zugang bearbeiten') : t('Zugang hinzufügen'))
10093            + ' — ' + esc(this._resTitel(f.typ)) + '</h5></div>'
10094            + (f.fehler ? '<div class="alert alert-danger">' + esc(f.fehler) + '</div>' : '')
10095            + '<div class="card"><div class="card-body">'
10096            + '<div class="mb-3"><label class="form-label small">' + t('Bezeichnung') + '</label>'
10097            + '<input class="form-control form-control-sm" id="resf_name" value="' + esc(f.name || '')
10098            + '" placeholder="' + t('z. B. [email protected]') + '"></div>'
10099            + kopfwahl + hinweis
10100            + '<div class="row g-2">'
10101            + felder.map(x => this._resFeld(x, cfg[x], spec)).join('')
10102            + '</div>'
10103            + '<div class="mt-3 d-flex gap-2">'
10104            + '<button class="btn btn-primary btn-sm" data-action="res-speichern">'
10105            + '<i class="fas fa-save me-1"></i>' + t('Speichern') + '</button>'
10106            + '<button class="btn btn-outline-secondary btn-sm" data-action="res-abbrechen">'
10107            + t('Abbrechen') + '</button></div>'
10108            + '<div class="text-muted small mt-3"><i class="fas fa-lock me-1"></i>'
10109            + t('Passwörter und Schlüssel werden verschlüsselt gespeichert und danach nur noch '
10110                + 'als Punkte angezeigt. Wer sie ändern will, trägt sie neu ein; ein leeres Feld '
10111                + 'lässt den bisherigen Wert unverändert.')
10112            + '</div></div></div>';
10113    }
10114
10115    static _resNeu(typ) {
10116        const vor = { ablage: { art: 'intern' }, llm: { betriebsart: 'inklusive' },
10117                      postfach: { imap_port: '993', smtp_port: '587', ssl: true } };
10118        this._resForm = { typ: typ, id: null, name: '', config: vor[typ] || {} };
10119        this._resTest = null;
10120        this._render();
10121    }
10122
10123    static _resBearbeiten(rid) {
10124        const r = (this._ressourcen || []).find(x => x.id === rid);
10125        if (!r) return;
10126        this._resForm = { typ: r.typ, id: r.id, name: r.name,
10127                          config: Object.assign({}, r.config) };
10128        this._resTest = null;
10129        this._render();
10130    }
10131
10132    static _resArtWechsel(wert) {
10133        if (!this._resForm) return;
10134        this._resSammeln();
10135        const feld = this._resForm.typ === 'ablage' ? 'art' : 'betriebsart';
10136        this._resForm.config[feld] = wert;
10137        this._render();
10138    }
10139
10140    static _resSammeln() {
10141        const f = this._resForm;
10142        if (!f) return;
10143        const nm = document.getElementById('resf_name');
10144        if (nm) f.name = nm.value;
10145        const spec = ((this._spec || {}).typen || {})[f.typ] || {};
10146        (spec.felder || []).forEach(feld => {
10147            const el = document.getElementById('resf_' + feld);
10148            if (!el) return;
10149            f.config[feld] = (el.type === 'checkbox') ? el.checked : el.value;
10150        });
10151    }
10152
10153    static async _resSpeichern() {
10154        const f = this._resForm;
10155        if (!f) return;
10156        this._resSammeln();
10157        f.fehler = '';
10158        try {
10159            if (f.id) {
10160                await api('/api/ai-team/resources/' + encodeURIComponent(f.id), 'PUT',
10161                          { name: f.name, config: f.config });
10162            } else {
10163                await api('/api/ai-team/resources', 'POST',
10164                          { typ: f.typ, name: f.name, config: f.config });
10165            }
10166            this._resForm = null;
10167            this._spec = null;
10168            await this._neuLaden();
10169        } catch (e) {
10170            f.fehler = e.message;
10171            this._render();
10172        }
10173    }
10174
10175    static async _resTesten(rid) {
10176        this._resTest = { schritte: [{ name: t('Prüfung läuft'), ok: true,
10177                                       text: t('Einen Moment bitte …') }] };
10178        this._render();
10179        try {
10180            this._resTest = await api('/api/ai-team/resources/'
10181                                      + encodeURIComponent(rid) + '/test', 'POST');
10182        } catch (e) {
10183            this._resTest = { schritte: [{ name: t('Prüfung'), ok: false, text: e.message }] };
10184        }
10185        this._render();
10186        this._modal(this._resTestHtml());
10187    }
10188
10189    static _resDelFragen(rid) {
10190        const r = (this._ressourcen || []).find(x => x.id === rid);
10191        const genutzt = (this._members || []).filter(m =>
10192            (((m.config || {}).ausstattung) || {})[r ? r.typ : ''] === rid);
10193        this._modal('<div class="modal-header"><h5 class="modal-title">'
10194            + t('Zugang entfernen') + '</h5>'
10195            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
10196            + '<div class="modal-body"><p>'
10197            + esc(t('Soll dieser Zugang wirklich entfernt werden?')) + '</p>'
10198            + (genutzt.length
10199                ? '<div class="alert alert-warning py-2 small">'
10200                  + esc(t('Er wird gerade genutzt von') + ': ' + genutzt.map(m => m.name).join(', ')
10201                        + '. ' + t('Diese Mitarbeiter können danach nicht mehr arbeiten, bis Sie '
10202                                   + 'ihnen einen anderen Zugang zuweisen.')) + '</div>'
10203                : '')
10204            + '<div class="d-flex justify-content-end gap-2">'
10205            + '<button class="btn btn-outline-secondary" data-action="modal-zu">' + t('Abbrechen') + '</button>'
10206            + '<button class="btn btn-danger" data-action="res-del-ja" data-id="' + esc(rid) + '">'
10207            + t('Entfernen') + '</button></div></div>');
10208    }
10209
10210    static async _resDel(rid) {
10211        this._modal('');
10212        try {
10213            await api('/api/ai-team/resources/' + encodeURIComponent(rid), 'DELETE');
10214            this._spec = null;
10215            await this._neuLaden();
10216        } catch (e) { this._modalFehler(e); }
10217    }
10218
10219    static async _resImportSmtp() {
10220        try {
10221            await api('/api/ai-team/resources/import-smtp', 'POST', {});
10222            this._spec = null;
10223            await this._neuLaden();
10224        } catch (e) { this._modalFehler(e); }
10225    }
10226
10227    /** ai-team-stand-v1: Holt den VOLLEN Stand, ohne zu zeichnen.
10228     *  Eine einzige Stelle, an der die Zwischenspeicher gefuellt werden -
10229     *  vorher gab es zwei _neuLaden-Fassungen, von denen die
10230     *  unvollstaendigere gewann und Abteilungen, Auftraege und
10231     *  Betriebsmittel still veralten liess.
10232     *  Setzt bewusst NICHT den Reiter: das Wegspringen auf "Freigaben"
10233     *  gehoert in show(), nicht in jedes Auffrischen. */
10234    static async _zahlenStill() {
10235        const d = await api('/api/ai-team/members');
10236        this._members = d.members || [];
10237        this._telefon = d.telefon || [];
10238        this._departments = d.departments || [];
10239        this._auftraege = d.auftraege || {};
10240        /* aiteam-offenerlauf-v1 */
10241        this._offeneLaeufe = d.offene_laeufe || {};
10242        this._stats = d.stats || null;
10243        this._setup = d.setup || null;
10244        this._ressourcen = (d.setup || {}).ressourcen || [];
10245        // ai-team-einstieg-v1
10246        this._einrichtung = d.einrichtung || null;
10247        this._standZeit = Date.now();
10248        AiTeam._badgeSetzen(d.badge || 0);
10249        return d;
10250    }
10251
10252    /** Auffrischen samt Neuzeichnen. Ein Fehler beim Laden darf die
10253     *  Anzeige nie leeren - dann bleibt der letzte Stand stehen. */
10254    static async _neuLaden(tab) {
10255        try { await this._zahlenStill(); }
10256        catch (e) { /* Anzeige bleibt auf dem letzten Stand */ }
10257        this._tab = tab || this._tab;
10258        this._render();
10259    }
10260
10261    /** Beim Wechsel auf das Board den Stand still nachziehen, wenn er
10262     *  aelter als STAND_MAX_MS ist. Deckt den Fall ab, dass der
10263     *  15-Minuten-Takt zwischenzeitlich eine Freigabe erzeugt hat.
10264     *  Zeichnet nur neu, wenn der Nutzer noch auf dem Board steht. */
10265    static _standPruefen(tab) {
10266        if (tab !== 'board') return;
10267        if (Date.now() - (this._standZeit || 0) < STAND_MAX_MS) return;
10268        this._zahlenStill().then(() => {
10269            if (this._tab === 'board' && !this._akteId && !this._hire) this._render();
10270        }).catch(() => { /* stiller Nebenaufruf */ });
10271    }
10272
10273    // ---- Ausstattung in der Personalakte
10274    static _akteAusstattung(m) {
10275        const aus = (m.config || {}).ausstattung || {};
10276        const bed = this._bedarf(m.id);
10277        const zeilen = ['postfach', 'llm', 'ablage'].map(typ => {
10278            const liste = (this._ressourcen || []).filter(r => r.typ === typ && r.aktiv);
10279            const noetig = bed.find(x => x.typ === typ);
10280            return '<div class="col-md-4"><label class="form-label small">'
10281                + '<i class="fas ' + esc(this._resIcon(typ)) + ' me-1"></i>'
10282                + esc(this._resTitel(typ)) + '</label>'
10283                + '<select class="form-select form-select-sm" id="aus_' + esc(typ) + '">'
10284                + '<option value="">' + t('— nicht zugewiesen —') + '</option>'
10285                + liste.map(r => '<option value="' + esc(r.id) + '"'
10286                    + (aus[typ] === r.id ? ' selected' : '') + '>' + esc(r.name) + '</option>').join('')
10287                + '</select>'
10288                + (noetig ? '<div class="small mt-1 ' + (noetig.schwere === 'blockiert'
10289                        ? 'text-danger' : 'text-muted') + '">' + esc(noetig.text) + '</div>' : '')
10290                + '</div>';
10291        }).join('');
10292        return '<div class="card mb-3"><div class="card-body">'
10293            + '<div class="d-flex align-items-center mb-2">'
10294            + '<h6 class="mb-0 me-auto"><i class="fas fa-toolbox me-2"></i>' + t('Ausstattung') + '</h6>'
10295            + '<button class="btn btn-sm btn-outline-secondary" data-action="zu-zugaengen">'
10296            + '<i class="fas fa-key me-1"></i>' + t('Zugänge verwalten') + '</button></div>'
10297            + '<div class="text-muted small mb-2">'
10298            + t('Womit dieser Mitarbeiter arbeitet. Zugänge richten Sie einmal unter „Zugänge" '
10299                + 'ein und weisen sie hier zu.') + '</div>'
10300            + '<div class="row g-2">' + zeilen + '</div>'
10301            + '<button class="btn btn-sm btn-primary mt-3" data-action="ausstattung-speichern" '
10302            + 'data-id="' + esc(m.id) + '">' + t('Ausstattung speichern') + '</button>'
10303            + '</div></div>';
10304    }
10305
10306    static async _ausstattungSpeichern(id) {
10307        const z = {};
10308        ['postfach', 'llm', 'ablage'].forEach(typ => {
10309            const el = document.getElementById('aus_' + typ);
10310            if (el && el.value) z[typ] = el.value;
10311        });
10312        try {
10313            await api('/api/ai-team/members/' + encodeURIComponent(id) + '/ausstattung',
10314                      'PUT', { ausstattung: z });
10315            await this._akteOeffnen(id);
10316            try {
10317                const d = await api('/api/ai-team/setup');
10318                this._setup = d; this._ressourcen = d.ressourcen || [];
10319            } catch (e) { /* Anzeige bleibt */ }
10320        } catch (e) { this._modalFehler(e); }
10321    }
10322
10323    static _akteTitel(m) {
10324        return '<div class="card mb-3"><div class="card-body">'
10325            + '<h6 class="mb-2"><i class="fas fa-id-badge me-2"></i>' + t('Bezeichnung') + '</h6>'
10326            + '<div class="text-muted small mb-2">'
10327            + t('Die Rolle steuert, was der Mitarbeiter tut. Wie Sie ihn nennen, bestimmen Sie '
10328                + 'selbst — zum Beispiel „CEO", „Praxisleitung" oder „Chef vom Dienst".') + '</div>'
10329            + '<div class="input-group input-group-sm" style="max-width:520px">'
10330            + '<span class="input-group-text">' + esc(this._rolleTitel(m.role_key)) + '</span>'
10331            + '<input class="form-control" id="akteTitel" value="' + esc(m.titel_eigen ? m.titel : '')
10332            + '" placeholder="' + t('eigene Bezeichnung (optional)') + '" maxlength="60">'
10333            + '<button class="btn btn-outline-primary" data-action="titel-speichern" data-id="'
10334            + esc(m.id) + '">' + t('Übernehmen') + '</button></div></div></div>';
10335    }
10336
10337    /* ai-team-fachtext-ui-v1: Freitext, der das Fachprofil der Rolle
10338       ergaenzt. Bewusst getrennt von den "Anweisungen" darunter: die sind
10339       kurze Verhaltensregeln, das hier ist fachlicher Hintergrund - und es
10340       steht an einer anderen Stelle des Systemprompts. */
10341    static _rolleHatFach(key) {
10342        const r = ((this._katalog || {}).rollen || []).find(x => x.key === key);
10343        return r ? !!r.hat_fach : true;
10344    }
10345
10346    static _akteFachtext(m) {
10347        const txt = (m.config || {}).fachtext || '';
10348        const hat = this._rolleHatFach(m.role_key);
10349        const erkl = hat
10350            ? t('Diese Rolle bringt bereits ein Fachprofil mit — Normen, Fristen und Hausregeln. '
10351                + 'Was Sie hier schreiben, kommt hinzu und geht vor, wenn es einer Vorgabe widerspricht.')
10352            : t('Diese Rolle bringt bewusst kein Fachprofil mit. Was Sie hier schreiben, '
10353                + 'ist ihr fachlicher Hintergrund.');
10354        return '<div class="card mb-3"><div class="card-body">'
10355            + '<h6 class="mb-2"><i class="fas fa-book me-2"></i>' + t('Eigenes Fachwissen') + '</h6>'
10356            + '<div class="text-muted small mb-2">' + esc(erkl) + ' '
10357            + t('Für kurze Verhaltensregeln nutzen Sie die Anweisungen weiter unten.') + '</div>'
10358            + '<textarea id="akteFachtext" class="form-control" rows="8" maxlength="4000" '
10359            + 'placeholder="' + esc(t('Zum Beispiel: Wir buchen nach SKR04. Unser Steuerberater ist '
10360                + 'Kanzlei Müller. Fristen melden Sie mir immer drei Tage früher.')) + '">'
10361            + esc(txt) + '</textarea>'
10362            + '<div class="d-flex align-items-center mt-2 gap-2">'
10363            + '<button class="btn btn-outline-primary btn-sm" data-action="fachtext-speichern" data-id="'
10364            + esc(m.id) + '">' + t('Übernehmen') + '</button>'
10365            + '<span class="text-muted small" id="aiFachZahl">' + esc(String(txt.length)) + ' '
10366            + t('von 4000 Zeichen') + '</span>'
10367            + '<span id="aiFachHinweis" class="small text-success"></span>'
10368            + '</div></div></div>';
10369    }
10370
10371    static async _fachtextSpeichern(id) {
10372        const el = document.getElementById('akteFachtext');
10373        try {
10374            await api('/api/ai-team/members/' + encodeURIComponent(id), 'PUT',
10375                      { fachtext: el ? el.value : '' });
10376            await this._neuLaden();
10377            await this._akteOeffnen(id);
10378        } catch (e) { this._modalFehler(e); }
10379    }
10380
10381    static async _titelSpeichern(id) {
10382        const el = document.getElementById('akteTitel');
10383        try {
10384            await api('/api/ai-team/members/' + encodeURIComponent(id) + '/titel', 'PUT',
10385                      { titel: el ? el.value : '' });
10386            await this._neuLaden();
10387            await this._akteOeffnen(id);
10388        } catch (e) { this._modalFehler(e); }
10389    }
10390
10391    // ai-team-zugriff-ui-v1: der gebuendelte Blick einer Fuehrungskraft.
10392    // Bei 5 Mitarbeitern bekaeme der Inhaber sonst 5 Tagesberichte.
10393    static _fuehrungsBlock(f) {
10394        if (!f) { return ''; }
10395        if (f.keine_unterstellten) {
10396            return '<div class="alert alert-secondary small mt-2 mb-0">'
10397                + esc(f.hinweis || '') + '</div>';
10398        }
10399        const team = (f.team || []).map(x => {
10400            const zahlen = [
10401                x.erledigt ? x.erledigt + ' ' + t('erledigt') : '',
10402                x.entwuerfe ? x.entwuerfe + ' ' + t('Entwürfe') : '',
10403                x.offene_freigaben ? x.offene_freigaben + ' ' + t('warten auf Freigabe') : '',
10404                x.blockiert ? x.blockiert + ' ' + t('blockiert') : '',
10405                x.offene_auftraege ? x.offene_auftraege + ' ' + t('offene Aufträge') : ''
10406            ].filter(Boolean).join(' · ') || t('nichts zu berichten');
10407            const kern = (x.kernpunkte || []).length
10408                ? '<ul class="mb-0 small text-muted">'
10409                  + x.kernpunkte.map(k => '<li>' + esc(k) + '</li>').join('') + '</ul>'
10410                : '';
10411            return '<div class="border-start ps-2 py-1 mb-1">'
10412                + '<div><strong>' + esc(x.name) + '</strong>'
10413                + (x.titel ? ' <span class="small text-muted">' + esc(x.titel) + '</span>' : '')
10414                + '</div><div class="small">' + esc(zahlen) + '</div>' + kern + '</div>';
10415        }).join('');
10416        const esk = (f.eskalation || []).length
10417            ? '<div class="alert alert-warning small mt-2 mb-0"><strong>'
10418              + t('Liegt zu lange bei Ihnen') + '</strong><ul class="mb-0">'
10419              + f.eskalation.map(x => '<li>' + esc(x) + '</li>').join('') + '</ul></div>'
10420            : '';
10421        return '<div class="mt-2 pt-2 border-top">'
10422            + '<div><strong>' + t('Überblick über mein Team') + '</strong></div>'
10423            + '<div class="small text-muted mb-2">' + esc(f.ueberblick || '') + '</div>'
10424            + team + esk + '</div>';
10425    }
10426
10427    /* ai-team-gfx-v1 — TAGESBERICHT
10428       Vorher trug jede Karte DREI gleich starke Ueberschriften, und
10429       davon standen regelmaessig zwei auf "(0)": zwei Drittel der
10430       Flaeche sagten nichts. Jetzt erscheint ein Abschnitt nur, wenn er
10431       Inhalt hat; hat der ganze Tag nichts, sagt EIN Satz das.
10432       Ausserdem waren "offene Auftraege" im Datensatz vorhanden, wurden
10433       aber nirgends gezeigt — bei einem Mitarbeiter neun Stueck. */
10434    static _berichtKarte(r) {
10435        const i = r.inhalt || {};
10436        const teil = (titel, liste, akzent, icon, hoechstens) => {
10437            const l = (liste || []).filter(x => x !== null && x !== undefined);
10438            if (!l.length) { return ''; }
10439            const zeig = hoechstens ? l.slice(0, hoechstens) : l;
10440            const rest = l.length - zeig.length;
10441            return '<div class="aiv-abschnitt" style="--aiv-akzent:' + akzent + '">'
10442                + '<div class="aiv-abschnitt-kopf"><i class="fas ' + icon + '"></i>'
10443                + titel + ' (' + l.length + ')</div>'
10444                + '<ul>' + zeig.map(x => '<li>'
10445                    + esc(typeof x === 'string' ? x : (x.text || JSON.stringify(x)))
10446                    + '</li>').join('')
10447                /* Die Zahl im Kopf nennt den vollen Umfang, diese Zeile
10448                   sagt zusaetzlich, dass hier gekuerzt wurde. */
10449                + (rest ? '<li class="text-muted">' + t('und') + ' ' + rest + ' '
10450                    + t(rest === 1 ? 'weiterer' : 'weitere') + '</li>' : '')
10451                + '</ul></div>';
10452        };
10453        const n = (a) => ((a || []).length);
10454        const wartet = n(i.wartet) + n(i.entwuerfe);
10455        const leer = !wartet && !n(i.erledigt) && !n(i.blockiert)
10456            && !n(i.auftraege_offen) && !n(i.auftraege_erledigt);
10457        const klasse = 'aiv-bericht'
10458            + (wartet ? ' hat-wartet' : (n(i.blockiert) ? ' hat-blockiert' : ''));
10459        return '<div class="' + klasse + '">'
10460            + '<div class="aiv-bericht-kopf">'
10461            + '<span class="aiv-bericht-wer">' + esc(r.mitarbeiter || t('Mitarbeiter')) + '</span>'
10462            + (r.titel ? '<span class="aiv-bericht-rolle">' + esc(r.titel) + '</span>' : '')
10463            + (i.probezeit ? '<span class="badge bg-light text-dark">' + t('Probezeit')
10464                + ' ' + esc(String(i.probezeit.tag)) + '/' + esc(String(i.probezeit.von))
10465                + '</span>' : '')
10466            + '<span class="aiv-bericht-datum">' + esc(this._datum(r.datum)) + '</span>'
10467            + '</div>'
10468            + (leer
10469                ? '<div class="aiv-leer">' + t('An diesem Tag ist nichts angefallen.') + '</div>'
10470                : teil(t('Wartet auf Sie'), (i.wartet || []).concat(i.entwuerfe || []),
10471                       'var(--aiv-info)', 'fa-hand-point-right')
10472                  + teil(t('Erledigt'), i.erledigt, 'var(--aiv-good)', 'fa-check')
10473                  + teil(t('Offene Aufträge'), i.auftraege_offen,
10474                         'var(--aiv-neutral)', 'fa-list-check', 3)
10475                  + teil(t('Konnte ich nicht — und warum'), i.blockiert,
10476                         'var(--aiv-warn)', 'fa-circle-exclamation'))
10477            + (i.empfehlung
10478                ? '<div class="aiv-abschnitt" style="--aiv-akzent:var(--aiv-info)">'
10479                  + '<div class="aiv-abschnitt-kopf"><i class="fas fa-lightbulb"></i>'
10480                  + t('Mein Vorschlag') + '</div>'
10481                  + '<div class="aiv-erg">
10481' + esc(i.empfehlung) + '</div></div>'
10482                : '')
10483            + this._fuehrungsBlock(i.fuehrung)
10484            + '</div>';
10485    }
10486
10487    static async _statusSetzen(id, status) {
10488        try {
10489            await api('/api/ai-team/members/' + encodeURIComponent(id), 'PUT', { status: status });
10490        } catch (e) { return this._modalFehler(e); }
10491        await this._akteOeffnen(id);
10492    }
10493
10494    static _entlassenFragen(id) {
10495        const m = (this._members || []).find(x => x.id === id) || ((this._akteDaten || {}).member || {});
10496        this._modal('<div class="modal-header"><h5 class="modal-title">' + t('Mitarbeiter entlassen?') + '</h5>'
10497            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
10498            + '<div class="modal-body"><p>' + esc(m.name || '') + ' '
10499            + t('wird aus dem Team entlassen. Offene Freigaben werden geschlossen; Protokoll und Berichte bleiben zur Nachvollziehbarkeit erhalten.') + '</p>'
10500            + '<div class="d-flex justify-content-end ga
10500p-2">'
10501            + '<button class="btn btn-outline-secondary" data-action="modal-zu">' + t('Abbrechen') + '</button>'
10502            + '<button class="btn btn-danger" data-action="entlassen-ja" data-id="' + esc(id) + '">'
10503            + t('Ja, entlassen') + '</button></div></div>');
10504    }
10505
10506    static async _entlassen(id) {
10507        try {
10508            await api('/api/ai-team/members/' + encodeURIComponent(id), 'DELETE');
10509        } catch (e) { return this._modalFehler(e); }
10510        this._modal('');
10511        this._akteId = null;
10512        await this._neuLaden('board');
10513    }
10514
10515    static async _anweisungAdd(id) {
10516        const inp = document.getElementById('aiAnwNeu');
10517        const text = inp ? inp.value.trim() : '';
10518        if (!text) return;
10519        const anw = (((this._akteDaten || {}).member || {}).config || {}).anweisungen || [];
10520        await this._anweisungenSpeichern(id, anw.concat([text]));
10521    }
10522
10523    static async _anweisungDel(id, idx) {
10524        const anw = ((((this._akteDaten || {}).member || {}).config || {}).anweisungen || []).slice();
10525        if (idx >= 0 && idx < anw.length) anw.splice(idx, 1);
10526        await this._anweisungenSpeichern(id, anw);
10527    }
10528
10529    static async _anweisungenSpeichern(id, liste) {
10530        try {
10531            await api('/api/ai-team/members/' + encodeURIComponent(id), 'PUT', { anweisungen: liste });
10532        } catch (e) { return this._modalFehler(e); }
10533        await this._akteOeffnen(id);
10534    }
10535
10536    static async _vertrauenSpeichern(id) {
10537        const werte = {};
10538        document.querySelectorAll('[data-vertrauen]').forEach(sel => {
10539            werte[sel.getAttribute('data-vertrauen')] = sel.value;
10540        });
10541        /* limit-v1: Tageskontingente im SELBEN Aufruf mitschicken. Zwei
10542           getrennte Aufrufe koennten halb durchgehen und der Inhaber
10543           saehe eine Stufe ohne die dazugehoerige Zahl. Der Server
10544           normalisiert und meldet jede Klemmung als Hinweis zurueck. */
10545        const limits = {};
10546        document.querySelectorAll('[data-limit]').forEach(el => {
10547            limits[el.getAttribute('data-limit')] = el.value;
10548        });
10549        let d;
10550        try {
10551            d = await api('/api/ai-team/members/' + encodeURIComponent(id), 'PUT',
10552                          { vertrauen: werte, limits: limits });
10553        } catch (e) { return this._modalFehler(e); }
10554        const h = document.getElementById('aiVertHinweis');
10555        if (h && d.hinweise && d.hinweise.length) h.textContent = d.hinweise.join(' · ');
10556        await this._akteOeffnen(id);
10557    }
10558
10559    static async _memoryKurieren(id) {
10560        const btn = document.querySelector('[data-action="memory-kurieren"]');
10561        if (btn) { btn.disabled = true; btn.textContent = t('Bewerte …'); }
10562        try {
10563            await api('/api/ai-team/members/' + encodeURIComponent(id)
10564                      + '/memory/kurieren', 'POST', {});
10565        } catch (e) {
10566            if (btn) { btn.disabled = false; btn.textContent = t('Jetzt bewerten lassen'); }
10567            return this._modalFehler(e);
10568        }
10569        await this._akteOeffnen(id);
10570    }
10571
10572    static async _memoryBehalten(id, memId) {
10573        try {
10574            await api('/api/ai-team/members/' + encodeURIComponent(id)
10575                      + '/memory/' + memId + '/behalten', 'POST', {});
10576        } catch (e) { return this._modalFehler(e); }
10577        await this._akteOeffnen(id);
10578    }
10579
10580    static async _memoryDel(id, memId) {
10581        try {
10582            await api('/api/ai-team/members/' + encodeURIComponent(id) + '/memory/' + memId, 'DELETE');
10583        } catch (e) { return this._modalFehler(e); }
10584        await this._akteOeffnen(id);
10585    }
10586
10587    // ---------------------------------------------------------- Freigaben
10588
10589    static async _renderFreigaben(host) {
10590        const seq = this._seq;
10591        host.innerHTML = '<div class="text-muted">' + t('Lade …') + '</div>';
10592        let d;
10593        /* freigabe-fehlschlag-v1: Freigegebenes, das NICHT gewirkt hat, wird
10594           mitgeladen. Ein Fehler dabei darf die offenen Freigaben nicht
10595           verdecken - deshalb getrennt abgefangen. */
10596        const fehlP = api('/api/ai-team/approvals?status=fehlgeschlagen')
10597            .then(x => (x && x.approvals) || []).catch(() => []);
10598        try { d = await api('/api/ai-team/approvals?status=offen'); }
10599        catch (e) {
10600            if (seq === this._seq) host.innerHTML = '<div class="alert alert-danger">' + esc(e.message) + '</div>';
10601            return;
10602        }
10603        const fehl = await fehlP;
10604        if (seq !== this._seq) return;
10605        const liste = d.approvals || [];
10606        AiTeam._badgeSetzen(liste.length);
10607        if (!liste.length) {
10608            host.innerHTML = this._freigabenFehlgeschlagen(fehl)
10609                + '<div class="alert alert-light border">'
10610                + t('Keine offenen Freigaben. Hier erscheint alles, was ein Mitarbeiter nach außen geben möchte — mit der fertigen Vorschau, nie nur einer Absicht.')
10611                + '</div>';
10612            return;
10613        }
10614        host.innerHTML = this._freigabenFehlgeschlagen(fehl) + (d.gekappt ? '<div class="alert alert-warning small">'
10615            + t('Anzeige gekürzt — es gibt weitere offene Freigaben.') + '</div>' : '')
10616            // freigabe-klartext-v1: EIN Satz, was diese Seite ueberhaupt ist.
10617            + '<div class="alert alert-light border small">' + t('Ihre KI-Mitarbeiter fragen hier um Erlaubnis, bevor sie etwas in Ihrem Konto ändern oder nach außen geben. Noch ist nichts davon passiert – es geschieht erst, wenn Sie freigeben.') + '</div>'
10618            + liste.map(a => '<div class="card mb-2"><div class="card-body">'
10619                + '<div class="d-flex align-items-center mb-2">'
10620                + '<strong>' + esc(a.mitarbeiter) + '</strong>'
10621                // ai-team-bugfix-20260812 (B4): auch hier der lesbare Name.
10622                // ai-team-approvaltext-v1: jetzt aus EINER Quelle (Server),
10623                // damit Desktop und mobile Ansicht nicht auseinanderlaufen.
10624                // vorschau.label bleibt als Rueckfall fuer aeltere Server.
10625                + '<span class="badge bg-light text-dark ms-2">'
10626                + esc((a.klartext && a.klartext.titel) || a.tool_text || (a.vorschau || {}).label || a.typ) + '</span>'
10627                + '<span class="text-muted small ms-auto">' + esc(this._datum(a.created_at)) + '</span></div>'
10628                + this._freigabeKlartext(a)
10629                + this._freigabeAnlagen(a)
10630                + '<div class="d-flex gap-2 mt-2">'
10631                + '<button class="btn btn-sm btn-success" data-action="freigeben" data-id="' + a.id + '">'
10632                + '<i class="fas fa-check me-1"></i>' + t('Freigeben') + '</button>'
10633                + '<button class="btn btn-sm btn-outline-danger" data-action="ablehnen" data-id="' + a.id + '">'
10634                + t('Ablehnen…') + '</button>'
10635                + (a.entwurf
10636                    ? '<button class="btn btn-sm btn-outline-secondary ms-auto" '
10637                      + 'data-action="freigabe-postfach" data-id="' + a.id + '">'
10638                      + '<i class="fas fa-pen me-1"></i>'
10639                      + t('Im Postfach bearbeiten') + '</button>'
10640                    : '')
10641                + '</div></div></div>').join('');
10642    }
10643
10644    /* ═══ freigabe-fehlschlag-v1 ═══════════════════════════════════════════
10645       Freigegeben, aber die Ausfuehrung ist gescheitert. Bis 30.09. gab es
10646       dafuer keine Anzeige: die Karte verschwand aus "offen", und die
10647       Aenderung war trotzdem nie passiert (gemessen: 395 Website-
10648       Aenderungen). Hier stehen sie mit Grund - und mit einem Weg. */
10649    static _freigabenFehlgeschlagen(liste) {
10650        if (!liste || !liste.length) return '';
10651        return '<div class="alert alert-danger py-2 small mb-2">'
10652            + '<i class="fas fa-triangle-exclamation me-1"></i><strong>'
10653            + t('Freigegeben, aber nicht ausgeführt') + ' (' + liste.length + ')</strong> — '
10654            + t('diese Änderungen haben Sie erlaubt, sie sind aber NICHT passiert. Der Mitarbeiter weiß Bescheid.')
10655            + '</div>'
10656            + liste.map(a => {
10657                const ang = ((a.vorschau || {}).angaben) || {};
10658                const was = ang.datei || ang.pfad || ang.subject || ang.betreff || '';
10659                return '<div class="card mb-2 border-danger"><div class="card-body py-2">'
10660                    + '<div class="d-flex align-items-center mb-1">'
10661                    + '<strong>' + esc(a.mitarbeiter) + '</strong>'
10662                    + '<span class="badge bg-light text-dark ms-2">' + esc(a.tool_text || a.typ) + '</span>'
10663                    + '<span class="text-muted small ms-auto">' + t('freigegeben') + ' '
10664                    + esc(this._datum(a.decided_at)) + '</span></div>'
10665                    + (was ? '<div class="small fw-semibold" style="overflow-wrap:anywhere">' + esc(String(was)) + '</div>' : '')
10666                    + '<div class="small text-danger" style="overflow-wrap:anywhere">'
10667                    + t('Grund') + ': ' + esc(a.fehler) + '</div>'
10668                    + '<div class="d-flex gap-2 mt-2">'
10669                    + '<button class="btn btn-sm btn-outline-primary" data-action="freigabe-wiederholen" data-id="' + a.id + '">'
10670                    + '<i class="fas fa-rotate-right me-1"></i>' + t('Erneut ausführen') + '</button>'
10671                    + '<button class="btn btn-sm btn-outline-secondary" data-action="freigabe-verwerfen" data-id="' + a.id + '">'
10672                    + t('Verwerfen') + '</button></div>'
10673                    + '</div></div>';
10674            }).join('')
10675            + '<hr class="my-3">';
10676    }
10677
10678    static async _freigabeWiederholen(id) {
10679        let r;
10680        try { r = await api('/api/ai-team/approvals/' + id + '/wiederholen', 'POST', {}); }
10681        catch (e) { return this._modalFehler(e); }
10682        const a = (r || {}).ausfuehrung || {};
10683        await this._neuLaden('freigaben');
10684        if (a.ausgefuehrt === false) {
10685            this._modalFehler(new Error(t('Wieder nicht ausgeführt') + ': ' + (a.grund || t('unbekannter Grund'))));
10686        }
10687    }
10688
10689    static async _freigabeVerwerfen(id) {
10690        if (!window.confirm(t('Diese Änderung endgültig verwerfen? Sie wird nicht mehr ausgeführt.'))) return;
10691        try { await api('/api/ai-team/approvals/' + id + '/verwerfen', 'POST', {}); }
10692        catch (e) { return this._modalFehler(e); }
10693        await this._neuLaden('freigaben');
10694    }
10695
10696    /* ═══ ai-team-freigabe-anlagen-ui-v1 ═══════════════════════════════════
10697       Wer freigibt, muss sehen WAS mitgeht. Bisher standen die Anlagen —
10698       wenn ueberhaupt — als Pfadliste mitten im JSON-Klumpen der Angaben.
10699       Zusaetzlich der haeufigste und teuerste Fehler: der Text kuendigt
10700       eine Anlage an, es haengt aber keine dran. Der Empfaenger sucht
10701       sie dann, und der Absender merkt nichts. */
10702    static _freigabeAnlagen(a) {
10703        const liste = a.anhaenge || [];
10704        const angaben = ((a.vorschau || {}).angaben) || {};
10705        const text = [angaben.body, angaben.text, angaben.inhalt]
10706            .filter(x => typeof x === 'string').join('\n');
10707        /* Bewusst eng gefasst: nur Formulierungen, die eine BEIGEFUEGTE
10708           Datei versprechen. "im Anhang Ihrer Mail" oder "anbei sende ich
10709           Ihnen die Zahlen" (= im Text) sollen nicht warnen. */
10710        const kuendigt =
10711            /\b(anbei|beigef(ü|ue)gt|im anhang|als anlage|als pdf|anh(ä|ae)ngend)\b/i
10712                .test(text)
10713            /* Gegenprobe: "im Anhang Ihrer Mail" spricht von der Datei, die
10714               HEREINKAM - da fehlt nichts. Ohne diese Ausnahme warnt jede
10715               Empfangsbestaetigung. */
10716            && !/\b(anhang|anlage)[^.\n]{0,20}\b(ihrer|ihres|deiner|eurer)\b/i
10717                .test(text);
10718        let h = '';
10719        if (liste.length) {
10720            h += '<div class="mt-2 small">'
10721                + '<i class="fas fa-paperclip text-muted me-1"></i>'
10722                + '<span class="text-muted">' + t('Anlagen') + ':</span> '
10723                + liste.map(p => '<span class="badge bg-light text-dark ms-1">'
10724                    + esc(String(p).split('/').pop()) + '</span>').join('')
10725                + '</div>';
10726        }
10727        if (kuendigt && !liste.length) {
10728            h += '<div class="alert alert-warning py-2 px-2 mt-2 mb-0 small">'
10729                + '<i class="fas fa-triangle-exclamation me-1"></i>'
10730                + t('Der Text kündigt eine Anlage an, es hängt aber keine dran. '
10731                    + 'So geht die Mail ohne die versprochene Datei raus.')
10732                + '</div>';
10733        }
10734        return h;
10735    }
10736
10737    /* Fuehrt zum EINEN Bearbeitungsort statt einen zweiten zu bauen: der
10738       Entwurf liegt ohnehin im Postfach, dort laesst sich alles aendern —
10739       Text, Empfaenger und Anlagen — und von dort abschicken. */
10740    static async _freigabePostfach(id) {
10741        const d = await api('/api/ai-team/approvals?status=offen').catch(() => null);
10742        const a = ((d || {}).approvals || []).find(x => x.id === id);
10743        if (!a || !a.entwurf) return this._modalFehler(
10744            new Error(t('Zu dieser Freigabe liegt kein Entwurf im Postfach.')));
10745        await this._akteOeffnen(a.employee_id);
10746        /* Zustand VOR dem Reiterwechsel setzen: das Rendern des Postfachs
10747           laedt selbst nach, und ein danach gesetzter Hinweis waere sofort
10748           wieder weg. */
10749        const p = this._pfZustand();
10750        p.ordner = 'entwuerfe'; p.label = 'Entwürfe';
10751        p.ansicht = 'liste'; p.mail = null; p.offset = 0;
10752        p.melden = t('Der Entwurf zu dieser Freigabe liegt hier. '
10753            + 'Änderungen wirken auf die Mail, die nach der Freigabe rausgeht.');
10754        this._pfGeladen = false;
10755        this._akteReiter('postfach');
10756    }
10757
10758    /* ═══ ai-team-bugfix-20260812 (B2) ═══════════════════════════════════
10759       Die Vorschau wurde bisher als rohe Schluessel/Wert-Tabelle
10760       ausgegeben. Bei einer E-Mail hiess das: "angaben" als JSON-Klumpen
10761       (Zeilenumbrueche als literales \n, weil JSON.stringify sie
10762       maskiert), darunter "text" mit demselben Inhalt ein zweites Mal,
10763       dazu "werkzeug"/"quelle" als technische Feldnamen.
10764
10765       Das ist die wichtigste Entscheidung im ganzen System - geht diese
10766       Mail an einen echten Kunden raus? - und Empfaenger, Betreff und
10767       Text waren nur im JSON zu erkennen.
10768
10769       Es wird NICHTS weggelassen: bekannte Felder bekommen eine Form,
10770       alle uebrigen stehen weiterhin darunter. */
10771    /* freigabe-klartext-v1: Zuerst in Klartext, WAS passieren soll, WARUM
10772       und was die beiden Knoepfe bewirken. Die Rohangaben (IDs, Werkzeug-
10773       name) nur noch eingeklappt - ausser dort, wo sie der Inhalt sind
10774       (Mailtext, Dateiaenderung). Ohne Klartext vom Server: Rohansicht. */
10775    static _freigabeKlartext(a) {
10776        const kt = a.klartext;
10777        if (!kt || !kt.satz) return this._vorschau(a.vorschau);
10778        let h = '<div class="mb-2" style="font-size:1.02rem;font-weight:600">' + esc(kt.satz) + '</div>';
10779        if (kt.warnung) {
10780            h += '<div class="alert alert-danger py-2 px-3 small mb-2">'
10781               + '<i class="fas fa-triangle-exclamation me-1"></i>' + esc(kt.warnung) + '</div>';
10782        }
10783        if (kt.punkte && kt.punkte.length) {
10784            // Raster statt Tabelle: die globale Tabellenregel brach die
10785            // Beschriftung Buchstabe fuer Buchstabe um (gemessen 27.09.).
10786            h += '<div class="small mb-2" style="display:grid;grid-template-columns:max-content 1fr;'
10787               + 'gap:4px 18px;max-width:720px">'
10788               + kt.punkte.map(p => '<div class="text-muted" style="white-space:nowrap">' + esc(p.k) + '</div>'
10789                   + '<div class="fw-semibold" style="overflow-wrap:anywhere">' + esc(p.v) + '</div>').join('')
10790               + '</div>';
10791        }
10792        if (kt.grund || kt.auftrag) {
10793            h += '<div class="small border-start border-3 ps-2 mb-2">'
10794               + '<div class="fw-semibold">' + t('Warum?') + '</div>'
10795               + (kt.grund ? '<div>„' + esc(kt.grund) + '“</div>' : '')
10796               + (kt.auftrag ? '<div class="text-muted">' + t('Aus dem Auftrag') + ': ' + esc(kt.auftrag) + '</div>' : '')
10797               + '</div>';
10798        }
10799        if (kt.frei) h += '<div class="small mb-1"><strong class="text-success">' + t('Wenn Sie freigeben:')
10800               + '</strong> ' + esc(kt.frei) + '</div>';
10801        if (kt.ab) h += '<div class="small mb-2"><strong class="text-danger">' + t('Wenn Sie ablehnen:')
10802               + '</strong> ' + esc(kt.ab) + '</div>';
10803        h += '<details' + (kt.details_offen ? ' open' : '') + '>'
10804           + '<summary class="small text-muted" style="cursor:pointer">' + t('Technische Details') + '</summary>'
10805           + '<div class="mt-1">' + this._vorschau(a.vorschau) + '</div></details>';
10806        return h;
10807    }
10808
10809    static _vorschau(v) {
10810        v = v || {};
10811        const a = (v.angaben && typeof v.angaben === 'object' && !Array.isArray(v.angaben))
10812            ? v.angaben : {};
10813        const zeile = (k, wert) => '<tr><td class="text-muted small pe-2" '
10814            + 'style="white-space:nowrap;vertical-align:top">' + esc(k) + '</td>'
10815            + '<td class="small" style="white-space:pre-wrap;overflow-wrap:anywhere">'
10816            + esc(typeof wert === 'string' ? wert : JSON.stringify(wert, null, 1))
10817            + '</td></tr>';
10818
10819        /* Was in der Kopfzeile schon steht (Werkzeug) oder rein technisch
10820           ist, gehoert nicht in die Tabelle. */
10821        const NICHT = { werkzeug: 1, label: 1, quelle: 1, _entwurf: 1, anhaenge: 1, auftrag_id: 1 };
10822        const BEKANNT = {
10823            to: t('An'), empfaenger: t('An'), cc: t('Kopie'), bcc: t('Blindkopie'),
10824            subject: t('Betreff'), betreff: t('Betreff'),
10825            body: t('Nachricht'), text: t('Nachricht'), inhalt: t('Nachricht')
10826        };
10827
10828        /* Der Badge in der Kopfzeile traegt das Label bereits — hier
10829           steht nur noch, WOHER der Vorgang stammt. (Im ersten Entwurf
10830           stand "E-Mail senden" zweimal untereinander.) */
10831        const kopf = v.quelle
10832            ? '<div class="small text-muted mb-2">' + t('aus dem') + ' '
10833              + esc(v.quelle) + '</div>'
10834            : '';
10835
10836        const zeilen = [];
10837        const gesehen = {};
10838        /* Erst die bekannten Felder in sinnvoller Reihenfolge ... */
10839        ['to', 'empfaenger', 'cc', 'bcc', 'subject', 'betreff',
10840         'body', 'text', 'inhalt'].forEach(k => {
10841            if (a[k] === undefined || a[k] === null || a[k] === '') return;
10842            if (gesehen[BEKANNT[k]]) return;
10843            gesehen[BEKANNT[k]] = 1;
10844            zeilen.push(zeile(BEKANNT[k], a[k]));
10845        });
10846        /* ... dann alles Uebrige aus den Angaben ... */
10847        Object.keys(a).forEach(k => {
10848            if (BEKANNT[k] || NICHT[k]) return;
10849            zeilen.push(zeile(k, a[k]));
10850        });
10851        /* ... und zuletzt Felder ausserhalb von "angaben". "text" ist dort
10852           die Serialisierung derselben Angaben und wird nur gezeigt, wenn
10853           es wirklich etwas Eigenes enthaelt. */
10854        Object.keys(v).forEach(k => {
10855            if (k === 'angaben' || NICHT[k]) return;
10856            if (k === 'text' && zeilen.length) return;
10857            zeilen.push(zeile(BEKANNT[k] || k, v[k]));
10858        });
10859
10860        if (!zeilen.length) {
10861            return kopf + '<div class="text-muted small">'
10862                + t('(keine Vorschau hinterlegt)') + '</div>';
10863        }
10864        return kopf + '<table class="table table-sm table-borderless mb-0"><tbody>'
10865            + zeilen.join('') + '</tbody></table>';
10866    }
10867
10868    static async _entscheiden(id, entscheidung, begruendung) {
10869        let r;
10870        try {
10871            r = await api('/api/ai-team/approvals/' + id + '/decide', 'POST',
10872                { entscheidung: entscheidung, begruendung: begruendung || '' });
10873        } catch (e) { return this._modalFehler(e); }
10874        this._modal('');
10875        /* freigabe-fehlschlag-v1: Das Ergebnis der Ausfuehrung wurde hier
10876           verworfen - eine gescheiterte Freigabe sah aus wie eine
10877           erfolgreiche. */
10878        const aus = (r || {}).ausfuehrung || {};
10879        const fehlschlag = (entscheidung === 'freigegeben' && aus.ausgefuehrt === false)
10880            ? t('Freigegeben, aber nicht ausgeführt') + ': ' + (aus.grund || t('unbekannter Grund'))
10881            : '';
10882        this._akteId = null;
10883        // ai-team-stand-v1: Zahlen NACH der Entscheidung neu holen. Vorher
10884        // wurde nur neu gezeichnet - das Handlungsband auf dem Board las
10885        // weiter den alten Zwischenspeicher und zeigte entschiedene
10886        // Freigaben als wartend an.
10887        await this._neuLaden('freigaben');
10888        if (fehlschlag) this._modalFehler(new Error(fehlschlag));
10889    }
10890
10891    static _ablehnenFragen(id) {
10892        this._modal('<div class="modal-header"><h5 class="modal-title">' + t('Ablehnen — mit Grund') + '</h5>'
10893            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
10894            + '<div class="modal-body">'
10895            + '<p class="small text-muted">' + t('Der Grund wandert ins Gedächtnis des Mitarbeiters — so lernt er, was Sie nicht wollen.') + '</p>'
10896            + '<textarea id="aiAblGrund" class="form-control" rows="3" maxlength="1000" '
10897            + 'placeholder="' + esc(t('z. B.: Keine Rabatte anbieten')) + '"></textarea>'
10898            + '<div class="d-flex justify-content-end gap-2 mt-2">'
10899            + '<button class="btn btn-outline-secondary" data-action="modal-zu">' + t('Abbrechen') + '</button>'
10900            + '<button class="btn btn-danger" data-action="ablehnen-senden" data-id="' + id + '">'
10901            + t('Ablehnen') + '</button></div></div>');
10902    }
10903
10904    static async _ablehnenSenden(id) {
10905        const g = document.getElementById('aiAblGrund');
10906        const grund = g ? g.value.trim() : '';
10907        if (!grund) {
10908            if (g) { g.classList.add('is-invalid'); g.focus(); }
10909            return;
10910        }
10911        await this._entscheiden(id, 'abgelehnt', grund);
10912    }
10913
10914    // ---------------------------------------------------------- Aktivitaet
10915
10916    /* ==================================================================
10917       ai-team-gfx-v1 — REITER AKTIVITAET
10918
10919       Michaels Befund: grafisch nicht geloest. Gemessen war es ein
10920       Mengenproblem: 173 Handlungen in EINEM Strom, 14.662 px hoch
10921       (16 Bildschirme), ohne Tagestrenner, ohne Filter, und der Status
10922       — die einzige Information, nach der man sucht — stand als graues
10923       Abzeichen am rechten Rand, gleich stark fuer "erledigt" wie fuer
10924       "nicht moeglich".
10925
10926       Vier Eingriffe, in dieser Reihenfolge wirksam:
10927         1. Tagesgruppen. Der juengste Tag offen, aeltere zugeklappt.
10928            Damit ist die Seite kurz, OHNE dass etwas verschwiegen wird
10929            (jede Gruppe nennt ihre Anzahl im Kopf).
10930         2. Farbe an den Status, mit Symbol UND Wort.
10931         3. Kennzahlen, die zugleich der Filter sind.
10932         4. Ergebnistext auf zwei Zeilen. Er ist im Schnitt 240 und
10933            im laengsten Fall 2.768 Zeichen lang und hat die Kopfzeile
10934            — wer, was, wann — nach unten gedrueckt.
10935       ================================================================== */
10936
10937    /** Status -> Anzeige. Zwei technische Werte ("ausgefuehrt" und
10938     *  "erledigt") meinen dasselbe; gefiltert wird deshalb ueber die
10939     *  GRUPPE, nicht ueber den Rohwert — sonst zeigt ein Filter
10940     *  "erledigt" nur die Haelfte. */
10941    static get AKT_GRUPPEN() {
10942        return {
10943            erledigt:  { text: 'erledigt',        farbe: 'var(--aiv-good)',    icon: 'fa-check',
10944                         hat: ['ausgefuehrt', 'erledigt'] },
10945            wartet:    { text: 'wartet auf Sie',  farbe: 'var(--aiv-info)',    icon: 'fa-hand-point-right',
10946                         hat: ['entwurf', 'wartet_freigabe'] },
10947            blockiert: { text: 'nicht möglich',   farbe: 'var(--aiv-warn)',    icon: 'fa-circle-exclamation',
10948                         hat: ['blockiert', 'abgebrochen'] },
10949            abgelehnt: { text: 'abgelehnt',       farbe: 'var(--aiv-neutral)', icon: 'fa-ban',
10950                         hat: ['abgelehnt'] },
10951            offen:     { text: 'läuft',           farbe: 'var(--aiv-neutral)', icon: 'fa-hourglass-half',
10952                         hat: ['offen', 'in_arbeit'] }
10953        };
10954    }
10955
10956    static _aktGruppe(status) {
10957        const s = String(status || '').toLowerCase();
10958        const G = this.AKT_GRUPPEN;
10959        for (const k of Object.keys(G)) { if (G[k].hat.indexOf(s) >= 0) { return k; } }
10960        return 'offen';
10961    }
10962
10963    static async _renderAktivitaet(host) {
10964        const seq = this._seq;
10965        host.innerHTML = '<div class="text-muted">' + t('Lade …') + '</div>';
10966        let d;
10967        try { d = await api('/api/ai-team/activity'); }
10968        catch (e) {
10969            if (seq === this._seq) host.innerHTML = '<div class="alert alert-danger">' + esc(e.message) + '</div>';
10970            return;
10971        }
10972        if (seq !== this._seq) return;
10973        const liste = d.activity || [];
10974        if (!liste.length) {
10975            host.innerHTML = '<div class="alert alert-light border">'
10976                + t('Noch keine Aktivität. Hier steht später jede einzelne Handlung jedes Mitarbeiter
10976s — es kann nichts berichtet werden, was hier nicht steht.')
10977                + '</div>';
10978            return;
10979        }
10980        host.innerHTML = (d.gekappt ? '<div class="alert alert-warning small">'
10981            + t('Anzeige gekürzt — ältere Einträge werden nicht angezeigt.') + '</div>' : '')
10982            + this._aktKopf(liste)
10983            + this._aktGruppiert(this._aktGefiltert(liste), liste.length);
10984        this._aktSucheBinden(host);
10985        this._aktGruppenBinden(host);
10986        this._aktKappung(host);
10987        /* Webschriften koennen NACH dem ersten Aufbau eintreffen und
10988           brechen den Text dann anders um. Ohne dieses Nachmessen
10989           beschreibt die Kappung einen Zustand, den es nicht mehr gibt. */
10990        if (document.fonts && document.fonts.ready) {
10991            document.fonts.ready.then(() => {
10992                try { this._aktKappung(host); } catch (e) { /* nur Darstellung */ }
10993            });
10994        }
10995    }
10996
10997    /** Kennzahlen + Filterzeile. Die Kennzahlen zeigen IMMER den
10998     *  Gesamtbestand, auch wenn gefiltert ist — sonst kaeme man aus
10999     *  einem Filter nicht mehr zurueck, weil die anderen Zahlen auf
11000     *  Null faelen und wie "gibt es nicht" aussaehen. */
11001    static _aktKopf(liste) {
11002        const f = this._aktF || (this._aktF = { status: '', wer: '', q: '' });
11003        const G = this.AKT_GRUPPEN;
11004        const zahl = {};
11005        const leute = [];
11006        liste.forEach(a => {
11007            const g = this._aktGruppe(a.status);
11008            zahl[g] = (zahl[g] || 0) + 1;
11009            const n = a.mitarbeiter || '';
11010            if (n && leute.indexOf(n) < 0) { leute.push(n); }
11011        });
11012        leute.sort();
11013
11014        const kachel = (schluessel, wert, label, farbe) =>
11015            '<button class="aiv-kpi' + (f.status === schluessel ? ' is-an' : '') + '"'
11016            + ' style="--aiv-akzent:' + farbe + '"'
11017            + ' data-action="akt-status" data-id="' + esc(schluessel) + '"'
11018            + ' title="' + t('Nach diesem Zustand filtern') + '">'
11019            + '<div class="aiv-kpi-wert">' + esc(String(wert)) + '</div>'
11020            + '<div class="aiv-kpi-lab">' + esc(label) + '</div></button>';
11021
11022        // "Alle" ist keine Kachel mit Farbe, sondern der Ruecksprung.
11023        let kacheln = '<button class="aiv-kpi' + (f.status ? '' : ' is-an') + '"'
11024            + ' data-action="akt-status" data-id=""'
11025            + ' style="--aiv-akzent:var(--v2-text-muted, #8a97a8)">'
11026            + '<div class="aiv-kpi-wert">' + esc(String(liste.length)) + '</div>'
11027            + '<div class="aiv-kpi-lab">' + t('Handlungen gesamt') + '</div></button>';
11028        // Reihenfolge nach Dringlichkeit fuer den Inhaber, nicht nach Menge.
11029        ['wartet', 'blockiert', 'erledigt', 'abgelehnt', 'offen'].forEach(k => {
11030            if (zahl[k]) { kacheln += kachel(k, zahl[k], t(G[k].text), G[k].farbe); }
11031        });
11032
11033        const chips = leute.length > 1
11034            ? '<span class="aiv-chip' + (f.wer ? '' : ' is-an') + '" role="button"'
11035              + ' data-action="akt-wer" data-id="">' + t('Alle') + '</span>'
11036              + leute.map(n => '<span class="aiv-chip' + (f.wer === n ? ' is-an' : '') + '"'
11037                  + ' role="button" data-action="akt-wer" data-id="' + esc(n) + '">'
11038                  + esc(n) + '</span>').join('')
11039            : '';
11040
11041        return '<div class="aiv-kpis">' + kacheln + '</div>'
11042            + '<div class="aiv-filter">' + chips
11043            + '<input type="search" class="aiv-suche" data-aiv-suche="1"'
11044            + ' placeholder="' + t('In der Aktivität suchen …') + '"'
11045            + ' value="' + esc(f.q || '') + '">'
11046            + '<span class="aiv-zaehler" data-aiv-zaehler="1"></span></div>';
11047    }
11048
11049    static _aktGefiltert(liste) {
11050        const f = this._aktF || {};
11051        return liste.filter(a =>
11052            (!f.status || this._aktGruppe(a.status) === f.status)
11053            && (!f.wer || (a.mitarbeiter || '') === f.wer));
11054    }
11055
11056    /** Nach Tag gruppiert. Der juengste Tag ist offen, aeltere sind
11057     *  zugeklappt — aber MIT Anzahl im Kopf, damit Zuklappen nicht wie
11058     *  Fehlen aussieht. Ist ein Filter gesetzt, sind alle Gruppen offen:
11059     *  wer nach "nicht moeglich" sucht, will sie nicht einzeln
11060     *  aufklappen muessen. */
11061    static _aktGruppiert(liste, gesamt) {
11062        const f = this._aktF || {};
11063        const gefiltert = !!(f.status || f.wer);
11064        if (!liste.length) {
11065            return '<div class="alert alert-light border small mb-0">'
11066                + t('Keine Handlung passt zu dieser Auswahl.')
11067                + ' <span data-action="akt-status" data-id="" role="button"'
11068                + ' class="text-decoration-underline" style="cursor:pointer">'
11069                + t('Auswahl aufheben') + '</span></div>';
11070        }
11071        const gruppen = [];
11072        const nach = {};
11073        liste.forEach(a => {
11074            const tag = String(a.created_at || '').slice(0, 10);
11075            if (!nach[tag]) { nach[tag] = []; gruppen.push(tag); }
11076            nach[tag].push(a);
11077        });
11078        return gruppen.map((tag, idx) => {
11079            const auf = gefiltert || idx === 0;
11080            return '<details class="aiv-taggruppe"' + (auf ? ' open' : '') + '>'
11081                + '<summary><span class="aiv-tag-name">' + esc(this._tagName(tag)) + '</span>'
11082                + '<span class="aiv-tagzahl">' + esc(String(nach[tag].length)) + ' '
11083                + t(nach[tag].length === 1 ? 'Handlung' : 'Handlungen') + '</span></summary>'
11084                + '<div class="aiv-tagkoerper">' + this._aktListe(nach[tag], false) + '</div>'
11085                + '</details>';
11086        }).join('')
11087        + (gesamt && liste.length !== gesamt
11088            ? '<div class="text-muted small mt-2">' + esc(String(liste.length)) + ' '
11089              + t('von') + ' ' + esc(String(gesamt)) + ' ' + t('Handlungen angezeigt.') + '</div>'
11090            : '');
11091    }
11092
11093    /** "Heute" / "Gestern" / "Sonntag, 16.8." — ein Datum allein sagt
11094     *  einem Menschen nicht, ob es gestern war. */
11095    static _tagName(tag) {
11096        const p = String(tag || '').split('-');
11097        if (p.length !== 3) { return tag || ''; }
11098        const d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
11099        if (isNaN(d.getTime())) { return tag; }
11100        const heute = new Date();
11101        const tage = Math.round((new Date(heute.getFullYear(), heute.getMonth(), heute.getDate())
11102            - new Date(d.getFullYear(), d.getMonth(), d.getDate())) / 86400000);
11103        if (tage === 0) { return t('Heute'); }
11104        if (tage === 1) { return t('Gestern'); }
11105        return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' });
11106    }
11107
11108    // ai-team-akttab-v1 — Zeit kompakt. Die volle Form ("10.8.2026, 21:22:30") war in
11109    // schmalen Behaeltern der Hauptverbraucher und drueckte die Spalte auf
11110    // Minimalbreite, bis die Kopfzeile buchstabenweise umbrach.
11111    static _zeitKurz(iso) {
11112        if (!iso) return '';
11113        const d = new Date(iso);
11114        if (isNaN(d.getTime())) return '';
11115        const j = new Date();
11116        const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
11117        const heute = d.getFullYear() === j.getFullYear() && d.getMonth() === j.getMonth()
11118            && d.getDate() === j.getDate();
11119        return heute ? uhr
11120            : d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ' ' + uhr;
11121    }
11122
11123    static _zeitVoll(iso) {
11124        if (!iso) return '';
11125        const d = new Date(iso);
11126        return isNaN(d.getTime()) ? '' : d.toLocaleString('de-DE');
11127    }
11128
11129    /** Suche filtert am ELEMENTBAUM, nicht ueber einen Neuaufbau:
11130     *  ein Neuaufbau bei jedem Tastendruck kostet den Eingabefokus.
11131     *  Der Suchtext bleibt in this._aktF.q, damit ein Klick auf eine
11132     *  Kachel ihn nicht verliert. */
11133    static _aktSucheBinden(host) {
11134        const feld = host.querySelector('[data-aiv-suche]');
11135        if (!feld) { return; }
11136        const lauf = () => {
11137            const q = (feld.value || '').trim().toLowerCase();
11138            (this._aktF || (this._aktF = {})).q = feld.value || '';
11139            let sicht = 0, alle = 0;
11140            host.querySelectorAll('.aiv-zeile').forEach(z => {
11141                alle++;
11142                const treffer = !q || (z.getAttribute('data-such') || '').indexOf(q) >= 0;
11143                z.style.display = treffer ? '' : 'none';
11144                if (treffer) { sicht++; }
11145            });
11146            // Eine Tagesgruppe ohne Treffer verschwindet ganz — sonst
11147            // bleiben leere Ueberschriften stehen und sehen wie Fehler aus.
11148            host.querySelectorAll('.aiv-taggruppe').forEach(g => {
11149                const drin = g.querySelectorAll('.aiv-zeile:not([style*="none"])').length;
11150                g.style.display = drin ? '' : 'none';
11151                if (q && drin) { g.setAttribute('open', 'open'); }
11152            });
11153            const z = host.querySelector('[data-aiv-zaehler]');
11154            if (z) {
11155                z.textContent = q ? (sicht + ' ' + t('von') + ' ' + alle + ' ' + t('sichtbar')) : '';
11156            }
11157        };
11158        feld.addEventListener('input', lauf);
11159        if (feld.value) { lauf(); }
11160    }
11161
11162    /** Blendet "mehr anzeigen" dort aus, wo nichts abgeschnitten ist.
11163     *  GEMESSEN statt geraten: die Textlaenge sagt nichts darueber, ob
11164     *  der Umbruch in dieser Spaltenbreite ueber zwei Zeilen geht.
11165     *  VORSICHT: in einer zugeklappten Gruppe ist die Hoehe 0 — dort
11166     *  darf NICHT entschieden werden, sonst verschwindet der Knopf an
11167     *  genau den Texten, die ihn brauchen. Deshalb die Vorbedingung
11168     *  clientHeight > 0 und ein Nachmessen beim Aufklappen.
11169     *  Faellt diese Messung ganz aus, bleibt ein ueberfluessiger Knopf
11170     *  stehen — das ist der harmlose Fehler, ein fehlender waere der
11171     *  teure (Text nicht mehr lesbar). */
11172    static _aktKappung(host) {
11173        host.querySelectorAll('.aiv-zeile').forEach(z => {
11174            const txt = z.querySelector('.aiv-erg');
11175            const knopf = z.querySelector('.aiv-mehr');
11176            if (!txt || !knopf || !txt.classList.contains('is-kurz')) { return; }
11177            /* ZUGEKLAPPTE GRUPPE NIE MESSEN. Chrome versteckt den Inhalt
11178               eines geschlossenen <details> ueber content-visibility und
11179               NICHT ueber display:none — die Elemente haben also sehr wohl
11180               eine Hoehe, nur eine aus einem uebersprungenen Layout. Die
11181               Messung sah gueltig aus und war falsch: drei Eintraege
11182               verloren ihren Knopf, obwohl ihr Text abgeschnitten war.
11183               Deshalb entscheidet hier NICHT die gemessene Hoehe, ob
11184               gemessen werden darf, sondern der offene Zustand — und der
11185               toggle-Horcher misst beim Aufklappen nach. */
11186            const grp = z.closest('details.aiv-taggruppe');
11187            if (grp && !grp.open) { return; }
11188            if (!txt.clientHeight) { return; }
11189            knopf.style.display = (txt.scrollHeight > txt.clientHeight + 2) ? '' : 'none';
11190        });
11191    }
11192
11193    /** 'toggle' steigt nicht auf, muss also je Gruppe gebunden werden. */
11194    static _aktGruppenBinden(host) {
11195        host.querySelectorAll('details.aiv-taggruppe').forEach(g => {
11196            g.addEventListener('toggle', () => {
11197                if (g.open) { this._aktKappung(host); }
11198            });
11199        });
11200    }
11201
11202    // Aufrufer aus der Personalakte (dort ist der Mitarbeitername
11203    // redundant, deshalb kompakt=true).
11204    static _aktTabelle(liste, kompakt) {
11205        return this._aktListe(liste, !!kompakt);
11206    }
11207
11208    static _aktListe(liste, ohneName) {
11209        const G = this.AKT_GRUPPEN;
11210        return '<div class="aiv-strang" data-akt-liste="1">'
11211            + liste.map(a => {
11212                const g = G[this._aktGruppe(a.status)];
11213                const erg = a.ergebnis || '';
11214                // Suchtext deckt alles ab, was in der Zeile steht.
11215                const such = ((a.mitarbeiter || '') + ' ' + (a.tool_text || a.tool || '')
11216                    + ' ' + (a.status_text || a.status || '') + ' ' + erg).toLowerCase();
11217                return '<div class="aiv-zeile" data-such="' + esc(such) + '">'
11218                    + '<span class="aiv-punkt" style="--aiv-akzent:' + g.farbe + '"></span>'
11219                    + '<div class="aiv-kopf">'
11220                    + '<span class="aiv-uhr" title="' + esc(this._zeitVoll(a.created_at)) + '">'
11221                    + esc(this._zeitKurz(a.created_at)) + '</span>'
11222                    + (ohneName ? ''
11223                        : '<span class="aiv-wer">' + esc(a.mitarbeiter || '') + '</span>')
11224                    // ai-team-bugfix-20260812 (B4): lesbare Namen aus dem Backend
11225                    // (tool_text/status_text). Fehlen sie, bleibt der technische
11226                    // Name sichtbar, statt dass die Zeile leer wird.
11227                    + '<span class="aiv-was">' + esc(a.tool_text || a.tool) + '</span>'
11228                    + '<span class="aiv-marke" style="--aiv-akzent:' + g.farbe + '">'
11229                    + '<i class="fas ' + g.icon + '"></i>'
11230                    + esc(a.status_text || a.status) + '</span></div>'
11231                    + (erg
11232                        ? '<div class="aiv-erg is-kurz">' + esc(erg) + '</div>'
11233                          + '<button class="aiv-mehr" data-action="akt-mehr">'
11234                            + t('mehr anzeigen') + '</button>'
11235                        : '')
11236                    + '</div>';
11237            }).join('')
11238            + '</div>';
11239    }
11240
11241    // ---------------------------------------------------------- Berichte
11242
11243    /* ================================================ aiteam-nutzung-v1
11244       VERBRAUCH — Michaels Vorgabe 17.08.: "wir müssen die Nutzung sehen
11245       können damit wir wenn die Token aufgebraucht sind reagieren können".
11246
11247       ZWEI QUELLEN, BEWUSST NEBENEINANDER:
11248         Unsere Messung   wie viel gearbeitet wurde (Läufe, Werkzeuge, Wert)
11249         Das Kontingent   was die Subscription selbst meldet
11250       Die erste sagt "wie viel", die zweite sagt "geht noch". Keine
11251       ersetzt die andere, deshalb stehen beide da.
11252
11253       FORM (nach der Hausmethode für Datendarstellung):
11254         - Kontingent = ZUSTAND -> Band mit Symbol UND Text, nie Farbe allein
11255         - Tageszahlen = Kennzahlen -> Kachelreihe, keine Ein-Balken-Grafik
11256         - Verhältnis zu einer ECHTEN Grenze -> Meter (Tagesdeckel gibt es
11257           wirklich; ohne Grenze wäre ein Meter eine erfundene Skala)
11258         - Verlauf = Größenvergleich über Tage -> Balken in EINER Farbe,
11259           kein Regenbogen, keine Legende bei einer Reihe
11260         - Zahlen tragen Text-Farben, nicht die Balkenfarbe                */
11261
11262    static _nzZahl(n, k) {
11263        return Number(n || 0).toLocaleString('de-DE',
11264            { minimumFractionDigits: k || 0, maximumFractionDigits: k || 0 });
11265    }
11266
11267    /* aiteam-token-v1: Token-Zahlen werden gross. In Kacheln und
11268       Meter deshalb verkuerzt (1,3 Mio), in der Tabelle immer voll —
11269       eine Tabelle ist zum Nachrechnen da, eine Kachel zum Erfassen. */
11270    static _nzTok(n) {
11271        const v = Number(n || 0);
11272        if (v >= 1000000) {
11273            return this._nzZahl(v / 1000000, v >= 100000000 ? 0 : 1) + ' Mio';
11274        }
11275        return this._nzZahl(v, 0);
11276    }
11277
11278    static _nzTokVoll(n) {
11279        return this._nzZahl(n, 0);
11280    }
11281
11282    /* Vor der Umstellung am 17.08.2026 wurden Laeufe gezaehlt, aber
11283       keine Token. Solche Tage duerfen NICHT als "0 Token" gelesen
11284       werden — das waere eine Falschaussage ueber den Verbrauch. */
11285    static _nzUngezaehlt(x) {
11286        return Number(x && x.laeufe || 0) > 0
11287            && Number(x && x.gesamt || 0) === 0;
11288    }
11289
11290    static _nzTag(iso) {
11291        const d = new Date(String(iso) + 'T12:00:00');
11292        return isNaN(d.getTime()) ? esc(String(iso || ''))
11293            : d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
11294    }
11295
11296    /* Meter: Füllung trägt die Schwere, die leere Spur ist ein hellerer
11297       Schritt derselben Farbe — der Zustand liest sich über den ganzen
11298       Balken. */
11299    static _nzMeter(anteil, warn) {
11300        const p = Math.max(0, Math.min(1, Number(anteil) || 0));
11301        const f = p >= 1 ? '#b45309' : (p >= (warn || 0.8) ? '#c2870a' : '#2e6b94');
11302        return '<div style="height:8px;border-radius:4px;background:#e3ecf3;'
11303             + 'overflow:hidden"><div style="height:8px;width:'
11304             + (p * 100).toFixed(1) + '%;background:' + f + ';border-radius:4px">'
11305             + '</div></div>';
11306    }
11307
11308    static _nzKachel(titel, wert, unten) {
11309        return '<div class="col-6 col-lg-3"><div class="card border-0 shadow-sm h-100">'
11310             + '<div class="card-body py-3">'
11311             + '<div class="text-muted small text-uppercase">' + esc(titel) + '</div>'
11312             + '<div class="fs-4 fw-semibold">' + wert + '</div>'
11313             + (unten ? '<div class="text-muted small">' + unten + '</div>' : '')
11314             + '</div></div></div>';
11315    }
11316
11317    static _nzKontingent(k, quelle) {
11318        const gemessen = k && k.gemessen;
11319        const ok = !k || k.ok;
11320        const art = ok ? (gemessen ? 'success' : 'secondary') : 'danger';
11321        const sym = ok ? (gemessen ? 'fa-circle-check' : 'fa-circle-question')
11322                       : 'fa-triangle-exclamation';
11323        let txt = esc((k && k.text) || t('noch nicht gemessen'));
11324        if (!gemessen) {
11325            /* Ehrlich bleiben: kein Ereignis heißt NICHT "alles gut" —
11326               es heißt, dass sich die Subscription noch nicht gemeldet
11327               hat. Sie meldet sich erst, wenn es etwas zu melden gibt. */
11328            txt = t('Die Subscription hat sich bisher nicht gemeldet. Sie meldet sich erst, wenn das Kontingent knapp wird — bis dahin ist das der Normalfall.');
11329        }
11330        const wann = (k && k.gesehen_at)
11331            ? '<div class="small text-muted mt-1">' + t('zuletzt gesehen') + ': '
11332              + esc(new Date(k.gesehen_at).toLocaleString('de-DE')) + '</div>' : '';
11333        const reset = (k && k.resets_at && !ok)
11334            ? '<div class="small mt-1">' + t('Setzt sich zurück am') + ' '
11335              + esc(new Date(k.resets_at).toLocaleString('de-DE')) + '</div>' : '';
11336        const grund = (k && k.grund && !ok)
11337            ? '<div class="small mt-1">' + t('Grund') + ': ' + esc(k.grund) + '</div>' : '';
11338        return '<div class="alert alert-' + art + ' d-flex align-items-start">'
11339             + '<i class="fas ' + sym + ' me-2 mt-1"></i><div>'
11340             + '<strong>' + t('Kontingent') + '</strong> — ' + txt + reset + grund + wann
11341             /* aiteam-mittel-v1: Der Subscription-Satz ist entfernt - er
11342                sagte im Normalfall nur das Erwartbare. Die UMGEKEHRTE
11343                Lage bleibt sichtbar, weil sie eine echte Warnung ist. */
11344             + (quelle === 'subscription' ? ''
11345                 : '<div class="small text-muted mt-1">'
11346                   + t('Achtung: Das KI-Team läuft gerade über die API, nicht über Ihre Subscription.')
11347                   + '</div>')
11348             + '</div></div>';
11349    }
11350
11351    /* ==================== kiteam-oberflaeche-v1 =========================
11352       Der Kaufweg. Fuenf Routen bestanden seit dem Vormittag, aber keine
11353       war verlinkt - es gab schlicht keinen Knopf. Diese Ansicht bedient
11354       drei Lagen mit demselben Aufbau: noch nichts gebucht, gebucht und
11355       passend, gebucht und die Mannschaft hat sich geaendert.
11356
11357       Meldungen laufen ueber _modal/_modalFehler wie im Rest der Datei.
11358       _hinweis() waere falsch: es schreibt in #aiChatTitelHinweis, ein
11359       Element, das es nur im Gespraech gibt - hier bliebe es wirkungslos.
11360       ==================================================================== */
11361
11362    static _abrEuro(betrag) {
11363        const n = Number(betrag || 0);
11364        return n.toLocaleString('de-DE', { minimumFractionDigits: 2,
11365                                           maximumFractionDigits: 2 }) + ' €';
11366    }
11367
11368    static _abrKachel(titel, wert, fuss) {
11369        return '<div class="col-sm-4"><div class="card border-0 shadow-sm h-100">'
11370             + '<div class="card-body py-3">'
11371             + '<div class="small text-muted">' + esc(titel) + '</div>'
11372             + '<div class="fs-4 fw-semibold">' + esc(wert) + '</div>'
11373             + (fuss ? '<div class="small text-muted">' + esc(fuss) + '</div>' : '')
11374             + '</div></div></div>';
11375    }
11376
11377    static async _renderAbrechnung(host) {
11378        /* kiteam-recht-v1: auch der Kauffall (_keinAbo) rendert hierher -
11379           ein Mitarbeiter bekaeme sonst Kauf-Knoepfe, die alle in das
11380           403 kiteam_nur_inhaber laufen. */
11381        if (AiTeam._istMitarbeiter()) {
11382            host.innerHTML = '<div class="alert alert-info">'
11383                + t('Die Abrechnung des KI-Teams kann nur der Kontoinhaber einsehen und \u00e4ndern.')
11384                + '</div>';
11385            return;
11386        }
11387        const seq = this._seq;
11388        if (!this._abr) {
11389            host.innerHTML = '<div class="text-muted">' + t('Lade …') + '</div>';
11390            try { this._abr = await api('/api/ai-team/abrechnung'); }
11391            catch (e) {
11392                if (seq !== this._seq) return;
11393                host.innerHTML = '<div class="alert alert-danger">'
11394                    + esc(e.message) + '</div>';
11395                return;
11396            }
11397        }
11398        if (seq !== this._seq) return;
11399
11400        const d = this._abr || {};
11401        const abo = d.abo || null;
11402        const lage = d.abo_lage || 'keins';
11403        const anzahl = Number(d.anzahl || 0);
11404        const brutto = Number(d.brutto_cent || 0) / 100;
11405        /* kiteam-buchungsbugs-v1 (B1): Zwei Sichten auf dieselbe Mannschaft.
11406           'anzahl' ist, was HEUTE berechnet wird - waehrend der Probezeit
11407           also 0. 'kauf' zaehlt die Probezeitler mit, denn gebucht werden
11408           muessen ihre Arbeitsplaetze trotzdem;
11408 kostenlos bleiben sie ueber
11409           die Testphase bei Stripe. Ohne diese Trennung stand hier waehrend
11410           der ganzen Probezeit '0 KI-Mitarbeiter eingestellt', und der
11411           Kauf-Knopf war gesperrt. */
11412        const kauf = d.kauf || {};
11413        const kaufAnzahl = Number(kauf.anzahl || 0);
11414        const kaufBrutto = Number(kauf.brutto_cent || 0) / 100;
11415        const pz = d.probezeit || {};
11416        const pzAnz = Number(pz.anzahl || 0);
11417
11418        let html = '';
11419
11420        if (this._keinAbo) {
11421            html += '<div class="d-flex align-items-center mb-3">'
11422                 + '<h4 class="mb-0"><i class="fas fa-users-gear me-2"></i>'
11423                 + t('Mein KI-Team') + '</h4></div>';
11424        }
11425
11426        /* Einmalige Rueckmeldung nach einer Aenderung. Wird beim Anzeigen
11427           verbraucht, damit sie nicht bei jedem Neuzeichnen wieder
11428           auftaucht. */
11429        if (this._abrMeldung) {
11430            html += '<div class="alert alert-' + esc(this._abrMeldung.art) + '">'
11431                 + esc(this._abrMeldung.text) + '</div>';
11432            this._abrMeldung = null;
11433        }
11434
11435        /* kiteam-buchungsbugs-v1 (B2): Rueckkehr von der Bezahlseite.
11436           Bis hierher wurde der Parameter 'abo=ok' NIRGENDS ausgewertet:
11437           der Kunde zahlte, kam zurueck und sah unveraendert 'Noch nicht
11438           gebucht' samt Kauf-Knopf, weil unsere Tabelle erst der Webhook
11439           fuellt. Genau so sind am 13.09. sieben Abos fuer denselben
11440           Kunden entstanden. Jetzt wird gewartet und nachgeladen, statt
11441           zum zweiten Kauf einzuladen. */
11442        if (this._abrZahlung === undefined) {
11443            try {
11444                this._abrZahlung = new URLSearchParams(window.location.search)
11445                    .get('abo') === 'ok';
11446            } catch (e) { this._abrZahlung = false; }
11447            this._abrWarte = 0;
11448        }
11449        if (this._abrZahlung && lage === 'keins') {
11450            html += '<div class="alert alert-info">'
11451                 + '<span class="spinner-border spinner-border-sm me-2"></span>'
11452                 + '<strong>' + t('Vielen Dank – Ihre Buchung wird verarbeitet.')
11453                 + '</strong> '
11454                 + t('Das dauert in der Regel nur wenige Sekunden. Bitte buchen Sie nicht erneut.')
11455                 + '</div>';
11456            /* Hoechstens fuenf Versuche. Kommt der Webhook nicht, ist das
11457               ein Fall fuer uns und nicht fuer eine Endlosschleife. */
11458            if (this._abrWarte < 5) {
11459                this._abrWarte += 1;
11460                setTimeout(() => {
11461                    if (this._tab !== 'abrechnung' && !this._keinAbo) return;
11462                    this._abr = null;
11463                    this.show();
11464                }, 3000);
11465            } else {
11466                this._abrZahlung = false;
11467            }
11468        } else if (this._abrZahlung && lage === 'vorhanden') {
11469            this._abrZahlung = false;
11470            html += '<div class="alert alert-success">'
11471                 + '<i class="fas fa-circle-check me-2"></i>'
11472                 + '<strong>' + t('Ihre Arbeitsplätze sind gebucht.') + '</strong> '
11473                 + t('Das Team kann loslegen.') + '</div>';
11474        }
11475
11476        /* Der Hinweis oben sagt, WAS als Naechstes zu tun ist - nicht nur,
11477           was gerade der Fall ist. */
11478        if (lage === 'unbekannt') {
11479            html += '<div class="alert alert-warning">'
11480                 + t('Der Abo-Stand ist gerade nicht abrufbar. Bitte laden Sie die Seite in einigen Minuten neu — es wurde nichts geändert.')
11481                 + '</div>';
11482        } else if (lage === 'keins' && !this._abrZahlung) {
11483            /* kiteam-buchungsbugs-v1 (B1): Gemessen wird an der KAUFsicht.
11484               Vorher hing der Satz an 'anzahl' - und der ist waehrend der
11485               Probezeit 0, also stand dort 'Stellen Sie zuerst KI-Mitarbeiter
11486               ein', obwohl direkt darunter '1 Mitarbeiter ist in der
11487               kostenlosen Probezeit' zu lesen war. Zwei Saetze, die
11488               einander widersprechen, kosten uns einen Anruf. */
11489            html += '<div class="alert alert-primary">'
11490                 + '<strong>' + t('Noch nicht gebucht.') + '</strong> '
11491                 + (kaufAnzahl > 0
11492                     ? t('Ihre Mannschaft steht bereits — buchen Sie die Arbeitsplätze, dann kann das Team loslegen.')
11493                     : t('Stellen Sie zuerst einen KI-Mitarbeiter ein. Danach können Sie die Arbeitsplätze hier buchen.'))
11494                 /* kiteam-zirkular-v1: DER KNOPF IST DER EIGENTLICHE FIX.
11495                    Ohne Abo sieht der Inhaber ausschliesslich diese Seite -
11496                    die Reiterleiste ist ausgeblendet, das Board unerreichbar.
11497                    Der Satz "Stellen Sie zuerst jemanden ein" war damit eine
11498                    Aufforderung ohne Weg: die einzige Stelle, an der der
11499                    Assistent sonst startet, liegt auf dem Board. */
11500                 + (kaufAnzahl > 0 ? ''
11501                     : '<div class="mt-2"><button class="btn btn-primary btn-sm" '
11502                       + 'data-action="wizard"><i class="fas fa-user-plus me-1"></i>'
11503                       + t('Ersten KI-Mitarbeiter einstellen') + '</button></div>')
11504                 + '</div>';
11505        } else if (abo && Number(abo.anzahl || 0) !== anzahl) {
11506            html += '<div class="alert alert-warning">'
11507                 + '<strong>' + t('Ihre Mannschaft hat sich geändert.') + '</strong> '
11508                 + t('Gebucht sind {a} Arbeitsplätze, eingestellt sind {b}. Passen Sie die Anzahl an, damit die Abrechnung stimmt.')
11509                     .replace('{a}', esc(String(abo.anzahl)))
11510                     .replace('{b}', esc(String(anzahl)))
11511                 + '</div>';
11512        }
11513
11514        /* kiteam-buchungsbugs-v1 (B1): Die Kachel ist mit 'eingestellt'
11515           beschriftet, zeigte aber die BERECHENBARE Zahl - waehrend der
11516           Probezeit also 0 bei tatsaechlich vorhandener Mannschaft. Eine
11517           Kachel, die etwas anderes zaehlt als ihre Beschriftung sagt,
11518           ist ein Zahlenfehler fuer jeden, der sie liest. */
11519        html += '<div class="row g-3 mb-3">'
11520             + this._abrKachel(t('KI-Mitarbeiter'), String(kaufAnzahl || anzahl),
11521                               t('eingestellt'))
11522             + this._abrKachel(t('Kosten je Monat'), this._abrEuro(brutto),
11523                               pzAnz > 0 ? t('derzeit kostenlos') : t('inkl. MwSt.'))
11524             + this._abrKachel(t('Gebucht'),
11525                               abo ? String(abo.anzahl) : '—',
11526                               abo ? t('Arbeitsplätze') : t('noch kein Abo'))
11527             + '</div>';
11528
11529        /* Woraus sich der Betrag zusammensetzt. Eine blosse Endsumme laedt
11530           zur Rueckfrage ein, und die Preisstufe je Rolle ist ohnehin die
11531           Groesse, die der Inhaber steuern kann. */
11532        const sp = d.sitzplaetze || {};
11533        /* kiteam-probezeit-v1c: Die kostenlose Probezeit benennen. Sie
11534           fehlt in der Aufstellung, weil sie nichts kostet - genau das
11535           muss dastehen, sonst liest es sich als fehlender Platz.
11536           (pz/pzAnz stehen oben, weil die Kacheln sie schon brauchen.) */
11537        if (pzAnz > 0) {
11538            let bis = '';
11539            if (pz.naechstes_ende) {
11540                try {
11541                    bis = ' ' + t('Die erste endet am') + ' '
11542                        + new Date(pz.naechstes_ende).toLocaleDateString('de-DE');
11543                } catch (e) { bis = ''; }
11544            }
11545            /* kiteam-buchungsbugs-v1 (B1): MIT dem kuenftigen Betrag. Wer
11546               heute 0,00 EUR liest und in zwei Wochen eine Rechnung
11547               bekommt, fuehlt sich zu Recht ueberrumpelt - auch wenn beide
11548               Zahlen fuer sich genommen richtig sind. */
11549            let danach = '';
11550            if (kaufBrutto > 0) {
11551                danach = ' ' + t('Danach: {b} je Monat inkl. MwSt.')
11552                    .replace('{b}', this._abrEuro(kaufBrutto));
11553            }
11554            html += '<div class="alert alert-success py-2 px-3 mb-3">'
11555                 + '<i class="fas fa-gift me-2"></i>'
11556                 + esc(String(pzAnz)) + ' '
11557                 + t(pzAnz === 1 ? 'Mitarbeiter ist in der kostenlosen Probezeit'
11558                                 : 'Mitarbeiter sind in der kostenlosen Probezeit')
11559                 + ' ' + t('und noch nicht berechnet.') + esc(bis) + esc(danach)
11560                 + '</div>';
11561        }
11562        const stufen = Object.keys(sp).map(Number).sort((a, b) => a - b);
11563        if (stufen.length) {
11564            html += '<div class="card border-0 shadow-sm mb-3"><div class="card-body py-3">'
11565                 + '<div class="fw-semibold mb-2">' + t('Woraus sich der Betrag zusammensetzt') + '</div>'
11566                 + '<table class="table table-sm mb-0"><tbody>';
11567            stufen.forEach(s => {
11568                const a = Number(sp[String(s)] || 0);
11569                html += '<tr><td>' + esc(String(a)) + ' × '
11570                     + t('Arbeitsplatz') + ' ' + this._abrEuro(s) + '</td>'
11571                     + '<td class="text-end">' + this._abrEuro(s * a) + '</td></tr>';
11572            });
11573            html += '<tr class="fw-semibold"><td>' + t('Summe netto') + '</td>'
11574                 + '<td class="text-end">' + this._abrEuro(d.netto_eur) + '</td></tr>'
11575                 + '</tbody></table></div></div>';
11576        }
11577
11578        /* Ehrlich bleiben: was der Katalog nicht hergibt, darf nicht still
11579           unter den Tisch fallen - sonst stimmt die Summe nicht und
11580           niemand weiss warum. */
11581        const ohne = d.rollen_ohne_preis || [];
11582        if (ohne.length) {
11583            html += '<div class="alert alert-warning small">'
11584                 + t('Für diese Rollen ist kein Preis hinterlegt, sie fehlen in der Summe:')
11585                 + ' ' + esc(ohne.join(', ')) + '</div>';
11586        }
11587        const fehlend = d.fehlende_preise || [];
11588        if (fehlend.length) {
11589            html += '<div class="alert alert-danger small">'
11590                 + t('Für eine Preisstufe fehlt die Hinterlegung beim Zahlungsdienstleister. Buchen ist deshalb gerade nicht möglich — bitte melden Sie sich kurz bei uns.')
11591                 + '</div>';
11592        }
11593
11594        /* kiteam-buchungsbugs-v1 (B1): An der KAUFsicht gemessen. Vorher
11595           war der Knopf waehrend der gesamten Probezeit gesperrt - der
11596           Inhaber konnte also genau in den 14 Tagen nicht buchen, in
11597           denen er sich entscheidet. Am 15. Tag sperrt ihn der Riegel
11598           dann aus, bis er kauft. */
11599        const kaufFehlend = (kauf.fehlende_preise || fehlend);
11600        const kaufbar = !kaufFehlend.length && kaufAnzahl > 0
11601                        && lage !== 'unbekannt';
11602
11603        html += '<div class="d-flex flex-wrap gap-2 mb-4">';
11604        if (lage === 'keins') {
11605            html += '<button class="btn btn-primary" data-action="abr-buchen"'
11606                 + (kaufbar ? '' : ' disabled') + '>'
11607                 + '<i class="fas fa-cart-shopping me-1"></i>'
11608                 + t('Arbeitsplätze buchen') + '</button>';
11609        } else if (lage === 'vorhanden') {
11610            html += '<button class="btn btn-outline-primary" data-action="abr-vorschau">'
11611                 + '<i class="fas fa-scale-balanced me-1"></i>'
11612                 + t('Anzahl anpassen') + '</button>';
11613        }
11614        html += '<button class="btn btn-outline-secondary" data-action="abr-neu">'
11615             + '<i class="fas fa-rotate me-1"></i>' + t('Aktualisieren') + '</button>'
11616             + '</div>';
11617
11618        /* Nachkauf. Steht bewusst UNTER dem Abo: er ist die Ausnahme, nicht
11619           der Regelfall, und ohne gebuchtes Team ergibt er keinen Sinn. */
11620        const pakete = d.nachkauf_pakete || [];
11621        if (lage === 'vorhanden' && pakete.length) {
11622            html += '<h6 class="mb-2">' + t('Zusätzliche Aktionen nachkaufen') + '</h6>'
11623                 + '<p class="small text-muted mb-2">'
11624                 + t('Jeder KI-Mitarbeiter hat ein monatliches Kontingent. Ist es aufgebraucht, arbeitet er erst im nächsten Monat weiter — mit einem Nachkauf sofort.')
11625                 + ' ' + t('In diesem Monat nachgekauft: {n} Aktionen.')
11626                     .replace('{n}', esc(String(d.nachgekauft_monat || 0)))
11627                 + '</p>';
11628
11629            /* kiteam-verfall-v1: Gekaufte Aktionen verfallen zum Monatsende.
11630               Das stand bisher nur in der Stripe-Beschreibung, die beim Kauf
11631               niemand liest. Gegen Monatsende ist es ein Warnkasten, sonst
11632               eine ruhige Zeile - eine Dauerwarnung wuerde nach zwei Wochen
11633               ueberlesen. Der Zustand wird zusaetzlich gemerkt, weil ein
11634               Hinweis allein niemanden schuetzt: der Kaufknopf fragt nach. */
11635            const vf = d.verfall || null;
11636            this._abrVerfall = vf;
11637            if (vf && vf.hinweis) {
11638                html += vf.knapp
11639                    ? ('<div class="alert alert-warning py-2 small mb-3">'
11640                       + '<i class="fas fa-triangle-exclamation me-1"></i>'
11641                       + esc(vf.hinweis) + '</div>')
11642                    : ('<p class="small text-muted mb-2">' + esc(vf.hinweis) + '</p>');
11643            }
11644            html += '<div class="row g-3 mb-3">';
11645            pakete.forEach(p => {
11646                html += '<div class="col-sm-4"><div class="card border-0 shadow-sm h-100">'
11647                     + '<div class="card-body py-3 d-flex flex-column">'
11648                     + '<div class="fs-5 fw-semibold">'
11649                     + esc(Number(p.aktionen || 0).toLocaleString('de-DE')) + ' '
11650                     + t('Aktionen') + '</div>'
11651                     + '<div class="text-muted mb-3">'
11652                     + this._abrEuro(Number(p.brutto_cent || 0) / 100) + ' '
11653                     + t('inkl. MwSt.') + '</div>'
11654                     + '<button class="btn btn-sm btn-outline-primary mt-auto" '
11655                     + 'data-action="abr-nachkauf" data-id="' + esc(p.key) + '">'
11656                     + t('Nachkaufen') + '</button>'
11657                     + '</div></div></div>';
11658            });
11659            html += '</div>';
11660        }
11661
11662        host.innerHTML = html;
11663    }
11664
11665    static async _abrBuchen() {
11666        let d = null;
11667        try { d = await api('/api/ai-team/abo', 'POST'); }
11668        catch (e) { return this._modalFehler(e); }
11669        if (d && d.url) { window.location.href = d.url; return; }
11670        this._modalFehler(new Error(t('Die Kaufseite konnte nicht geöffnet werden.')));
11671    }
11672
11673    static async _abrNachkauf(key) {
11674        if (!key) return;
11675        /* kiteam-verfall-v1: Gegen Monatsende nachfragen, BEVOR gekauft wird.
11676           Wer am 28. das groesste Paket kauft, verliert den Rest am 1. - das
11677           ist Geld, also gehoert eine bewusste Entscheidung davor und nicht
11678           eine Ueberraschung danach. Bei genug Resttagen passiert hier
11679           nichts. */
11680        const vf = this._abrVerfall;
11681        if (vf && vf.knapp) {
11682            const tage = Number(vf.tage || 0);
11683            const frage = t('Dieser Monat läuft in {n} Tagen ab. Nicht verbrauchte Aktionen verfallen am Monatsersten. Trotzdem jetzt kaufen?')
11684                .replace('{n}', String(tage));
11685            if (!window.confirm(frage)) return;
11686        }
11687        let d = null;
11688        try { d = await api('/api/ai-team/nachkauf', 'POST', { paket: key }); }
11689        catch (e) { return this._modalFehler(e); }
11690        if (d && d.url) { window.location.href = d.url; return; }
11691        this._modalFehler(new Error(t('Die Kaufseite konnte nicht geöffnet werden.')));
11692    }
11693
11694    /* Erst zeigen, was sich aendert - dann fragen. Eine Bestaetigung ohne
11695       sichtbare Differenz klickt man nach dem dritten Mal blind weg, und
11696       genau daran haengt hier ein Betrag. */
11697    static async _abrVorschau() {
11698        let v = null;
11699        try { v = await api('/api/ai-team/abo/vorschau'); }
11700        catch (e) { return this._modalFehler(e); }
11701
11702        const kopf = (titel) =>
11703            '<div class="modal-header"><h5 class="modal-title">' + titel + '</h5>'
11704            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>';
11705
11706        /* kiteam-bugfix-v1: Kein Arbeitsplatz mehr -> Kuendigung, nicht
11707           Anpassung. Der Hinweis muss VOR der Differenzanzeige stehen,
11708           sonst rechnet die Maske eine Ersparnis vor, die es so nicht
11709           gibt - das Abo laeuft naemlich weiter. */
11710        if (v.wuerde_abo_leeren) {
11711            return this._modal(kopf(t('Anzahl anpassen'))
11712                + '<div class="modal-body"><div class="alert alert-warning">'
11713                + t('Sie haben derzeit keine KI-Mitarbeiter eingestellt. Ein Abo ohne Arbeitsplatz ist nicht möglich — wenn Sie das KI-Team nicht mehr nutzen möchten, kündigen Sie es bitte über Ihre Abo-Verwaltung.')
11714                + '</div><div class="d-flex justify-content-end">'
11715                + '<button class="btn btn-secondary" data-action="modal-zu">'
11716                + t('Schließen') + '</button></div></div>');
11717        }
11718        if (!v.aenderung_noetig) {
11719            return this._modal(kopf(t('Anzahl anpassen'))
11720                + '<div class="modal-body"><p class="mb-3">'
11721                + t('Ihr Abo passt bereits zu Ihrer Mannschaft — es gibt nichts anzupassen.')
11722                + '</p><div class="d-flex justify-content-end">'
11723                + '<button class="btn btn-secondary" data-action="modal-zu">'
11724                + t('Schließen') + '</button></div></div>');
11725        }
11726        /* kiteam-bugfix-v1: .length ist Pflicht - ein leeres Array
11727           ist in JavaScript WAHR. Die Pruefung schlug deshalb bei
11728           jeder Vorschau an und meldete fremde Positionen, wo
11729           keine waren; die Anzahl liess sich ueber die Oberflaeche
11730           damit ueberhaupt nicht anpassen. */
11731        if ((v.fremde_positionen || []).length) {
11732            return this._modal(kopf(t('Anzahl anpassen'))
11733                + '<div class="modal-body"><div class="alert alert-warning">'
11734                + t('Dieses Abo enthält Positionen, die wir nicht automatisch ändern können. Bitte melden Sie sich kurz bei uns.')
11735                + '</div><div class="d-flex justify-content-end">'
11736                + '<button class="btn btn-secondary" data-action="modal-zu">'
11737                + t('Schließen') + '</button></div></div>');
11738        }
11739
11740        const diff = Number(v.differenz_eur || 0);
11741        const richtung = diff > 0 ? t('Mehrkosten') : t('Ersparnis');
11742        this._modal(kopf(t('Anzahl anpassen'))
11743            + '<div class="modal-body">'
11744            + '<p>' + t('Gebucht sind derzeit {a} Arbeitsplätze für {b} netto im Monat.')
11745                  .replace('{a}', esc(String(v.alt.anzahl)))
11746                  .replace('{b}', this._abrEuro(v.alt.netto_eur)) + '</p>'
11747            + '<p>' + t('Nach der Anpassung sind es {a} Arbeitsplätze für {b} netto im Monat.')
11748                  .replace('{a}', esc(String(v.neu.anzahl)))
11749                  .replace('{b}', this._abrEuro(v.neu.netto_eur)) + '</p>'
11750            + '<p class="fw-semibold">' + esc(richtung) + ': '
11751            + this._abrEuro(Math.abs(diff)) + ' ' + t('netto je Monat') + '</p>'
11752            + '<p class="small text-muted">'
11753            + t('Die Differenz wird anteilig mit Ihrer nächsten Rechnung verrechnet — es wird jetzt nichts abgebucht.')
11754            + '</p>'
11755            + '<div class="d-flex justify-content-end gap-2">'
11756            + '<button class="btn btn-outline-secondary" data-action="modal-zu">'
11757            + t('Abbrechen') + '</button>'
11758            + '<button class="btn btn-primary" data-action="abr-anpassen">'
11759            + t('Anpassen') + '</button></div></div>');
11760    }
11761
11762    static async _abrAnpassen() {
11763        let d = null;
11764        try { d = await api('/api/ai-team/abo', 'PATCH'); }
11765        catch (e) { return this._modalFehler(e); }
11766        this._modal('');
11767        this._abr = null;
11768        this._abrMeldung = {
11769            art: 'success',
11770            text: t('Ihr Abo steht jetzt auf {n} Arbeitsplätzen.')
11771                    .replace('{n}', String((d && d.neu && d.neu.anzahl) || ''))
11772        };
11773        this._render();
11774    }
11775
11776    static async _renderNutzung(host) {
11777        const seq = this._seq;
11778        host.innerHTML = '<div class="text-muted">' + t('Lade …') + '</div>';
11779        let d = null, fehler = null;
11780        try { d = await api('/api/ai-team/nutzung?tage=14'); }
11781        catch (e) { fehler = e; }
11782        if (seq !== this._seq) return;
11783        if (!d) {
11784            host.innerHTML = '<div class="alert alert-danger">'
11785                + esc((fehler && fehler.message)
11786                      || t('Der Verbrauch konnte nicht geladen werden.'))
11787                + '</div>';
11788            return;
11789        }
11790
11791        const h = d.heute || {};
11792        const g = d.grenzen || {};
11793        const v = d.verlauf || {};
11794        const tage = v.tage || [];
11795        const summe = v.summe || {};
11796        const deckel = Number(g.token_je_mandant_tag || 0);
11797        const anteil = deckel > 0 ? (Number(h.gesamt || 0) / deckel) : 0;
11798        const offen = this._nzUngezaehlt(h);
11799
11800        let html = this._nzKontingent(d.kontingent, d.quelle);
11801
11802        html += '<div class="row g-3 mb-3">'
11803             + this._nzKachel(t('Läufe heute'), this._nzZahl(h.laeufe),
11804                              t('Modell-Durchläufe'))
11805             + this._nzKachel(t('Werkzeuge heute'), this._nzZahl(h.werkzeuge),
11806                              t('ausgeführte Schritte'))
11807             + this._nzKachel(t('Token heute'),
11808                              offen ? '—' : this._nzTok(h.gesamt),
11809                              offen ? t('noch nicht erfasst')
11810                                    : t('von der Subscription verarbeitet'))
11811             + this._nzKachel(t('14 Tage'),
11812                              this._nzUngezaehlt(summe) ? '—'
11813                                  : this._nzTok(summe.gesamt),
11814                              this._nzZahl(summe.laeufe) + ' ' + t('Läufe'))
11815             + '</div>';
11816
11817        if (offen) {
11818            html += '<div class="alert alert-light border small mb-3">'
11819                 + t('Für die heutigen Läufe wurde der Token-Verbrauch noch nicht erfasst — die Zählung ist am 17.08.2026 eingeführt worden. Ab dem nächsten Lauf stehen hier echte Zahlen.')
11820                 + '</div>';
11821        }
11822
11823        /* Woraus die Token bestehen. Das gehört sichtbar dazu: über 90
11824           Prozent kommen in der Praxis aus dem Zwischenspeicher und
11825           belasten das Kontingent weit weniger als frische Token. Eine
11826           blosse Gesamtzahl würde den Verbrauch dramatischer aussehen
11827           lassen, als er ist. */
11828        if (Number(h.gesamt || 0) > 0) {
11829            const teile = [
11830                [t('Eingabe (neu)'), h.ein],
11831                [t('Zwischenspeicher angelegt'), h.cache_neu],
11832                [t('Zwischenspeicher gelesen'), h.cache_gelesen],
11833                [t('Ausgabe'), h.aus]
11834            ];
11835            html += '<div class="card border-0 shadow-sm mb-3"><div class="card-body py-3">'
11836                 + '<div class="fw-semibold mb-2">' + t('Woraus die Token heute bestehen') + '</div>'
11837                 + '<div class="d-flex flex-wrap gap-4">'
11838                 + teile.map(x => '<div><div class="text-muted small">' + x[0]
11839                     + '</div><div style="font-variant-numeric:tabular-nums">'
11840                     + this._nzTokVoll(x[1]) + '</div></div>').join('')
11841                 + '</div></div></div>';
11842        }
11843
11844        /* Ein Meter auf 0 bei 19 Laeufen wuerde behaupten, es sei nichts
11845           verbraucht worden. Solange nichts gezaehlt ist, gibt es nichts
11846           zu messen — dann bleibt der Deckel weg. */
11847        if (deckel > 0 && !offen) {
11848            html += '<div class="card border-0 shadow-sm mb-3"><div class="card-body py-3">'
11849                 + '<div class="d-flex justify-content-between align-items-baseline mb-2">'
11850                 + '<span class="fw-semibold">' + t('Tagesdeckel') + '</span>'
11851                 + '<span class="text-muted small" style="font-variant-numeric:tabular-nums">'
11852                 + this._nzTok(h.gesamt) + ' ' + t('von') + ' '
11853                 + this._nzTok(deckel) + ' ' + t('Token') + '</span></div>'
11854                 + this._nzMeter(anteil)
11855                 + '<div class="text-muted small mt-2">'
11856                 + t('Ist der Deckel erreicht, legt das KI-Team für heute keine neuen Läufe mehr an.')
11857                 + '</div></div></div>';
11858        }
11859
11860        /* Wer verbraucht — Größenvergleich, also Balken in EINER Farbe.
11861           Je Mitarbeiter zusätzlich das Verhältnis zu SEINER Tagesgrenze. */
11862        const je = d.heute_je_mitarbeiter || [];
11863        const grenzeL = Number(g.laeufe_je_mitarbeiter_tag || 0);
11864        if (je.length) {
11865            html += '<div class="card border-0 shadow-sm mb-3"><div class="card-body py-3">'
11866                 + '<div class="fw-semibold mb-3">' + t('Heute je Mitarbeiter') + '</div>';
11867            je.forEach(e => {
11868                const a = grenzeL > 0 ? (Number(e.laeufe || 0) / grenzeL) : 0;
11869                html += '<div class="mb-3">'
11870                     + '<div class="d-flex justify-content-between align-items-baseline mb-1">'
11871                     + '<span>' + esc(e.name || e.employee_id) + '</span>'
11872                     + '<span class="text-muted small" style="font-variant-numeric:tabular-nums">'
11873                     + this._nzZahl(e.laeufe) + '/' + this._nzZahl(grenzeL) + ' '
11874                     + t('Läufe') + ' · '
11875                     + (this._nzUngezaehlt(e) ? '—' : this._nzTok(e.gesamt) + ' ' + t('Token'))
11876                     + '</span></div>'
11877                     + this._nzMeter(a) + '</div>';
11878            });
11879            html += '</div></div>';
11880        }
11881
11882        /* Verlauf. Eine Reihe -> keine Legende. Beschriftet wird
11883           ausdrücklich NICHT jeder Balken, nur der höchste. */
11884        const tageMitToken = tage.some(x => Number(x.gesamt) > 0);
11885        if (tage.length && tageMitToken) {
11886            const max = Math.max.apply(null, tage.map(x => Number(x.gesamt) || 0)) || 1;
11887            const geordnet = tage.slice().sort((x, y) => (x.tag < y.tag ? -1 : 1));
11888            html += '<div class="card border-0 shadow-sm mb-3"><div class="card-body py-3">'
11889                 + '<div class="fw-semibold mb-1">' + t('Verlauf (14 Tage)') + '</div>'
11890                 + '<div class="text-muted small mb-3">' + t('Token je Tag') + '</div>'
11891                 + '<div class="d-flex align-items-end" style="height:120px;gap:2px">';
11892            geordnet.forEach(x => {
11893                const w = Number(x.gesamt) || 0;
11894                const hh = Math.max(2, Math.round((w / max) * 104));
11895                const spitze = (w >= max && max > 0);
11896                html += '<div class="flex-fill d-flex flex-column align-items-center'
11897                     + '" title="' + esc(this._nzTag(x.tag)) + ': '
11898                     + esc(this._nzTokVoll(w)) + ' ' + t('Token') + ', '
11899                     + esc(this._nzZahl(x.laeufe))
11900                     + ' ' + t('Läufe') + '">'
11901                     + (spitze ? '<div class="text-muted" style="font-size:10px">'
11902                                 + esc(this._nzTok(w)) + '</div>' : '')
11903                     + '<div style="width:100%;height:' + hh + 'px;background:#2e6b94;'
11904                     + 'border-radius:4px 4px 0 0"></div>'
11905                     + '<div class="text-muted" style="font-size:10px">'
11906                     + esc(this._nzTag(x.tag)) + '</div>'
11907                     + '</div>';
11908            });
11909            html += '</div>';
11910
11911            /* Tabellensicht: dieselben Zahlen ohne Farbe und ohne Größe —
11912               für alle, die die Balken nicht lesen können oder wollen. */
11913            html += '<details class="mt-3"><summary class="small text-muted">'
11914                 + t('Als Tabelle anzeigen') + '</summary>'
11915                 + '<table class="table table-sm mt-2 mb-0" '
11916                 + 'style="font-variant-numeric:tabular-nums"><thead><tr>'
11917                 + '<th>' + t('Tag') + '</th><th class="text-end">' + t('Läufe')
11918                 + '</th><th class="text-end">' + t('Werkzeuge')
11919                 + '</th><th class="text-end">' + t('Token') + '</th></tr></thead><tbody>';
11920            tage.forEach(x => {
11921                html += '<tr><td>' + esc(this._nzTag(x.tag)) + '</td>'
11922                     + '<td class="text-end">' + this._nzZahl(x.laeufe) + '</td>'
11923                     + '<td class="text-end">' + this._nzZahl(x.werkzeuge) + '</td>'
11924                     + '<td class="text-end">'
11925                     + esc(this._nzUngezaehlt(x) ? '—' : this._nzTokVoll(x.gesamt))
11926                     + '</td></tr>';
11927            });
11928            html += '</tbody></table></details></div></div>';
11929        } else if (tage.length) {
11930            /* Es gab Läufe, aber noch keine Token-Zählung. Ein flacher
11931               Balken auf Mindesthöhe säße hier als leere Grafik und
11932               sähe nach einem Fehler aus. */
11933            html += '<div class="alert alert-light border">'
11934                 + t('Für den bisherigen Zeitraum liegen noch keine Token-Zahlen vor. Der Verlauf füllt sich ab dem nächsten Lauf.')
11935                 + '</div>';
11936        } else {
11937            html += '<div class="alert alert-light border">'
11938                 + t('Für die letzten 14 Tage ist noch kein Verbrauch verzeichnet.')
11939                 + '</div>';
11940        }
11941
11942        html += '<div class="text-muted small">' + esc(d.hinweis || '') + '</div>';
11943        host.innerHTML = html;
11944    }
11945
11946    static async _renderBerichte(host) {
11947        const seq = this._seq;
11948        host.innerHTML = '<div class="text-muted">' + t('Lade …') + '</div>';
11949        /* ai-team-wochenbericht-v1: BEIDE Anfragen gleichzeitig — die
11950           Seite soll nicht doppelt so lange laden, nur weil oben eine
11951           Zusammenfassung dazugekommen ist. Und jede fängt ihren Fehler
11952           SELBST: fällt der Wochenbericht aus, sollen die Tagesberichte
11953           trotzdem erscheinen, und umgekehrt. */
11954        let d = null, w = null, fehler = null;
11955        const [rd, rw] = await Promise.all([
11956            api('/api/ai-team/reports').catch(e => { fehler = e; return null; }),
11957            api('/api/ai-team/wochenbericht').catch(() => null)
11958        ]);
11959        d = rd; w = rw;
11960        if (seq !== this._seq) return;
11961        if (!d) {
11962            host.innerHTML = '<div class="alert alert-danger">'
11963                + esc((fehler && fehler.message) || t('Die Berichte konnten nicht geladen werden.'))
11964                + '</div>' + this._wochenBlock(w);
11965            return;
11966        }
11967        const liste = d.reports || [];
11968        /* Der Wochenblock steht VOR dem Leerzustand: ein Mandant ohne
11969           Tagesberichte ist genau der, dem die Zusammenfassung sagt,
11970           dass noch niemand etwas getan hat. Stünde er dahinter, wäre
11971           er im interessantesten Moment unsichtbar. */
11972        host.innerHTML = this._wochenBlock(w)
11973            + (liste.length
11974                ? '<h6 class="mt-3 mb-2">' + t('Tagesberichte') + '</h6>'
11975                  + this._berichteNachTag(liste)
11976                : '<div class="alert alert-light border">'
11977                  + t('Noch keine Tagesberichte. Jeder Mitarbeiter berichtet täglich in fester Form: Erledigt / Wartet auf dich / Konnte ich nicht — und warum.')
11978                  + '</div>');
11979    }
11980
11981    /* ai-team-gfx-v1 — Tagesberichte nach TAG gebuendelt.
11982       Bei drei Mitarbeitern kommen 21 Karten je Woche zusammen, und sie
11983       standen als gleich aussehende Kaesten untereinander: drei Karten
11984       "16.8.2026" hintereinander, ohne dass der Tag als Einheit sichtbar
11985       war. Jetzt eine Gruppe je Tag, der juengste offen. */
11986    static _berichteNachTag(liste) {
11987        const tage = [];
11988        const nach = {};
11989        liste.forEach(r => {
11990            const k = String(r.datum || '');
11991            if (!nach[k]) { nach[k] = []; tage.push(k); }
11992            nach[k].push(r);
11993        });
11994        return tage.map((tag, idx) => {
11995            const n = nach[tag].length;
11996            /* Ein Tag, an dem irgendwo etwas auf den Inhaber wartet,
11997               ist immer offen — auch wenn er aelter ist. */
11998            const wartet = nach[tag].some(r => {
11999                const i = r.inhalt || {};
12000                return ((i.wartet || []).length + (i.entwuerfe || []).length) > 0;
12001            });
12002            return '<details class="aiv-taggruppe"' + ((idx === 0 || wartet) ? ' open' : '') + '>'
12003                + '<summary><span class="aiv-tag-name">' + esc(this._tagName(tag)) + '</span>'
12004                + (wartet ? '<span class="aiv-tagzahl" style="color:var(--aiv-info)">'
12005                    + '<i class="fas fa-hand-point-right"></i> ' + t('etwas wartet') + '</span>' : '')
12006                + '<span class="aiv-tagzahl">' + esc(String(n)) + ' '
12007                + t(n === 1 ? 'Bericht' : 'Berichte') + '</span></summary>'
12008                + '<div class="aiv-tagkoerper">'
12009                + nach[tag].map(r => this._berichtKarte(r)).join('')
12010                + '</div></details>';
12011        }).join('');
12012    }
12013
12014    /* ai-team-wochenbericht-v1 — DAS GESAMTBILD
12015       Bei drei Mitarbeitern sind Tagesberichte 21 Karten je Woche, bei
12016       acht über fünfzig. Dieser Block ist die eine Seite davor.
12017       Reihenfolge nach dem, was den Inhaber etwas kostet:
12018         1. Kennzahlen — wer nur hinsieht, hat die Lage
12019         2. ein Satz
12020         3. still     — wer eine Woche nichts tat (teuerste Zeile)
12021         4. wartet    — was an IHM hängt
12022         5. Mitarbeiter mit Anteilsbalken
12023
12024       ai-team-gfx-v1: Aus der nackten Tabelle wurden Kennzahl-Kacheln
12025       und je Mitarbeiter EIN Anteilsbalken. Die Tabelle nannte
12026       16 / 0 / 10 / 9 nebeneinander und beantwortete damit nicht die
12027       eine Frage, die zaehlt: kommt bei diesem Mitarbeiter mehr heraus
12028       als haengenbleibt. Bei einem standen 17 blockierte gegen 8
12029       erledigte Handlungen — in vier gleich grossen Ziffern sieht das
12030       niemand. */
12031    static _wochenBlock(w) {
12032        if (!w) { return ''; }
12033        const s = w.summe || {};
12034        const still = w.still || [];
12035        const wartet = w.wartet_auf_sie || [];
12036        const rout = w.dauerauftraege || {};
12037        const leute = w.mitarbeiter || [];
12038
12039        const zeitraum = this._datum(w.von) + ' – ' + this._datum(w.bis);
12040
12041        const kachel = (wert, label, farbe) =>
12042            '<div class="aiv-kpi" style="--aiv-akzent:'
12043            + ((wert === 0 || wert === '0') ? 'var(--v2-text-muted, #8a97a8)' : farbe) + '">'
12044            + '<div class="aiv-kpi-wert">' + esc(String(wert)) + '</div>'
12045            + '<div class="aiv-kpi-lab">' + esc(label) + '</div></div>';
12046
12047        const sum = (feld) => leute.reduce((a, m) => a + (Number(m[feld]) || 0), 0);
12048        const nErl = (s.erledigt !== undefined) ? s.erledigt : sum('erledigt');
12049        const nEnt = (s.entwuerfe !== undefined) ? s.entwuerfe : sum('entwuerfe');
12050        const nBlo = (s.blockiert !== undefined) ? s.blockiert : sum('blockiert');
12051
12052        const kpis = '<div class="aiv-kpis">'
12053            + kachel(nErl, t('erledigt'), 'var(--aiv-good)')
12054            + kachel(wartet.length + (w.wartet_gekappt ? '+' : ''),
12055                     t('warten auf Sie'), 'var(--aiv-info)')
12056            + kachel(nBlo, t('nicht möglich'), 'var(--aiv-warn)')
12057            + (nEnt ? kachel(nEnt, t('Entwürfe'), 'var(--aiv-info)') : '')
12058            + (rout.aktiv
12059                ? kachel(rout.gelaufen + '/' + rout.aktiv, t('Daueraufträge gelaufen'),
12060                         (rout.gelaufen < rout.aktiv ? 'var(--aiv-warn)' : 'var(--aiv-good)'))
12061                : '')
12062            + '</div>';
12063
12064        const stillBlock = still.length
12065            ? '<div class="alert alert-warning py-2 px-3 small mb-2"><strong>'
12066              + t('Ohne eine einzige Handlung in diesen sieben Tagen') + ':</strong> '
12067              + still.map(n => esc(n)).join(', ')
12068              + '<div class="text-muted mt-1">'
12069              + t('Das kostet Sie Geld, ohne dass etwas entsteht — geben Sie einen Auftrag oder schalten Sie Daueraufträge ein.')
12070              + '</div></div>'
12071            : '';
12072
12073        const wartetBlock = wartet.length
12074            ? '<div class="aiv-abschnitt mb-2" style="--aiv-akzent:var(--aiv-info)">'
12075              + '<div class="aiv-abschnitt-kopf"><i class="fas fa-hand-point-right"></i>'
12076              + t('Wartet auf Ihre Entscheidung') + ' (' + wartet.length
12077              + (w.wartet_gekappt ? '+' : '') + ')</div>'
12078              + '<ul class="list-unstyled mb-0 small mt-1">'
12079              + wartet.map(x => '<li class="mb-1">'
12080                  + '<span class="fw-semibold">' + esc(x.name || '') + '</span> · '
12081                  + esc(x.was || '')
12082                  + (x.vorschau ? ' <span class="text-muted">' + esc(x.vorschau) + '</span>' : '')
12083                  + ' <span class="' + (x.tage >= 2 ? 'text-danger' : 'text-muted') + '">('
12084                  + (x.tage >= 1
12085                      ? esc(String(x.tage)) + ' ' + t(x.tage === 1 ? 'Tag' : 'Tage')
12086                      : esc(String(x.stunden)) + ' ' + t('Std'))
12087                  + ')</span></li>').join('')
12088              + '</ul></div>'
12089            : '';
12090
12091        /* Der Balken traegt ZWEI Aussagen zugleich:
12092             LAENGE  = wie viel dieser Mitarbeiter getan hat, gemessen am
12093                       aktivsten der Woche
12094             ABSCHNITTE = wovon
12095           Waere jede Zeile auf ihre eigene Summe normiert, saehen alle
12096           Balken gleich lang aus — 63 Handlungen und 25 Handlungen
12097           ergaeben dasselbe Bild, und die Ansicht wuerde eine
12098           Gleichheit behaupten, die es nicht gibt. */
12099        const teileVon = (m) => [
12100            { n: Number(m.erledigt) || 0,  f: 'var(--aiv-good)' },
12101            { n: Number(m.entwuerfe) || 0, f: 'var(--aiv-info)' },
12102            { n: Number(m.blockiert) || 0, f: 'var(--aiv-warn)' }
12103        ].filter(x => x.n > 0);
12104        const gesVon = (m) => teileVon(m).reduce((a, x) => a + x.n, 0);
12105        const maxGes = leute.reduce((a, m) => Math.max(a, gesVon(m)), 0);
12106
12107        const balken = (m) => {
12108            const teile = teileVon(m);
12109            const ges = gesVon(m);
12110            if (!ges) { return '<div class="aiv-balken"></div>'; }
12111            /* Mindestbreite, damit eine kleine Zahl nicht zu einem
12112               unsichtbaren Strich wird und wie "nichts" aussieht. */
12113            const anteil = maxGes ? Math.max(8, ges / maxGes * 100) : 100;
12114            return '<div class="aiv-balken">'
12115                + '<div class="aiv-balken-innen" style="width:' + anteil.toFixed(1) + '%">'
12116                + teile.map(x => '<span style="width:' + (x.n / ges * 100).toFixed(1)
12117                    + '%;background:' + x.f + '"></span>').join('')
12118                + '</div></div>';
12119        };
12120
12121        const zahlen = (m) => {
12122            const st = [];
12123            if (m.erledigt)  { st.push(['var(--aiv-good)', m.erledigt, t('erledigt')]); }
12124            if (m.entwuerfe) { st.push(['var(--aiv-info)', m.entwuerfe, t('Entwürfe')]); }
12125            if (m.blockiert) { st.push(['var(--aiv-warn)', m.blockiert, t('nicht möglich')]); }
12126            /* Offene Auftraege stehen OHNE Farbpunkt und abgesetzt: sie
12127               sind kein Abschnitt des Balkens, sondern ein Zustand. */
12128            const offen = m.auftraege_offen
12129                ? '<span class="aiv-leg text-muted">' + esc(String(m.auftraege_offen))
12130                  + ' ' + t(Number(m.auftraege_offen) === 1 ? 'offener Auftrag' : 'offene Aufträge')
12131                  + '</span>'
12132                : '';
12133            if (!st.length && !offen) { return ''; }
12134            return '<div class="aiv-legende">'
12135                + st.map(x => '<span class="aiv-leg"><i style="background:' + x[0] + '"></i>'
12136                    + esc(String(x[1])) + ' ' + esc(x[2]) + '</span>').join('')
12137                + offen + '</div>';
12138        };
12139
12140        const zeilen = leute.map(m =>
12141            '<div class="aiv-mit">'
12142            + '<div class="aiv-mit-kopf">'
12143            + '<span class="aiv-mit-name">' + esc(m.name || '') + '</span>'
12144            + (m.titel ? '<span class="aiv-mit-titel">' + esc(m.titel) + '</span>' : '')
12145            + (m.still
12146                ? '<span class="aiv-mit-summe aiv-mit-still">'
12147                  + '<i class="fas fa-circle-exclamation"></i> ' + t('nichts getan') + '</span>'
12148                : '')
12149            + '</div>'
12150            + balken(m) + zahlen(m)
12151            + '</div>').join('');
12152
12153        return '<div class="aiv-woche">'
12154            + '<div class="aiv-woche-kopf">'
12155            + '<h6>' + t('Die Woche im Überblick') + '</h6>'
12156            + '<span class="aiv-zeitraum">' + esc(zeitraum) + '</span></div>'
12157            + this._taktWarnung(w.takt)
12158            + kpis
12159            + ((w.satz && (!leute.length || !(nErl + nEnt + nBlo)))
12160                ? '<div class="aiv-satz">' + esc(w.satz) + '</div>' : '')
12161            + stillBlock + wartetBlock + zeilen
12162            + '</div>';
12163    }
12164
12165    /* ai-team-vier-v1 — arbeitet das Team überhaupt?
12166       Der Takt hatte bis heute keinen eigenen Dienst; er lief in der
12167       Kalenderwartung mit. Der eigentliche Schaden daran war nicht der
12168       geteilte Prozess, sondern: fällt er aus, tut das ganze Team nichts
12169       mehr — und für den Inhaber sieht das aus wie eine ruhige Woche.
12170       Ein eigener Dienst behebt das NICHT (der kann genauso still
12171       ausfallen). Erst diese Zeile behebt es.
12172       Sie steht GANZ OBEN und vor dem Satz: sind die Zahlen darunter
12173       nicht entstanden, weil niemand arbeitete, muss man das lesen,
12174       BEVOR man die Null für ein Ergebnis hält. */
12175    static _taktWarnung(takt) {
12176        if (!takt || !takt.still) { return ''; }
12177        const min = Number(takt.alter_min || 0);
12178        const wie = min >= 120
12179            ? Math.round(min / 60) + ' ' + t('Stunden')
12180            : min + ' ' + t('Minuten');
12181        return '<div class="alert alert-danger py-2 px-3 small mb-2">'
12182            + '<i class="fas fa-triangle-exclamation me-2"></i>'
12183            + '<strong>' + t('Ihr Team hat seit') + ' ' + esc(wie) + ' '
12184            + t('nicht gearbeitet.') + '</strong> '
12185            + t('Die Zahlen unten sind deshalb möglicherweise nicht vollständig. '
12186                + 'Bitte melden Sie sich bei uns.')
12187            + '</div>';
12188    }
12189
12190    // ------------------------------------------------------- Unternehmen
12191    //
12192    // ai-team-unternehmen-v1. Drei Dinge in EINER Ansicht, bewusst nicht
12193    // in drei Reitern:
12194    //   1. Steckbrief — WO der Mitarbeiter arbeitet
12195    //   2. Ziele      — WORAUF hingearbeitet wird
12196    //   3. Wissen     — was das ganze Team weiss
12197    // Ziele ohne Betriebskenntnis sind halb wirkungslos ("20 neue Kunden"
12198    // heisst fuer eine Physiopraxis anderes als fuer einen Fensterbauer),
12199    // deshalb stehen sie zusammen.
12200
12201    static async _renderUnternehmen(host) {
12202        const seq = this._seq;
12203        host.innerHTML = '<div class="text-muted">' + t('Lade …') + '</div>';
12204        let d;
12205        try { d = await api('/api/ai-team/unternehmen'); }
12206        catch (e) {
12207            if (seq === this._seq) host.innerHTML =
12208                '<div class="alert alert-danger">' + esc(e.message) + '</div>';
12209            return;
12210        }
12211        if (seq !== this._seq) return;
12212        this._unStand = d;
12213        host.innerHTML = this._unSteckbrief(d) + this._unZiele(d) + this._unWissen(d)
12214            + this._unPii(d);
12215    }
12216
12217    static _unSteckbrief(d) {
12218        const p = d.profil || {};
12219        const felder = d.felder || [];
12220        const gross = { leistungen: 1, besonderes: 1 };
12221        return '<div class="card mb-3"><div class="card-body">'
12222            + '<h5 class="card-title mb-1"><i class="fas fa-building me-2"></i>'
12223            + t('Ihr Unternehmen') + '</h5>'
12224            + '<p class="text-muted small mb-3">'
12225            + t('Was hier steht, kennt jeder Ihrer KI-Mitarbeiter — in jedem Gespräch, ohne dass Sie es erklären müssen. Leere Felder werden nicht erwähnt.')
12226            + '</p>'
12227            + (p.gepflegt ? '' :
12228                '<div class="alert alert-warning py-2 small"><i class="fas fa-circle-info me-1"></i>'
12229                + t('Noch nichts hinterlegt. Ihre Mitarbeiter wissen deshalb nicht, in welchem Unternehmen sie arbeiten.')
12230                + '</div>')
12231            + '<div class="row g-3">'
12232            + felder.map(f => {
12233                const wert = p[f.key] || '';
12234                const breit = gross[f.key] ? 12 : 6;
12235                const feld = gross[f.key]
12236                    ? '<textarea class="form-control" rows="3" id="un-' + esc(f.key)
12237                      + '" maxlength="' + Number(f.max || 400) + '">' + esc(wert) + '</textarea>'
12238                    : '<input class="form-control" id="un-' + esc(f.key)
12239                      + '" maxlength="' + Number(f.max || 400) + '" value="' + esc(wert) + '">';
12240                return '<div class="col-md-' + breit + '">'
12241                    + '<label class="form-label small fw-semibold">' + esc(f.label) + '</label>'
12242                    + feld + '</div>';
12243            }).join('')
12244            + '</div>'
12245            + '<div class="mt-3"><button class="btn btn-primary" data-action="un-speichern">'
12246            + '<i class="fas fa-floppy-disk me-1"></i>' + t('Speichern') + '</button>'
12247            + '<span class="ms-3 small text-muted" id="un-meldung"></span></div>'
12248            + '</div></div>';
12249    }
12250
12251    static async _unSpeichern() {
12252        const felder = (this._unStand || {}).felder || [];
12253        const daten = {};
12254        felder.forEach(f => {
12255            const el = document.getElementById('un-' + f.key);
12256            if (el) daten[f.key] = el.value;
12257        });
12258        const m = document.getElementById('un-meldung');
12259        if (m) m.textContent = t('Wird gespeichert …');
12260        try {
12261            await api('/api/ai-team/unternehmen', 'PUT', daten);
12262            if (m) {
12263                m.textContent = t('Gespeichert. Ihre Mitarbeiter kennen das ab dem nächsten Gespräch.');
12264                m.className = 'ms-3 small text-success';
12265            }
12266        } catch (e) {
12267            if (m) {
12268                m.textContent = e.message;
12269                m.className = 'ms-3 small text-danger';
12270            }
12271        }
12272    }
12273
12274    static _unZielStatusFarbe(s) {
12275        return { offen: '#0d6efd', erreicht: '#198754',
12276                 verfehlt: '#dc3545', pausiert: '#6c757d' }[s] || '#6c757d';
12277    }
12278
12279    static _unZielStatusWort(s) {
12280        return { offen: t('läuft'), erreicht: t('erreicht'),
12281                 verfehlt: t('verfehlt'), pausiert: t('pausiert') }[s] || s;
12282    }
12283
12284    static _unZiele(d) {
12285        const liste = d.ziele || [];
12286        const grenze = ((d.grenzen || {}).ziele) || 12;
12287        const bearbeitet = this._unZiel;
12288        let inhalt;
12289        if (bearbeitet === 'neu') {
12290            inhalt = this._unZielFormular(null);
12291        } else if (bearbeitet) {
12292            const z = liste.find(x => x.id === bearbeitet);
12293            inhalt = z ? this._unZielFormular(z) : '';
12294        } else {
12295            inhalt = liste.length
12296                ? liste.map(z => this._unZielKarte(z)).join('')
12297                : '<div class="alert alert-light border mb-0">'
12298                  + t('Noch keine Ziele. Ohne Ziel arbeitet jeder Mitarbeiter für sich — mit Ziel prüft er, ob das, was er vorschlägt, darauf einzahlt.')
12299                  + '</div>';
12300        }
12301        return '<div class="card mb-3"><div class="card-body">'
12302            + '<div class="d-flex align-items-center mb-1">'
12303            + '<h5 class="card-title mb-0"><i class="fas fa-bullseye me-2"></i>'
12304            + t('Ziele') + '</h5>'
12305            + (bearbeitet ? '' :
12306                '<button class="btn btn-sm btn-outline-primary ms-auto" '
12307                + 'data-action="un-ziel-neu"' + (liste.length >= grenze ? ' disabled' : '')
12308                + '><i class="fas fa-plus me-1"></i>' + t('Ziel hinzufügen') + '</button>')
12309            + '</div>'
12310            + '<p class="text-muted small mb-3">'
12311            + t('Jeder Mitarbeiter sieht die laufenden Ziele und bezieht seine Vorschläge darauf. Höchstens')
12312            + ' ' + Number(grenze) + '.</p>'
12313            + inhalt
12314            + '</div></div>';
12315    }
12316
12317    static _unZielKarte(z) {
12318        const farbe = this._unZielStatusFarbe(z.status);
12319        const teile = [];
12320        if (z.kennzahl) teile.push(t('gemessen an') + ': ' + z.kennzahl);
12321        if (z.stand) teile.push(t('Stand') + ': ' + z.stand);
12322        if (z.zielwert) teile.push(t('Ziel') + ': ' + z.zielwert);
12323        if (z.frist) teile.push(t('bis') + ': ' + z.frist);
12324        return '<div class="border rounded p-2 mb-2 d-flex align-items-start">'
12325            + '<span class="badge me-2 mt-1" style="background:' + farbe + '">'
12326            + esc(this._unZielStatusWort(z.status)) + '</span>'
12327            + '<div class="flex-grow-1">'
12328            + '<div class="fw-semibold">' + esc(z.titel) + '</div>'
12329            + (teile.length ? '<div class="small text-muted">' + esc(teile.join(' · ')) + '</div>' : '')
12330            + '</div>'
12331            + '<button class="btn btn-sm btn-outline-secondary ms-2" '
12332            + 'data-action="un-ziel-bearbeiten" data-id="' + esc(z.id) + '" '
12333            + 'title="' + t('Bearbeiten') + '"><i class="fas fa-pen"></i></button>'
12334            + '</div>';
12335    }
12336
12337    static _unZielFormular(z) {
12338        const neu = !z;
12339        z = z || {};
12340        const f = (id, label, wert, platz) =>
12341            '<div class="col-md-6"><label class="form-label small fw-semibold">'
12342            + esc(label) + '</label><input class="form-control" id="unz-' + id
12343            + '" maxlength="300" value="' + esc(wert || '') + '"'
12344            + (platz ? ' placeholder="' + esc(platz) + '"' : '') + '></div>';
12345        return '<div class="border rounded p-3">'
12346            + '<div class="row g-3">'
12347            + '<div class="col-12"><label class="form-label small fw-semibold">'
12348            + t('Was wollen Sie erreichen?') + '</label>'
12349            + '<input class="form-control" id="unz-titel" maxlength="200" value="'
12350            + esc(z.titel || '') + '" placeholder="'
12351            + t('z. B. Mehr Erstpatienten aus der Umgebung') + '"></div>'
12352            + f('kennzahl', t('Woran gemessen?'), z.kennzahl, t('z. B. Neue Patienten pro Monat'))
12353            + f('stand', t('Wo stehen Sie heute?'), z.stand, t('z. B. 12'))
12354            + f('zielwert', t('Wo wollen Sie hin?'), z.zielwert, t('z. B. 20'))
12355            + f('frist', t('Bis wann?'), z.frist, t('z. B. Ende Q4'))
12356            + (neu ? '' :
12357                '<div class="col-md-6"><label class="form-label small fw-semibold">'
12358                + t('Stand') + '</label><select class="form-select" id="unz-status">'
12359                + ['offen', 'erreicht', 'verfehlt', 'pausiert'].map(s =>
12360                    '<option value="' + s + '"' + (z.status === s ? ' selected' : '') + '>'
12361                    + esc(this._unZielStatusWort(s)) + '</option>').join('')
12362                + '</select></div>')
12363            + '</div>'
12364            + '<div class="mt-3 d-flex align-items-center">'
12365            + '<button class="btn btn-primary btn-sm" data-action="un-ziel-speichern" '
12366            + 'data-id="' + esc(z.id || 'neu') + '">'
12367            + '<i class="fas fa-check me-1"></i>' + t('Speichern') + '</button>'
12368            + '<button class="btn btn-outline-secondary btn-sm ms-2" '
12369            + 'data-action="un-ziel-abbrechen">' + t('Abbrechen') + '</button>'
12370            + (neu ? '' :
12371                '<button class="btn btn-outline-danger btn-sm ms-auto" '
12372                + 'data-action="un-ziel-loeschen" data-id="' + esc(z.id) + '">'
12373                + '<i class="fas fa-trash me-1"></i>' + t('Löschen') + '</button>')
12374            + '</div>'
12375            + '<div class="small text-danger mt-2" id="unz-meldung"></div>'
12376            + '</div>';
12377    }
12378
12379    static async _unZielSpeichern(id) {
12380        const w = (k) => {
12381            const el = document.getElementById('unz-' + k);
12382            return el ? el.value : undefined;
12383        };
12384        const daten = { titel: w('titel'), kennzahl: w('kennzahl'),
12385                        stand: w('stand'), zielwert: w('zielwert'),
12386                        frist: w('frist') };
12387        const st = w('status');
12388        if (st !== undefined) daten.status = st;
12389        const m = document.getElementById('unz-meldung');
12390        try {
12391            if (id === 'neu') {
12392                await api('/api/ai-team/unternehmen/ziele', 'POST', daten);
12393            } else {
12394                await api('/api/ai-team/unternehmen/ziele/'
12395                          + encodeURIComponent(id), 'PUT', daten);
12396            }
12397            this._unZiel = null;
12398            this._render();
12399        } catch (e) {
12400            if (m) m.textContent = e.message;
12401        }
12402    }
12403
12404    static async _unZielLoeschen(id) {
12405        const m = document.getElementById('unz-meldung');
12406        try {
12407            await api('/api/ai-team/unternehmen/ziele/'
12408                      + encodeURIComponent(id), 'DELETE');
12409            this._unZiel = null;
12410            this._render();
12411        } catch (e) {
12412            if (m) m.textContent = e.message;
12413        }
12414    }
12415
12416    static async _unZielStatus(id, status) {
12417        try {
12418            await api('/api/ai-team/unternehmen/ziele/'
12419                      + encodeURIComponent(id), 'PUT', { status: status });
12420            this._render();
12421        } catch (e) { /* Anzeige bleibt, der naechste Aufbau zeigt den Stand */ }
12422    }
12423
12424    // --------------------------------------------- Datenschutz (PII)
12425    //
12426    // ai-team-pii-ui-v1. Steht in DIESER Ansicht und nicht in der Akte
12427    // eines Mitarbeiters: was das Unternehmen an ein Sprachmodell gibt,
12428    // ist eine Unternehmensentscheidung, keine Rolleneigenschaft.
12429    //
12430    // DER WORTLAUT IST HIER TEIL DER FUNKTION, nicht Schmuck:
12431    // Im aktiven Weg (Subscription) laeuft die Werkzeugschicht in einem
12432    // EIGENEN Prozess. Der Tresor mit den Originalwerten lebt dort und
12433    // ist im Antwortstrom nicht verfuegbar -- der Inhaber sieht
12434    // "[IBAN geschwaerzt]" und NICHT seinen Wert. Die Oberflaeche darf
12435    // deshalb keine Rueckuebersetzung versprechen. Ein Versprechen, das
12436    // der Code nicht haelt, ist schlimmer als eine fehlende Zusage.
12437
12438    static _unPiiStufen() {
12439        return [
12440            ['aus', t('Aus'),
12441             t('Nichts wird verändert. Voreinstellung.')],
12442            ['zaehlen', t('Nur messen'),
12443             t('Es wird gezählt, was in den Daten vorkommt — am Inhalt ändert sich nichts. Der empfohlene erste Schritt: Sie sehen, worum es überhaupt geht, bevor Sie eingreifen.')],
12444            ['aktiv', t('Ersetzen'),
12445             t('Die gewählten Angaben werden durch Platzhalter ersetzt, bevor Ihre Daten an das Sprachmodell gehen.')]
12446        ];
12447    }
12448
12449    static _unPii(d) {
12450        const p = d.pii || {};
12451        const stufen = this._unPiiStufen();
12452        // Unbekannte Stufe wird als "aus" gezeigt -- dieselbe Richtung
12453        // wie im Server (im Zweifel nicht eingreifen).
12454        const stufe = stufen.some(s => s[0] === p.stufe) ? p.stufe : 'aus';
12455        const gewaehlt = {};
12456        (p.kategorien || []).forEach(k => { gewaehlt[k] = 1; });
12457        const auswahl = p.auswahl || [];
12458        return '<div class="card mb-3"><div class="card-body">'
12459            + '<h5 class="card-title mb-1"><i class="fas fa-user-shield me-2"></i>'
12460            + t('Datenschutz gegenüber dem Sprachmodell') + '</h5>'
12461            + '<p class="text-muted small mb-3">'
12462            + t('Ihre KI-Mitarbeiter lesen Kundendaten — Kontakte, Rechnungen, Vorgänge, Postfach. Was sie lesen, geht an das Sprachmodell. Hier legen Sie fest, was vorher unkenntlich gemacht wird.')
12463            + '</p>'
12464            + stufen.map(s => '<div class="form-check mb-2">'
12465                + '<input class="form-check-input" type="radio" name="unpii" '
12466                + 'id="un-pii-' + esc(s[0]) + '" value="' + esc(s[0]) + '"'
12467                + (s[0] === stufe ? ' checked' : '') + '>'
12468                + '<label class="form-check-label" for="un-pii-' + esc(s[0]) + '">'
12469                + '<span class="fw-semibold">' + esc(s[1]) + '</span>'
12470                + '<span class="d-block text-muted small">' + esc(s[2]) + '</span>'
12471                + '</label></div>').join('')
12472            + '<div id="un-pii-kat" class="mt-3 ps-3 border-start"'
12473            + (stufe === 'aus' ? ' style="display:none"' : '') + '>'
12474            + '<div class="small fw-semibold mb-2">' + t('Welche Angaben') + '</div>'
12475            + (auswahl.length
12476                ? auswahl.map(a => '<div class="form-check">'
12477                    + '<input class="form-check-input ait-piicb" type="checkbox" '
12478                    + 'id="un-pii-k-' + esc(a.key) + '" value="' + esc(a.key) + '"'
12479                    + (gewaehlt[a.key] ? ' checked' : '') + '>'
12480                    + '<label class="form-check-label small" for="un-pii-k-' + esc(a.key) + '">'
12481                    + esc(a.label) + '</label></div>').join('')
12482                : '<div class="text-muted small">' + t('Keine Auswahl verfügbar.') + '</div>')
12483            + '<div class="alert alert-light border py-2 px-3 small mt-3 mb-0">'
12484            + '<i class="fas fa-circle-info me-1"></i>'
12485            + t('Die Rufnummer bleibt bewusst unverändert — eine ersetzte Rufnummer wäre kein Schutz, sondern ein Anruf, der nicht zustande kommt. Name und Anschrift können Sie dagegen wählen: Ihr Mitarbeiter sieht sie beim Denken nur als Platzhalter, beim Schreiben eines Briefs oder einer E-Mail wird der echte Wert automatisch wieder eingesetzt.')
12486            + '</div>'
12487            + '<div class="alert alert-warning py-2 px-3 small mt-2 mb-0" id="un-pii-hinweis"'
12488            + (stufe === 'aktiv' ? '' : ' style="display:none"') + '>'
12489            + '<i class="fas fa-triangle-exclamation me-1"></i>'
12490            + t('Bei »Ersetzen« sehen auch Sie die Angabe in der Antwort nur noch als »[IBAN geschwärzt]«. Ihre gespeicherten Daten bleiben unverändert — der Mitarbeiter trägt beim Schreiben wieder den echten Wert ein.')
12491            + '</div>'
12492            + '</div>'
12493            + '<div class="mt-3"><button class="btn btn-primary" data-action="un-pii-speichern">'
12494            + '<i class="fas fa-floppy-disk me-1"></i>' + t('Speichern') + '</button>'
12495            + '<span class="ms-3 small text-muted" id="un-pii-meldung"></span></div>'
12496            + '</div></div>';
12497    }
12498
12499    // Nur der Elementbaum, KEIN _render(): ein Neuaufbau wuerde die
12500    // Ansicht neu laden und die noch nicht gespeicherte Auswahl
12501    // verwerfen. Gleiches Muster wie 'akt-mehr'.
12502    static _unPiiStufeUm(k) {
12503        const box = document.getElementById('un-pii-kat');
12504        if (box) box.style.display = (k === 'aus') ? 'none' : '';
12505        const hw = document.getElementById('un-pii-hinweis');
12506        if (hw) hw.style.display = (k === 'aktiv') ? '' : 'none';
12507    }
12508
12509    static async _unPiiSpeichern() {
12510        const r = document.querySelector('#aiTeamRoot input[name="unpii"]:checked');
12511        const stufe = r ? r.value : 'aus';
12512        const kats = Array.prototype.slice.call(
12513            document.querySelectorAll('#un-pii-kat .ait-piicb'))
12514            .filter(x => x.checked).map(x => x.value);
12515        const m = document.getElementById('un-pii-meldung');
12516        if (m) {
12517            m.textContent = t('Wird gespeichert …');
12518            m.className = 'ms-3 small text-muted';
12519        }
12520        try {
12521            const a = await api('/api/ai-team/unternehmen/pii', 'PUT',
12522                                { stufe: stufe, kategorien: kats });
12523            // Den Stand des SERVERS anzeigen, nicht die eigene Annahme:
12524            // er darf die Auswahl korrigieren (eingeschaltet ohne
12525            // Auswahl fuellt er die Vorgabe nach). Wer hier die eigene
12526            // Eingabe zurueckspiegelt, zeigt einen Zustand, den es in
12527            // der Datenbank nicht gibt.
12528            const pii = (a && a.pii) || {};
12529            if (this._unStand) {
12530                this._unStand.pii = Object.assign({}, this._unStand.pii || {},
12531                                                  pii);
12532            }
12533            const gesetzt = {};
12534            (pii.kategorien || []).forEach(k => { gesetzt[k] = 1; });
12535            Array.prototype.slice.call(
12536                document.querySelectorAll('#un-pii-kat .ait-piicb'))
12537                .forEach(x => { x.checked = !!gesetzt[x.value]; });
12538            if (pii.stufe) {
12539                const rr = document.getElementById('un-pii-' + pii.stufe);
12540                if (rr) rr.checked = true;
12541                this._unPiiStufeUm(pii.stufe);
12542            }
12543            const n = (pii.kategorien || []).length;
12544            let txt;
12545            if (pii.stufe === 'aus') {
12546                txt = t('Gespeichert. Es wird nichts verändert.');
12547            } else if (pii.stufe === 'zaehlen') {
12548                txt = t('Gespeichert. Ab dem nächsten Gespräch wird gezählt — beobachtet werden ')
12549                      + n + ' ' + t('Angaben.');
12550            } else {
12551                txt = t('Gespeichert. Ab dem nächsten Gespräch werden ')
12552                      + n + ' ' + t('Angaben ersetzt.');
12553            }
12554            if (m) { m.textContent = txt; m.className = 'ms-3 small text-success'; }
12555        } catch (e) {
12556            if (m) { m.textContent = e.message; m.className = 'ms-3 small text-danger'; }
12557        }
12558    }
12559
12560    static _unWissen(d) {
12561        const liste = d.wissen || [];
12562        const g = d.grenzen || {};
12563        // wissen-rang-v1: dieselbe Zahl nennen, die der Server durchsetzt.
12564        const promptMax = Number(g.wissen_prompt || 25);
12565        const nachrangig = liste.filter(w => w.relevant === false).length;
12566        return '<div class="card mb-3"><div class="card-body">'
12567            + '<h5 class="card-title mb-1"><i class="fas fa-lightbulb me-2"></i>'
12568            + t('Gemeinsames Wissen') + '</h5>'
12569            + '<p class="text-muted small mb-3">'
12570            + t('Was hier steht, kennt das ganze Team. Ihre Mitarbeiter legen hier selbst ab, was für alle gilt — Sie können es ebenso.')
12571            + '</p>'
12572            + '<div class="input-group mb-3">'
12573            + '<input class="form-control" id="un-wissen-text" maxlength="'
12574            + Number(g.wissen_text || 600) + '" placeholder="'
12575            + t('Ein ganzer Satz, der auch ohne Zusammenhang verständlich ist') + '">'
12576            + '<button class="btn btn-outline-primary" data-action="un-wissen-neu">'
12577            + '<i class="fas fa-plus me-1"></i>' + t('Hinzufügen') + '</button>'
12578            + '</div>'
12579            + '<div class="small text-danger mb-2" id="un-wissen-meldung"></div>'
12580            // wissen-rang-v1: Die Liste zeigt IMMER alles — auch das, was
12581            // die Bewertung aussortiert hat. Nur so kann der Inhaber eine
12582            // Fehleinschätzung überhaupt sehen und zurückdrehen.
12583            + (liste.length
12584                ? '<div class="d-flex justify-content-between align-items-center mb-2">'
12585                  + '<div class="small text-muted">' + esc(liste.length) + ' '
12586                  + t('Sätze') + (nachrangig ? ' · ' + esc(nachrangig) + ' '
12587                                               + t('nachrangig') : '')
12588                  + ' · ' + t('in der Anweisung') + ': '
12589                  + esc(Math.min(liste.length - nachrangig, promptMax)) + '</div>'
12590                  + '<button class="btn btn-sm btn-outline-primary" '
12591                  + 'data-action="un-wissen-kurieren">'
12592                  + t('Jetzt bewerten lassen') + '</button></div>'
12593                  + (liste.length - nachrangig > promptMax
12594                      ? '<div class="alert alert-light border small py-2 mb-2">'
12595                        + t('Mehr Sätze als in die Anweisung passen. Ihre Mitarbeiter bekommen die wichtigsten direkt — alles Übrige finden sie, indem sie im gemeinsamen Wissen nachschlagen.')
12596                        + '</div>' : '')
12597                  + liste.map(w => {
12598                    const nach = (w.relevant === false);
12599                    const marke = nach
12600                        ? '<span class="badge bg-warning text-dark me-2">'
12601                          + t('Nachrangig — steht nicht in der Anweisung') + '</span>'
12602                        : (w.bewertung != null
12603                            ? '<span class="badge bg-success-subtle text-success-emphasis me-2">'
12604                              + t('In der Anweisung') + ' · ' + esc(w.bewertung) + '</span>'
12605                            : '');
12606                    const grund = w.bewertung_grund
12607                        ? '<div class="text-muted mt-1" style="font-size:.85em">'
12608                          + t('Einschätzung der KI') + ': '
12609                          + esc(w.bewertung_grund) + '</div>' : '';
12610                    const zurueck = nach
12611                        ? '<button class="btn btn-sm btn-link py-0" '
12612                          + 'data-action="un-wissen-behalten" data-id="' + esc(w.id) + '" '
12613                          + 'title="' + t('Doch in die Anweisung aufnehmen')
12614                          + '"><i class="fas fa-rotate-left"></i></button>' : '';
12615                    return '<div class="d-flex align-items-start border-bottom py-2'
12616                    + (nach ? ' bg-light' : '') + '">'
12617                    + '<i class="fas fa-' + (w.quelle === 'mitarbeiter' ? 'robot' : 'user')
12618                    + ' text-muted me-2 mt-1" title="'
12619                    + (w.quelle === 'mitarbeiter' ? t('von einem Mitarbeiter') : t('von Ihnen'))
12620                    + '"></i>'
12621                    + '<div class="flex-grow-1 small">'
12622                    + (marke ? '<div class="mb-1">' + marke + '</div>' : '')
12623                    + esc(w.inhalt) + grund + '</div>'
12624                    + '<div class="text-nowrap">' + zurueck
12625                    + '<button class="btn btn-sm btn-link text-danger py-0" '
12626                    + 'data-action="un-wissen-loeschen" data-id="' + esc(w.id) + '" '
12627                    + 'title="' + t('Löschen') + '"><i class="fas fa-xmark"></i></button>'
12628                    + '</div></div>';
12629                  }).join('')
12630                : '<div class="text-muted small">'
12631                  + t('Noch nichts hinterlegt.') + '</div>')
12632            + '</div></div>';
12633    }
12634
12635    static async _unWissenNeu() {
12636        const el = document.getElementById('un-wissen-text');
12637        const m = document.getElementById('un-wissen-meldung');
12638        if (!el || !el.value.trim()) return;
12639        if (m) m.textContent = '';
12640        try {
12641            await api('/api/ai-team/unternehmen/wissen', 'POST',
12642                      { inhalt: el.value });
12643            this._render();
12644        } catch (e) {
12645            if (m) m.textContent = e.message;
12646        }
12647    }
12648
12649    static async _unWissenBehalten(id) {
12650        const m = document.getElementById('un-wissen-meldung');
12651        try {
12652            await api('/api/ai-team/unternehmen/wissen/'
12653                      + encodeURIComponent(id) + '/behalten', 'POST', {});
12654            this._render();
12655        } catch (e) {
12656            if (m) m.textContent = e.message;
12657        }
12658    }
12659
12660    static async _unWissenKurieren() {
12661        const btn = document.querySelector('[data-action="un-wissen-kurieren"]');
12662        const m = document.getElementById('un-wissen-meldung');
12663        if (btn) { btn.disabled = true; btn.textContent = t('Bewerte …'); }
12664        try {
12665            await api('/api/ai-team/unternehmen/wissen/kurieren', 'POST', {});
12666            this._render();
12667        } catch (e) {
12668            if (btn) {
12669                btn.disabled = false;
12670                btn.textContent = t('Jetzt bewerten lassen');
12671            }
12672            if (m) m.textContent = e.message;
12673        }
12674    }
12675
12676    static async _unWissenLoeschen(id) {
12677        const m = document.getElementById('un-wissen-meldung');
12678        try {
12679            await api('/api/ai-team/unternehmen/wissen/'
12680                      + encodeURIComponent(id), 'DELETE');
12681            this._render();
12682        } catch (e) {
12683            if (m) m.textContent = e.message;
12684        }
12685    }
12686
12687    // ---------------------------------------------------------- Helfer
12688
12689    // Menue-Badge (Muster Rezeptions-Eingangszaehler). Wird auch von
12690    // app.js nach dem Login gerufen (Admin-Poll).
12691    static _badgeSetzen(n) {
12692        const b = document.getElementById('aiTeamNavBadge');
12693        if (!b) return;
12694        if (n > 0) { b.textContent = String(n); b.style.display = ''; }
12695        else { b.style.display = 'none'; }
12696    }
12697
12698    static _modal(inhalt) {
12699        // Container notfalls anlegen (_modalBox-Lehre: nie auf ein DOM
12700        // bauen, das eine andere Ansicht gerade ersetzt hat).
12701        let box = document.getElementById('aiTeamModal');
12702        if (!box) {
12703            box = document.createElement('div');
12704            box.id = 'aiTeamModal';
12705            (document.getElementById('aiTeamRoot') || document.body).appendChild(box);
12706        }
12707        if (!inhalt) { box.innerHTML = ''; return; }
12708        box.innerHTML = '<div class="modal d-block" tabindex="-1" style="background:rgba(0,0,0,.4)">'
12709            + '<div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content">'
12710            + inhalt + '</div></div></div>';
12711        /* ai-team-dlspin-v1: Dokumentablage zeichnet ihr Fenster neu */
12712        if (this._dlLaeuft && this._dlLaeuft.size) { try { this._dlMarken(); } catch (e) { /* optional */ } }
12713    }
12714
12715    /* ai-team-gesichter-v1 — Auswahl. Der Bestand kommt aus dem
12716       Katalog des Servers, nicht aus einer zweiten Liste im Browser:
12717       sonst zeigt die Auswahl Gesichter an, die es nicht gibt, sobald
12718       der Bestand waechst. */
12719    static async _gesichtDialog(empId) {
12720        let kat;
12721        try { kat = await this._katalogLaden(); } catch (e) { return this._modalFehler(e); }
12722        const liste = (kat && kat.gesichter) || [];
12723        if (!liste.length) {
12724            return this._modalFehler(new Error(t('Es sind keine Gesichter hinterlegt.')));
12725        }
12726        /* Erst die Akte, dann das Board: der Dialog wird AUS der Akte
12727           geoeffnet, und _members ist leer, wenn der Inhaber direkt in
12728           die Akte eingestiegen ist (ai-team-einstieg-v1). */
12729        const m = ((this._akteDaten || {}).member
12730                   && String(((this._akteDaten || {}).member || {}).id) === String(empId)
12731                   ? this._akteDaten.member
12732                   : (this._members || []).find(x => String(x.id) === String(empId)))
12733                  || {};
12734        const jetzt = m.gesicht || '';
12735        const kacheln = liste.map(g => {
12736            const aktiv = String(g.id) === String(jetzt);
12737            return '<button type="button" class="btn p-1 border-0 bg-transparent" '
12738                + 'data-action="gesicht-setzen" data-arg="' + esc(empId) + '" '
12739                + 'data-id="' + esc(g.id) + '" '
12740                + 'title="' + esc(t('Dieses Gesicht verwenden')) + '" '
12741                + (aktiv ? 'aria-current="true" ' : '')
12742                + 'style="border-radius:50%">'
12743                + '<img src="' + esc(g.url) + '" alt="" width="64" height="64" '
12744                + 'loading="lazy" class="rounded-circle" '
12745                + 'style="width:64px;height:64px;background:#e9ecef'
12746                + (aktiv ? ';box-shadow:0 0 0 3px #2e6b94' : '') + '">'
12747                + '</button>';
12748        }).join('');
12749        this._modal('<div class="modal-header">'
12750            + '<h5 class="modal-title">' + esc(t('Gesicht wählen')) + '</h5>'
12751            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
12752            + '<div class="modal-body">'
12753            + '<p class="text-muted small">'
12754            + esc(t('Die Gesichter sind gezeichnet, nicht fotografiert — damit erkennbar bleibt, dass es sich um einen Assistenten handelt.'))
12755            + '</p>'
12756            + '<div class="d-flex flex-wrap gap-1">' + kacheln + '</div></div>'
12757            + '<div class="modal-footer">'
12758            + '<button class="btn btn-secondary" data-action="modal-zu">'
12759            + esc(t('Abbrechen')) + '</button></div>');
12760    }
12761
12762    static async _gesichtSetzen(empId, gid) {
12763        if (!empId || !gid) return;
12764        /* Doppelklick-Schutz: der Dialog bleibt bis zur Antwort stehen,
12765           ein zweiter Klick wuerde eine zweite Schreibanfrage senden. */
12766        if (this._gesichtLaeuft) return;
12767        this._gesichtLaeuft = true;
12768        try {
12769            /* api() nimmt (pfad, methode, body) und serialisiert selbst -
12770               ein Optionen-Objekt landete als Methoden-Zeichenkette. */
12771            const r = await api('/api/ai-team/members/' + encodeURIComponent(empId)
12772                                + '/gesicht', 'PUT', { gesicht: gid });
12773            /* Beide Bestaende im Fenster nachziehen, sonst zeigt die
12774               Oberflaeche bis zum naechsten Laden das alte Gesicht.
12775               Die Akte liegt unter _akteDaten.member (NICHT _akte). */
12776            if (r && r.member) {
12777                const i = (this._members || []).findIndex(
12778                    x => String(x.id) === String(empId));
12779                if (i >= 0) this._members[i] = r.member;
12780                if (this._akteDaten && this._akteDaten.member
12781                    && String(this._akteDaten.member.id) === String(empId)) {
12782                    this._akteDaten.member = r.member;
12783                }
12784            }
12785            this._modal('');
12786            this._render();
12787        } catch (e) {
12788            this._modalFehler(e);
12789        } finally {
12790            this._gesichtLaeuft = false;
12791        }
12792    }
12793
12794    static _modalFehler(e) {
12795        this._modal('<div class="modal-header"><h5 class="modal-title">' + t('Das hat nicht geklappt') + '</h5>'
12796            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
12797            + '<div class="modal-body"><div class="alert alert-danger mb-0">' + esc(e && e.message ? e.message : e) + '</div></div>');
12798    }
12799
12800    // ============================================================ //
12801    // ai-team-org-ui-v1 — Ebenen, Organigramm, Auftragsbuch        //
12802    // ============================================================ //
12803
12804    // ai-team-werkzeug-ui-v1: "Womit ich gerade arbeiten kann". Bewusst ganz
12805    // oben in der Akte — wer wissen will, warum nichts passiert, soll es
12806    // nicht suchen muessen. esc() ist eine MODULFUNKTION dieser Datei.
12807    static _akteWerkzeuge(d) {
12808        const liste = Array.isArray(d.werkzeuge) ? d.werkzeuge : [];
12809        const k = d.kontingent || null;
12810        const kopf = '<h6 class="mb-2">' + t('Womit ich gerade arbeiten kann') + '</h6>';
12811        if (!liste.length) {
12812            return kopf + '<div class="small text-muted">'
12813                + t('Konnte gerade nicht ermittelt werden.') + '</div>';
12814        }
12815        const zeilen = liste.map(x => {
12816            const ikon = x.kann ? 'fa-circle-check text-success'
12817                                : 'fa-circle-exclamation text-warning';
12818            return '<div class="d-flex align-items-start gap-2 py-1">'
12819                + '<i class="fa-solid ' + ikon + ' mt-1"></i>'
12820                + '<div><div>' + esc(x.was) + '</div>'
12821                + '<div class="small text-muted">' + esc(x.grund) + '</div></div></div>';
12822        }).join('');
12823        let quote = '';
12824        if (k && k.grenze) {
12825            const pro = Math.min(100, Math.round((k.verbraucht / k.grenze) * 100));
12826            const farbe = pro >= 90 ? 'bg-danger' : (pro >= 70 ? 'bg-warning' : 'bg-primary');
12827            quote = '<div class="mt-2"><div class="small text-muted mb-1">'
12828                + t('KI-Aktionen diesen Monat') + ': ' + esc(k.verbraucht) + ' '
12829                + t('von') + ' ' + esc(k.grenze) + '</div>'
12830                + '<div class="progress" style="height:6px"><div class="progress-bar '
12831                + farbe + '" style="width:' + pro + '%"></div></div></div>';
12832        }
12833        return kopf + zeilen + quote;
12834    }
12835
12836    // ai-team-zugriff-ui-v1: WORAUF darf er zugreifen. Der Server liefert
12837    // den vollstaendigen Stand (zugriffe) — die Oberflaeche legt die
12838    // Erbregel NICHT selbst aus, sonst gibt es zwei Auslegungen desselben
12839    // Rechts (der Fehler, der uns bei den Modulrechten dreimal einholte).
12840    static _akteZugriffe(m) {
12841        const d = this._akteDaten || {};
12842        const kat = d.zugriff_katalog || {};
12843        const stand = d.zugriffe || {};
12844        const keys = Object.keys(kat);
12845        if (!keys.length) {
12846            return '<h6>' + t('Worauf ich zugreifen darf') + '</h6>'
12847                + '<div class="small text-muted">' + t('Konnte gerade nicht geladen werden.') + '</div>';
12848        }
12849        const zeilen = keys.map(k => {
12850            const an = stand[k] === true;
12851            /* rollen-zugriff-v1: Der Server liefert nur, was zu dieser Rolle
12852               passt -- ODER was heute schon an ist. Der zweite Fall wird
12853               ausdruecklich benannt, damit der Inhaber sieht, dass hier
12854               jemand mehr darf als seine Stelle vorsieht. Abschalten geht,
12855               Wiedereinschalten nicht (der Server lehnt es ab). */
12856            const fremd = kat[k].rollenfremd === true;
12857            return '<div class="form-check form-switch py-1">'
12858                + '<input class="form-check-input" type="checkbox" role="switch" '
12859                + 'id="aiZug_' + esc(k) + '" data-zugriff="' + esc(k) + '"'
12860                + (an ? ' checked' : '') + '>'
12861                + '<label class="form-check-label" for="aiZug_' + esc(k) + '">'
12862                + esc(kat[k].label || k)
12863                + (fremd ? ' <span class="badge bg-warning text-dark">'
12864                    + t('über die Rolle hinaus') + '</span>' : '')
12865                + '<span class="d-block small text-muted">' + esc(kat[k].was || '')
12866                + (fremd ? ' — ' + t('stammt aus einer früheren Einstellung. Wenn Sie das abschalten, lässt es sich für diese Rolle nicht wieder einschalten.') : '')
12867                + '</span>'
12868                + '</label></div>';
12869        }).join('');
12870        return '<h6>' + t('Worauf ich zugreifen darf') + '</h6>'
12871            + '<div class="text-muted small mb-2">'
12872            + t('Angezeigt wird, was zu dieser Stelle passt. Was ich nicht sehen darf, fasse ich auch nicht an. Fehlt mir etwas für meine Arbeit, sage ich das im Tagesbericht — statt still nichts zu tun.')
12873            + '</div>' + zeilen
12874            + '<button class="btn btn-primary btn-sm mt-2" data-action="zugriffe-speichern" '
12875            + 'data-id="' + esc(m.id) + '">' + t('Zugriffe speichern') + '</button>'
12876            + '<div id="aiZugHinweis" class="small mt-2"></div>';
12877    }
12878
12879    static async _zugriffeSpeichern(id) {
12880        const hin = document.getElementById('aiZugHinweis');
12881        if (hin) { hin.textContent = ''; hin.className = 'small mt-2'; }
12882        // IMMER alle Schluessel senden — eine Teilmenge waere zwar seit
12883        // v1b unschaedlich, aber der abgeschickte Stand soll genau das
12884        // sein, was der Inhaber auf dem Bildschirm sieht.
12885        const zugriffe = {};
12886        document.querySelectorAll('[data-zugriff]').forEach(el => {
12887            zugriffe[el.getAttribute('data-zugriff')] = !!el.checked;
12888        });
12889        try {
12890            const r = await api('/api/ai-team/members/' + encodeURIComponent(id), 'PUT', { zugriffe });
12891            if (hin && (r.hinweise || []).length) {
12892                hin.className = 'small text-warning mt-2';
12893                hin.textContent = r.hinweise.join(' · ');
12894            }
12895        } catch (e) {
12896            if (hin) { hin.className = 'small text-danger mt-2'; hin.textContent = e.message; return; }
12897            return this._modalFehler(e);
12898        }
12899        await this._akteOeffnen(id);
12900    }
12901
12902    /* chefauswahl-v1 ----------------------------------------------
12903       Beschriftung eines Eintrags der Vorgesetzten-Auswahl. Ein
12904       blosser Vorname beantwortet die Frage nicht, die man sich an
12905       dieser Stelle stellt -- naemlich WEN man da ueber wen stellt.
12906       Reiner Text, kein HTML: in einem <option> wuerde Auszeichnung
12907       nicht dargestellt, sondern woertlich mitgelesen.
12908       Dieselbe Regel wie im Organigramm (_orgTitel): der eigene
12909       Titel gewinnt, sonst die Rollenbezeichnung. Traegt jemand
12910       seinen eigenen Namen als Titel, entfaellt die Wiederholung. */
12911    static _chefLabel(k) {
12912        const n = String((k || {}).name || '').trim();
12913        // Erst kuerzen, dann entscheiden. Ein Titel aus lauter
12914        // Leerzeichen ist kein Titel -- sonst faellt der Eintrag auf
12915        // den nackten Vornamen zurueck, also genau auf den Zustand,
12916        // der hier behoben werden soll.
12917        const eigen = String((k || {}).titel || '').trim();
12918        const b = eigen || String(this._rolleTitel((k || {}).role_key) || '').trim();
12919        return (b && b !== n) ? (n + ' — ' + b) : n;
12920    }
12921
12922    static _akteOrg(m) {
12923        const deps = (this._akteDaten || {}).departments || [];
12924        const kollegen = (this._akteDaten || {}).kollegen || [];
12925        return '<h6>' + t('Einordnung') + '</h6>'
12926            + '<div class="text-muted small mb-2">'
12927            /* ai-team-vier-v1: "bündelt später" war falsch. Der
12928               Führungsbericht läuft, seit es ihn gibt — er findet nur
12929               niemanden, weil das Feld überall leer ist. Ein Satz, der
12930               eine vorhandene Wirkung in die Zukunft schiebt, ist der
12931               Grund, warum niemand das Feld setzt. */
12932            + t('Abteilung und Berichtsweg. Wem Sie hier einen Vorgesetzten geben, dessen Tagesbericht fasst dieser ab morgen in seinem eigenen mit zusammen — und er kann ihm Aufträge weitergeben.')
12933            + '</div>'
12934            + '<div class="row g-2">'
12935            + '<div class="col-12 col-sm-6"><label class="form-label small mb-1">'
12936            + t('Abteilung') + '</label>'
12937            + '<select class="form-select form-select-sm" id="aiOrgDep">'
12938            + '<option value="">' + t('— keine —') + '</option>'
12939            + deps.map(d => '<option value="' + esc(d.id) + '"'
12940                + (d.id === m.department_id ? ' selected' : '') + '>' + esc(d.name) + '</option>').join('')
12941            + '</select></div>'
12942            + '<div class="col-12 col-sm-6"><label class="form-label small mb-1">'
12943            + t('Berichtet an') + '</label>'
12944            + '<select class="form-select form-select-sm" id="aiOrgChef">'
12945            + '<option value="">' + t('— direkt an Sie —') + '</option>'
12946            + kollegen.map(k => '<option value="' + esc(k.id) + '"'
12947                + (k.id === m.vorgesetzter_id ? ' selected' : '') + '>'
12948                + esc(this._chefLabel(k)) + '</option>').join('')
12949            + '</select></div></div>'
12950            + '<button class="btn btn-primary btn-sm mt-2" data-action="org-speichern" '
12951            + 'data-id="' + esc(m.id) + '">' + t('Einordnung speichern') + '</button>'
12952            + '<div id="aiOrgHinweis" class="small text-danger mt-2"></div>';
12953    }
12954
12955    static _prioBadge(p) {
12956        const f = { hoch: 'bg-danger', normal: 'bg-secondary', niedrig: 'bg-light text-dark' }[p] || 'bg-secondary';
12957        const l = { hoch: 'hoch', normal: 'normal', niedrig: 'niedrig' }[p] || p;
12958        return '<span class="badge ' + f + '">' + esc(t(l)) + '</span>';
12959    }
12960
12961    /* aiteam-auftrag-v2 --------------------------------------------
12962       Zustandsabzeichen. Der vierte Zustand ist der eigentliche Punkt:
12963       'offen', 'in Arbeit' und 'erledigt' bedeuten alle "der Mitarbeiter
12964       ist dran". Es gab keinen, der sagt "SIE sind dran". */
12965    static _taskBadge(s) {
12966        const f = {
12967            wartet_auf_mensch: 'bg-warning text-dark',
12968            in_arbeit: 'bg-info text-dark',
12969            offen: 'bg-light text-dark',
12970            erledigt: 'bg-success',
12971            abgebrochen: 'bg-secondary'
12972        }[s] || 'bg-light text-dark';
12973        const l = {
12974            wartet_auf_mensch: 'wartet auf Sie',
12975            in_arbeit: 'in Arbeit', offen: 'offen',
12976            erledigt: 'erledigt', abgebrochen: 'nicht möglich'
12977        }[s] || s;
12978        return '<span class="badge ' + f + '">' + esc(t(l)) + '</span>';
12979    }
12980
12981    static _akteAuftraege(m) {
12982        const liste = ((this._akteDaten || {}).tasks || []);
12983        const wartet = liste.filter(x => x.status === 'wartet_auf_mensch');
12984        const offen = liste.filter(x => x.status === 'offen' || x.status === 'in_arbeit');
12985        const fertig = liste.filter(x => ['offen', 'in_arbeit', 'wartet_auf_mensch']
12986            .indexOf(x.status) === -1);
12987        const laeuft = wartet.length + offen.length;
12988
12989        const zeile = (x) => {
12990            const wartetJa = x.status === 'wartet_auf_mensch';
12991            const offenJa = x.status === 'offen' || x.status === 'in_arbeit';
12992            return '<li class="list-group-item' + (wartetJa ? ' border-warning' : '') + '">'
12993            + '<div class="d-flex align-items-start gap-2">'
12994            + '<div class="me-auto" style="min-width:0">'
12995            + '<div class="fw-semibold">' + esc(x.titel) + '</div>'
12996            + (x.beschreibung ? '<div class="small text-muted">' + esc(x.beschreibung) + '</div>' : '')
12997            + '<div class="small text-muted mt-1">' + this._taskBadge(x.status)
12998            + ' ' + this._prioBadge(x.prio)
12999            + (x.faellig_am ? ' <i class="fas fa-calendar-day ms-2 me-1"></i>' + esc(this._datum(x.faellig_am)) : '')
13000            + (x.quelle === 'dauerauftrag' ? ' <span class="badge bg-info text-dark ms-2">'
13001                + t('Dauerauftrag') + '</span>' : '')
13002            + (x.quelle === 'fuehrung' ? ' <span class="badge bg-primary ms-2">'
13003                + t('von der Leitung') + '</span>' : '')
13004            + (x.quelle === 'gespraech' ? ' <span class="badge bg-light text-dark ms-2">'
13005                + t('aus einem Gespräch') + '</span>' : '')
13006            + (x.runde > 0 ? ' <span class="text-muted ms-2">' + esc(t('Runde') + ' ' + (x.runde + 1)) + '</span>' : '')
13007            + '</div></div>'
13008            + (offenJa || wartetJa
13009                ? '<button class="btn btn-sm btn-outline-success" data-action="auftrag-status" '
13010                  + 'data-id="' + esc(x.id) + '" data-arg="erledigt" title="' + t('Als erledigt markieren') + '">'
13011                  + '<i class="fas fa-check"></i></button>'
13012                : '')
13013            + '<button class="btn btn-sm btn-outline-danger" data-action="auftrag-del" '
13014            + 'data-id="' + esc(x.id) + '">&times;</button>'
13015            + '</div>'
13016            /* Das Ergebnis wird JETZT IN JEDEM ZUSTAND gezeigt. Der frühere
13017               Ausblender (nur wenn NICHT offen und NICHT in Arbeit) war der
13018               Grund, warum eine Rückfrage am Desktop gar nicht ankam. */
13019            + (x.ergebnis
13020                ? '<div class="small mt-2 p-2 rounded" style="background:#f8f9fa;white-space:pre-wrap">'
13021                  + '<i class="fas fa-arrow-turn-down me-1 text-muted"></i>' + esc(x.ergebnis) + '</div>'
13022                : (x.status === 'offen'
13023                    ? '<div class="small text-muted mt-2">'
13024                      + t('Noch nicht bearbeitet — er arbeitet im 15-Minuten-Takt.') + '</div>'
13025                    : ''))
13026            /* Der Rückkanal. Er steht NUR dort, wo wirklich jemand wartet —
13027               ein Antwortfeld an einem laufenden Auftrag würde suggerieren,
13028               dass eine Antwort erwartet wird. */
13029            + (wartetJa
13030                ? '<div class="mt-2">'
13031                  + '<label class="form-label small mb-1 fw-semibold">'
13032                  + '<i class="fas fa-reply me-1"></i>' + t('Ihre Antwort') + '</label>'
13033                  + '<textarea class="form-control form-control-sm" rows="2" maxlength="4000" '
13034                  + 'id="aiAntw-' + esc(x.id) + '" placeholder="'
13035                  + esc(t('Antworten Sie hier — er arbeitet danach selbständig weiter.')) + '"></textarea>'
13036                  + '<div class="d-flex gap-2 mt-1">'
13037                  /* ai-team-sendeknopf-v1: dieselbe Handlung wie im Chat,
13038                     also dasselbe Aussehen - reines Symbol in derselben
13039                     Masse wie die Diktiertaste daneben. */
13040                  + '<button class="btn btn-sm btn-primary flex-shrink-0" '
13041                  + this._symbolKnopfStil()
13042                  + 'data-action="auftrag-antwort" data-id="' + esc(x.id) + '" '
13043                  + 'title="' + esc(t('Antwort senden')) + '" '
13044                  + 'aria-label="' + esc(t('Antwort senden')) + '">'
13045                  + '<i class="fas fa-paper-plane"></i></button>'
13046                  /* ai-team-mikro-v1: dieselbe Diktiertaste. Die Rueckfrage zu
13047                     beantworten ist derselbe Dialog wie der Chat, nur an
13048                     anderer Stelle gerendert. */
13049                  + this._mikroKnopf('aiAntw-' + x.id, 'btn-sm btn-outline-primary')
13050                  + (x.conversation_id
13051                      ? '<button class="btn btn-sm btn-outline-secondary" data-action="auftrag-faden" '
13052                        + 'data-id="' + esc(x.id) + '">' + t('Verlauf') + '</button>' : '')
13053                  + '</div>'
13054                  + '<div id="aiMikMsg-aiAntw-' + esc(x.id) + '" '
13055                  + 'class="form-text text-warning d-none mt-1"></div>'
13056                  + '</div>'
13057                : (x.conversation_id
13058                    ? '<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" '
13059                      + 'data-action="auftrag-faden" data-id="' + esc(x.id) + '">'
13060                      + '<i class="fas fa-comments me-1"></i>' + t('Verlauf') + '</button></div>' : ''))
13061            + '</li>';
13062        };
13063
13064        return '<div class="d-flex align-items-center mb-2">'
13065            + '<h6 class="mb-0">' + t('Aufträge') + '</h6>'
13066            + '<span class="badge bg-light text-dark ms-2">' + laeuft + ' ' + t('offen') + '</span>'
13067            + (wartet.length
13068                ? '<span class="badge bg-warning text-dark ms-2">' + wartet.length + ' '
13069                  + t('wartet auf Sie') + '</span>' : '')
13070            + ((this._akteDaten || {}).tasks_gekappt
13071                ? '<span class="badge bg-warning text-dark ms-2" title="'
13072                  + esc(t('Es gibt mehr Aufträge, als hier angezeigt werden.')) + '">'
13073                  + t('gekürzt') + '</span>' : '')
13074            + '<button class="btn btn-sm btn-primary ms-auto" data-action="auftrag-neu" '
13075            + 'data-id="' + esc(m.id) + '"><i class="fas fa-plus me-1"></i>' + t('Auftrag geben') + '</button>'
13076            + '</div>'
13077            + (wartet.length
13078                ? '<div class="alert alert-warning py-2 px-2 mb-2" style="font-size:.85rem">'
13079                  + '<i class="fas fa-hand me-1"></i>'
13080                  + esc(t('Er kommt ohne Ihre Antwort nicht weiter.')) + '</div>'
13081                  + '<ul class="list-group mb-2">' + wartet.map(zeile).join('') + '</ul>'
13082                : '')
13083            + (offen.length
13084                ? '<ul class="list-group mb-2">' + offen.map(zeile).join('') + '</ul>'
13085                : (wartet.length ? '' : '<div class="text-muted small mb-2">'
13086                    + t('Keine offenen Aufträge.') + '</div>'))
13087            + (fertig.length
13088                ? '<details><summary class="small text-muted">'
13089                  + t('Abgeschlossen') + ' (' + fertig.length + ')'
13090                  /* Die Zusammensetzung gehoert IN die Ueberschrift. Sonst
13091                     liest jemand, der den Block nicht aufklappt, "erledigt"
13092                     ueber etwas, das nie geschehen ist. */
13093                  + (fertig.some(x => x.status === 'abgebrochen')
13094                      ? ' — ' + fertig.filter(x => x.status === 'erledigt').length
13095                        + ' ' + t('erledigt') + ', '
13096                        + fertig.filter(x => x.status === 'abgebrochen').length
13097                        + ' ' + t('nicht möglich')
13098                      : '')
13099                  + '</summary>'
13100                  + '<ul class="list-group mt-2">' + fertig.map(zeile).join('') + '</ul></details>'
13101                : '');
13102    }
13103
13104    static _akteRoutinen(m) {
13105        const liste = (this._akteDaten || {}).routines || [];
13106        const takt = { stuendlich: 'stündlich', taeglich: 'täglich', werktags: 'werktags',
13107                       woechentlich: 'wöchentlich' };
13108        return '<div class="d-flex align-items-center mb-2">'
13109            + '<h6 class="mb-0">' + t('Daueraufträge') + '</h6>'
13110            + '<button class="btn btn-sm btn-outline-primary ms-auto" data-action="routine-neu" '
13111            + 'data-id="' + esc(m.id) + '"><i class="fas fa-repeat me-1"></i>' + t('Dauerauftrag') + '</button>'
13112            + '</div>'
13113            + '<div class="text-muted small mb-2">'
13114            + t('Wiederkehrende Aufträge — sie landen zur eingestellten Zeit automatisch im Auftragsbuch.')
13115            + '</div>'
13116            + (liste.length
13117                ? '<ul class="list-group">' + liste.map(r =>
13118                    '<li class="list-group-item d-flex align-items-center gap-2">'
13119                    + '<div class="me-auto" style="min-width:0">'
13120                    + '<div class="fw-semibold">' + esc(r.titel) + '</div>'
13121                    + '<div class="small text-muted">' + esc(t(takt[r.takt] || r.takt))
13122                    + (r.takt !== 'stuendlich' ? ' ' + t('ab') + ' ' + esc(String(r.uhrzeit)) + ':00' : '')
13123                    + (r.last_run_at ? ' · ' + t('zuletzt') + ' ' + esc(this._datum(r.last_run_at)) : '')
13124                    + '</div></div>'
13125                    + '<div class="form-check form-switch mb-0">'
13126                    + '<input class="form-check-input" type="checkbox" data-action="routine-an" '
13127                    + 'data-id="' + esc(r.id) + '" data-arg="' + (r.aktiv ? '0' : '1') + '"'
13128                    + (r.aktiv ? ' checked' : '') + '></div>'
13129                    + '<button class="btn btn-sm btn-outline-danger" data-action="routine-del" '
13130                    + 'data-id="' + esc(r.id) + '">&times;</button></li>').join('')
13131                  + '</ul>'
13132                : '<div class="text-muted small">' + t('Noch keine Daueraufträge.') + '</div>')
13133            + this._routineVorschlaege(m);
13134    }
13135
13136    /* ai-team-routinen-v1 — VORSCHLAEGE DER ROLLE
13137       Neu eingestellte Mitarbeiter bekommen ihre Daueraufträge beim
13138       Einstellen. Dieser Block ist für die BESTANDSMITARBEITER: sie
13139       wurden eingestellt, bevor es den Katalog gab, und hätten sonst
13140       dauerhaft keine. Er erscheint nur, solange wirklich etwas fehlt,
13141       und verschwindet von selbst, sobald alles eingeschaltet ist. */
13142    static _routineVorschlaege(m) {
13143        const v = (this._akteDaten || {}).routine_vorschlaege || [];
13144        if (!v.length) { return ''; }
13145        const takt = { stuendlich: 'stündlich', taeglich: 'täglich',
13146                       werktags: 'werktags', woechentlich: 'wöchentlich' };
13147        const tage = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag',
13148                      'Freitag', 'Samstag', 'Sonntag'];
13149        /* Der Rhythmus steht bewusst DABEI und nicht erst nach dem
13150           Einschalten: der Inhaber soll vorher wissen, wie oft ihn das
13151           erreicht — sonst schaltet er ein und gleich wieder ab. */
13152        const wann = (x) => {
13153            if (x.takt === 'woechentlich') {
13154                return t(tage[x.wochentag || 0]) + ' ' + t('ab') + ' '
13155                     + String(x.uhrzeit === undefined ? 8 : x.uhrzeit) + ':00';
13156            }
13157            if (x.takt === 'stuendlich') { return t('stündlich'); }
13158            return t(takt[x.takt] || x.takt) + ' ' + t('ab') + ' '
13159                 + String(x.uhrzeit === undefined ? 8 : x.uhrzeit) + ':00';
13160        };
13161        return '<div class="border rounded p-2 mt-3" style="background:#f8fbfd">'
13162            + '<div class="fw-semibold mb-1">'
13163            + t('Vorschläge für diese Rolle') + '</div>'
13164            + '<div class="small text-muted mb-2">'
13165            + esc(m.name || '') + ' '
13166            + t('wartet ohne Daueraufträge auf Ihre Anweisung. Diese hier passen zu seiner Rolle und zu dem, worauf er Zugriff hat:')
13167            + '</div>'
13168            + '<ul class="list-unstyled mb-2">'
13169            + v.map(x => '<li class="mb-1"><i class="fas fa-repeat text-muted me-2"></i>'
13170                + '<span class="fw-semibold">' + esc(x.titel) + '</span>'
13171                + ' <span class="small text-muted">
13171· ' + esc(wann(x)) + '</span></li>').join('')
13172            + '</ul>'
13173            + '<button class="btn btn-sm btn-primary" data-action="routine-vorbelegen" '
13174            + 'data-id="' + esc(m.id) + '">'
13175            + t('Einschalten') + ' (' + v.length + ')</button>'
13176            + '<div class="small text-muted mt-1">'
13177            + t('Sie können jeden einzeln wieder abschalten.') + '</div>'
13178            + '</div>';
13179    }
13180
13181    static _auftragFragen(id) {
13182        const m = (this._members || []).find(x => x.id === id)
13183            || ((this._akteDaten || {}).member || {});
13184        /* aiteam-bugfix-ui-v1: toISOString() liefert das UTC-Datum. Zwischen
13185           Mitternacht und 2 Uhr deutscher Sommerzeit ist das der VORTAG - das
13186           Feld erlaubte dann eine Fälligkeit in der Vergangenheit. */
13187        const _n = new Date();
13188        const heute = _n.getFullYear() + '-'
13189            + String(_n.getMonth() + 1).padStart(2, '0') + '-'
13190            + String(_n.getDate()).padStart(2, '0');
13191        this._modal('<div class="modal-header"><h5 class="modal-title">'
13192            + t('Auftrag geben') + ' — ' + esc(m.name || '') + '</h5>'
13193            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
13194            + '<div class="modal-body">'
13195            + '<div class="mb-2"><label class="form-label">' + t('Was soll erledigt werden?') + '</label>'
13196            + '<input id="aiAufTitel" class="form-control" maxlength="200" '
13197            + 'placeholder="' + esc(t('z. B.: Alle offenen Angebote aus Juli nachfassen')) + '"></div>'
13198            + '<div class="mb-2"><label class="form-label">' + t('Details (optional)') + '</label>'
13199            + '<textarea id="aiAufText" class="form-control" rows="3" maxlength="4000"></textarea></div>'
13200            + '<div class="row g-2">'
13201            + '<div class="col-6"><label class="form-label">' + t('Priorität') + '</label>'
13202            + '<select id="aiAufPrio" class="form-select">'
13203            + '<option value="hoch">' + t('hoch') + '</option>'
13204            + '<option value="normal" selected>' + t('normal') + '</option>'
13205            + '<option value="niedrig">' + t('niedrig') + '</option></select></div>'
13206            + '<div class="col-6"><label class="form-label">' + t('Fällig bis') + '</label>'
13207            + '<input id="aiAufDatum" type="date" class="form-control" min="' + heute + '"></div>'
13208            + '</div>'
13209            + '<div class="alert alert-light border small mt-3 mb-0">'
13210            + t('Er nimmt den Auftrag an und meldet im Tagesbericht, was er davon selbst erledigen konnte. Was er nicht kann, bleibt sichtbar offen — nichts wird stillschweigend als erledigt gemeldet.')
13211            + '</div>'
13212            + '<div id="aiAufFehler" class="small text-danger mt-2"></div>'
13213            + '<div class="d-flex justify-content-end gap-2 mt-3">'
13214            + '<button class="btn btn-outline-secondary" data-action="modal-zu">' + t('Abbrechen') + '</button>'
13215            + '<button class="btn btn-primary" data-action="auftrag-senden" data-id="' + esc(id) + '">'
13216            + t('Auftrag geben') + '</button></div></div>');
13217    }
13218
13219    static async _auftragSenden(id) {
13220        const titel = (document.getElementById('aiAufTitel') || {}).value || '';
13221        const feh = document.getElementById('aiAufFehler');
13222        if (!titel.trim()) { if (feh) feh.textContent = t('Bitte sagen Sie, was erledigt werden soll.'); return; }
13223        try {
13224            await api('/api/ai-team/tasks', 'POST', {
13225                employee_id: id, titel: titel,
13226                beschreibung: (document.getElementById('aiAufText') || {}).value || '',
13227                prio: (document.getElementById('aiAufPrio') || {}).value || 'normal',
13228                faellig_am: (document.getElementById('aiAufDatum') || {}).value || null,
13229            });
13230        } catch (e) { if (feh) { feh.textContent = e.message; return; } return this._modalFehler(e); }
13231        this._modal('');
13232        if (this._akteId === id) await this._akteOeffnen(id);
13233        else await this._neuLaden('board');
13234    }
13235
13236    static async _auftragStatus(id, status) {
13237        try {
13238            await api('/api/ai-team/tasks/' + encodeURIComponent(id), 'PUT', { status: status });
13239        } catch (e) { return this._modalFehler(e); }
13240        if (this._akteId) await this._akteOeffnen(this._akteId);
13241    }
13242
13243    /* aiteam-auftrag-v2: Die Antwort des Menschen. Sie geht in den Faden
13244       des Auftrags, der Zustand fällt auf 'offen' zurück und die RUNDE
13245       steigt — erst dadurch sieht der Mitarbeiter den Auftrag im nächsten
13246       Takt wieder an. */
13247    static async _auftragAntwort(id) {
13248        const feld = document.getElementById('aiAntw-' + id);
13249        const text = feld ? String(feld.value || '').trim() : '';
13250        if (!text) {
13251            if (feld) { try { feld.focus(); } catch (_) {} }
13252            return this._modalFehler(new Error(t('Bitte schreiben Sie eine Antwort.')));
13253        }
13254        /* Doppelklick-Sperre: zwei Antworten würden zwei Runden erzeugen
13255           und den Auftrag doppelt anstoßen. */
13256        const knopf = document.querySelector('[data-action="auftrag-antwort"][data-id="'
13257            + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
13258        if (knopf) {
13259            if (knopf.dataset.laeuft === '1') return;
13260            knopf.dataset.laeuft = '1';
13261            knopf.disabled = true;
13262        }
13263        try {
13264            await api('/api/ai-team/tasks/' + encodeURIComponent(id) + '/antwort',
13265                      'POST', { text: text });
13266        } catch (e) {
13267            if (knopf) { knopf.dataset.laeuft = ''; knopf.disabled = false; }
13268            return this._modalFehler(e);
13269        }
13270        if (this._akteId) await this._akteOeffnen(this._akteId);
13271    }
13272
13273    /* Der Verlauf EINES Auftrags — Rückfragen, Antworten und die
13274       Werkzeugspuren dazwischen. Dieselbe Ablage wie ein Gespräch. */
13275    static async _auftragFaden(id) {
13276        let d;
13277        try {
13278            d = await api('/api/ai-team/tasks/' + encodeURIComponent(id) + '/faden');
13279        } catch (e) { return this._modalFehler(e); }
13280        const faden = (d && d.faden) || [];
13281        const tk = (d && d.task) || {};
13282        const bl = (x) => {
13283            const ich = x.von === 'mensch';
13284            const wz = (x.werkzeuge || []).map(w =>
13285                '<span class="badge bg-light text-dark me-1">'
13286                + '<i class="fas fa-wrench me-1"></i>' + esc(w.label || w.tool || '')
13287                + (w.modus === 'gesperrt' ? ' — ' + t('nicht erlaubt') : '')
13288                + (w.modus === 'freigabe' ? ' — ' + t('wartet auf Freigabe') : '')
13289                + '</span>').join('');
13290            return '<div class="mb-3">'
13291                + '<div class="small fw-semibold text-muted mb-1">'
13292                + (ich ? t('Sie') : esc(tk.mitarbeiter || t('Mitarbeiter')))
13293                + (x.zeit ? ' <span class="fw-normal">' + esc(this._datum(x.zeit)) + '</span>' : '')
13294                + '</div>'
13295                + '<div class="p-2 rounded" style="white-space:pre-wrap;background:'
13296                + (ich ? '#e7f1ff' : '#f8f9fa') + '">' + esc(x.text || '') + '</div>'
13297                + (wz ? '<div class="mt-1">' + wz + '</div>' : '')
13298                + '</div>';
13299        };
13300        this._modal('<div class="modal-header"><h5 class="modal-title">'
13301            + t('Verlauf') + ' — ' + esc(tk.titel || '') + '</h5>'
13302            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
13303            + '<div class="modal-body" style="max-height:65vh;overflow:auto">'
13304            + (faden.length ? faden.map(bl).join('')
13305                : (d && d.faden_stand === 'verloren'
13306                    /* ai-team-faden-verloren-v1: Der Auftrag traegt eine
13307                       Faden-Kennung, der Faden selbst fehlt. Das ist etwas
13308                       anderes als "es gab nie ein Gespraech" und muss auch
13309                       anders dastehen - sonst sucht der Inhaber einen
13310                       Verlauf, den niemand mehr herstellen kann. Der
13311                       Verweis auf das Ergebnis ist der eigentliche Nutzen:
13312                       die Arbeit ist nicht weg, nur der Weg dorthin. */
13313                    ? '<div class="alert alert-warning small mb-0">'
13314                      + '<i class="fas fa-triangle-exclamation me-1"></i>'
13315                      + t('Der Gesprächsverlauf zu diesem Auftrag ist durch eine '
13316                        + 'Störung am 17.08.2026 verlorengegangen und lässt sich '
13317                        + 'nicht wiederherstellen.')
13318                      + '<div class="mt-2">'
13319                      + t('Die Arbeit selbst ist erhalten: das Ergebnis steht im '
13320                        + 'Auftrag.')
13321                      + '</div></div>'
13322                    : '<div class="text-muted small">' + t('Noch kein Verlauf.') + '</div>'))
13323            + '</div>'
13324            + '<div class="modal-footer"><button class="btn btn-secondary" '
13325            + 'data-action="modal-zu">' + t('Schließen') + '</button></div>');
13326    }
13327
13328    static async _auftragDel(id) {
13329        try {
13330            await api('/api/ai-team/tasks/' + encodeURIComponent(id), 'DELETE');
13331        } catch (e) { return this._modalFehler(e); }
13332        if (this._akteId) await this._akteOeffnen(this._akteId);
13333    }
13334
13335    static _routineFragen(id) {
13336        this._modal('<div class="modal-header"><h5 class="modal-title">'
13337            + t('Dauerauftrag anlegen') + '</h5>'
13338            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
13339            + '<div class="modal-body">'
13340            + '<div class="mb-2"><label class="form-label">' + t('Was soll regelmäßig passieren?') + '</label>'
13341            + '<input id="aiRoutTitel" class="form-control" maxlength="200" '
13342            + 'placeholder="' + esc(t('z. B.: Offene Vorgänge des Vortags durchsehen')) + '"></div>'
13343            + '<div class="mb-2"><label class="form-label">' + t('Anweisung (optional)') + '</label>'
13344            + '<textarea id="aiRoutText" class="form-control" rows="2" maxlength="2000"></textarea></div>'
13345            + '<div class="row g-2">'
13346            + '<div class="col-6"><label class="form-label">' + t('Takt') + '</label>'
13347            + '<select id="aiRoutTakt" class="form-select">'
13348            + '<option value="taeglich" selected>' + t('täglich') + '</option>'
13349            + '<option value="werktags">' + t('werktags') + '</option>'
13350            + '<option value="woechentlich">' + t('wöchentlich (montags)') + '</option>'
13351            + '<option value="stuendlich">
13351' + t('stündlich') + '</option></select></div>'
13352            + '<div class="col-6"><label class="form-label">' + t('Ab welcher Uhrzeit?') + '</label>'
13353            + '<input id="aiRoutStunde" type="number" min="0" max="23" value="8" class="form-control"></div>'
13354            + '</div>'
13355            + '<div id="aiRoutFehler" class="small text-danger mt-2"></div>'
13356            + '<div class="d-flex justify-content-end gap-2 mt-3">'
13357            + '<button class="btn btn-outline-secondary" data-action="modal-zu">' + t('Abbrechen') + '</button>'
13358            + '<button class="btn btn-primary" data-action="routine-senden" data-id="' + esc(id) + '">'
13359            + t('Dauerauftrag anlegen') + '</button></div></div>');
13360    }
13361
13362    static async _routineSenden(id) {
13363        const titel = (document.getElementById('aiRoutTitel') || {}).value || '';
13364        const feh = document.getElementById('aiRoutFehler');
13365        if (!titel.trim()) { if (feh) feh.textContent = t('Bitte geben Sie dem Dauerauftrag einen Titel.'); return; }
13366        try {
13367            await api('/api/ai-team/routines', 'POST', {
13368                employee_id: id, titel: titel,
13369                anweisung: (document.getElementById('aiRoutText') || {}).value || '',
13370                takt: (document.getElementById('aiRoutTakt') || {}).value || 'taeglich',
13371                uhrzeit: parseInt((document.getElementById('aiRoutStunde') || {}).value || '8', 10),
13372                wochentag: 0,
13373            });
13374        } catch (e) { if (feh) { feh.textContent = e.message; return; } return this._modalFehler(e); }
13375        this._modal('');
13376        if (this._akteId) await this._akteOeffnen(this._akteId);
13377    }
13378
13379    static async _routineToggle(id, an) {
13380        try {
13381            await api('/api/ai-team/routines/' + encodeURIComponent(id), 'PUT', { aktiv: an });
13382        } catch (e) { return this._modalFehler(e); }
13383        if (this._akteId) await this._akteOeffnen(this._akteId);
13384    }
13385
13386    static async _routineVorbelegen(id) {
13387        /* Der Knopf wird sofort gesperrt: das Anlegen fasst mehrere
13388           Daueraufträge an, ein zweiter Klick währenddessen würde die
13389           Antwort des ersten überholen. Bei einem Fehler wird er wieder
13390           bedienbar — sonst steht der Inhaber vor einem toten Knopf. */
13391        const knopf = document.querySelector('[data-action="routine-vorbelegen"]');
13392        if (knopf) { knopf.disabled = true; knopf.textContent = t('Wird eingeschaltet …'); }
13393        try {
13394            await api('/api/ai-team/routines/vorbelegen', 'POST', { employee_id: id });
13395        } catch (e) {
13396            if (knopf) { knopf.disabled = false; knopf.textContent = t('Einschalten'); }
13397            return this._modalFehler(e);
13398        }
13399        await this._akteOeffnen(id);
13400    }
13401
13402    static async _routineDel(id) {
13403        try {
13404            await api('/api/ai-team/routines/' + encodeURIComponent(id), 'DELETE');
13405        } catch (e) { return this._modalFehler(e); }
13406        if (this._akteId) await this._akteOeffnen(this._akteId);
13407    }
13408
13409    static async _orgSpeichern(id) {
13410        const dep = (document.getElementById('aiOrgDep') || {}).value;
13411        const chef = (document.getElementById('aiOrgChef') || {}).value;
13412        const hin = document.getElementById('aiOrgHinweis');
13413        if (hin) hin.textContent = '';
13414        try {
13415            await api('/api/ai-team/members/' + encodeURIComponent(id) + '/organisation', 'PUT',
13416                { department_id: dep || null, vorgesetzter_id: chef || null });
13417        } catch (e) {
13418            if (hin) { hin.textContent = e.message; return; }
13419            return this._modalFehler(e);
13420        }
13421        await this._akteOeffnen(id);
13422    }
13423
13424    /** ai-team-board-v3 — Abteilungen an EINEM Ort verwalten.
13425     *  Vorher hing der Papierkorb an der Gruppen-Ueberschrift; im
13426     *  ungruppierten Board gaebe es dann gar keinen Weg mehr dorthin. */
13427    static _abtVerwalten() {
13428        const zaehl = (id) => this._members
13429            .filter(m => (m.department_id || '') === id).length;
13430        const zeilen = (this._departments || []).map(d => {
13431            const n = zaehl(d.id);
13432            const fest = !!d.system_key;
13433            return '<div class="d-flex align-items-center gap-2 py-2" '
13434                + 'style="border-bottom:1px solid #f1f3f5">'
13435                + '<span class="d-inline-flex align-items-center justify-content-center '
13436                + 'rounded-2 flex-shrink-0" style="width:26px;height:26px;background:'
13437                + esc(d.farbe || '#6c757d') + '1f"><i class="fas '
13438                + esc(d.icon || 'fa-building') + '" style="color:'
13439                + esc(d.farbe || '#6c757d') + ';font-size:.8rem"></i></span>'
13440                + '<span class="me-auto">' + esc(d.name) + '</span>'
13441                + '<span class="text-muted" style="font-size:.82rem">'
13442                + (n ? n + ' ' + t(n === 1 ? 'Mitarbeiter' : 'Mitarbeiter')
13443                     : t('nicht besetzt')) + '</span>'
13444                + (fest
13445                    ? '<span class="text-muted" style="font-size:.78rem">'
13446                      + t('fest') + '</span>'
13447                    : '<button class="btn btn-sm btn-link text-danger p-0 ms-2" '
13448                      + 'data-action="abt-del" data-id="' + esc(d.id) + '">'
13449                      + t('Löschen') + '</button>')
13450                + '</div>';
13451        }).join('');
13452        this._modal('<div class="modal-header"><h5 class="modal-title">'
13453            + t('Abteilungen') + '</h5>'
13454            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
13455            + '<div class="modal-body">'
13456            + (zeilen || '<div class="text-muted">' + t('Noch keine Abteilung.') + '</div>')
13457            + '<div class="text-muted mt-3" style="font-size:.82rem">'
13458            + t('Beim Löschen bleiben die Mitarbeiter erhalten — sie stehen danach ohne Abteilung.')
13459            + '</div>'
13460            + '<div class="d-flex justify-content-end gap-2 mt-3">'
13461            + '<button class="btn btn-outline-secondary" data-action="modal-zu">'
13462            + t('Schließen') + '</button>'
13463            + '<button class="btn btn-primary" data-action="abt-neu">'
13464            + t('Neue Abteilung') + '</button>'
13465            + '</div></div>');
13466    }
13467
13468    static _abtFragen() {
13469        this._modal('<div class="modal-header"><h5 class="modal-title">'
13470            + t('Neue Abteilung') + '</h5>'
13471            + '<button type="button" class="btn-close" data-action="modal-zu"></button></div>'
13472            + '<div class="modal-body">'
13473            + '<label class="form-label">' + t('Name der Abteilung') + '</label>'
13474            + '<input id="aiAbtName" class="form-control" maxlength="60" '
13475            + 'placeholder="' + esc(t('z. B.: Kundenservice')) + '">'
13476            + '<div id="aiAbtFehler" class="small text-danger mt-2"></div>'
13477            + '<div class="d-flex justify-content-end gap-2 mt-3">'
13478            + '<button class="btn btn-outline-secondary" data-action="modal-zu">' + t('Abbrechen') + '</button>'
13479            + '<button class="btn btn-primary" data-action="abt-senden">' + t('Anlegen') + '</button>'
13480            + '</div></div>');
13481    }
13482
13483    static async _abtSenden() {
13484        const name = (document.getElementById('aiAbtName') || {}).value || '';
13485        const feh = document.getElementById('aiAbtFehler');
13486        if (!name.trim()) { if (feh) feh.textContent = t('Bitte einen Namen eingeben.'); return; }
13487        try {
13488            await api('/api/ai-team/departments', 'POST', { name: name });
13489        } catch (e) { if (feh) { feh.textContent = e.message; return; } return this._modalFehler(e); }
13490        this._modal('');
13491        await this._neuLaden('board');
13492    }
13493
13494    static async _abtDel(id) {
13495        try {
13496            await api('/api/ai-team/departments/' + encodeURIComponent(id), 'DELETE');
13497        } catch (e) { return this._modalFehler(e); }
13498        await this._neuLaden('board');
13499    }
13500
13501    // ------------------------------------------------------- Organigramm
13502
13503    static async _renderOrg(host) {
13504        const seq = this._seq;
13505        host.innerHTML = '<div class="text-muted">' + t('Wird geladen …') + '</div>';
13506        let d;
13507        try {
13508            d = await api('/api/ai-team/org');
13509        } catch (e) {
13510            if (seq !== this._seq) return;
13511            host.innerHTML = '<div class="alert alert-danger">' + esc(e.message) + '</div>';
13512            return;
13513        }
13514        if (seq !== this._seq) return;
13515        this._org = d;
13516        host.innerHTML = this._orgHtml(d);
13517        requestAnimationFrame(() => this._orgEinpassen());
13518    }
13519
13520    /* aiteam-organigramm-v1 -------------------------------------------
13521       Bis hierher war das ein eingerueckte LISTE (margin-left je Ebene).
13522       Michaels Befund vom 12.09.2026: "es ist nicht ersichtlich welche
13523       Mitarbeiter zum Beispiel der CMO hat". Eine Einrueckung von 24px
13524       liest sich nicht als Unterstellung -- ein Kasten mit einer Linie
13525       darunter schon.
13526
13527       Die Liste bleibt als ZWEITE Ansicht erhalten und ist kein
13528       Beiwerk: ein Baum mit 30 Mitarbeitern auf einer Ebene ist
13529       breiter als jeder Bildschirm. Wer viele hat, braucht die Liste.  */
13530
13531    /** aiteam-orgbreite-v1 -- Michaels Befund 12.09.2026: "das organigram
13532     *  wird nicht komplett dargestellt".
13533     *
13534     *  Der Baum ist breiter als der Kasten, sobald mehrere Fuehrungskraefte
13535     *  eigene Leute haben (gemessen: 9 Blaetter = 1584px gegen ~1200px).
13536     *  Zwei Dinge muessen dann stimmen:
13537     *
13538     *  1. Das SCROLLEN muss ueberhaupt funktionieren. Das war der Fehler
13539     *     und steckt im CSS (.vo-orgb) -- nicht hier.
13540     *  2. "Nicht komplett dargestellt" ist mit Scrollen nur halb
13541     *     beantwortet. Deshalb wird bis zur Lesbarkeitsgrenze
13542     *     automatisch verkleinert, sodass der ganze Baum auf einmal zu
13543     *     sehen ist.
13544     *
13545     *  Unterhalb von MIN wird der Text unlesbar. Dann NICHT verkleinern,
13546     *  sondern scrollen lassen und offen auf die Liste verweisen -- eine
13547     *  Grafik, die niemand entziffern kann, ist keine Darstellung.  */
13548    static _orgEinpassen() {
13549        const wrap = document.querySelector('.vo-orgwrap');
13550        const baum = wrap && wrap.firstElementChild;
13551        const knopf = document.getElementById('aiOrgFit');
13552        const hint = document.getElementById('aiOrgHint');
13553        if (!wrap || !baum) return;
13554
13555        const MIN = 0.5;
13556
13557        // Erst zuruecksetzen, sonst wird die bereits verkleinerte Breite
13558        // gemessen und der Faktor schrumpft bei jedem Aufruf weiter.
13559        baum.style.transform = '';
13560        baum.style.marginBottom = '';
13561        wrap.style.overflowX = 'auto';
13562
13563        const platz = wrap.clientWidth - 6;
13564        const voll = Math.max(baum.scrollWidth, baum.offsetWidth);
13565        if (platz <= 0 || voll <= 0) return;
13566
13567        const zuBreit = voll > platz + 1;
13568        const faktor = zuBreit ? platz / voll : 1;
13569        const machbar = faktor >= MIN;
13570        const gewuenscht = (this._orgFit === true || this._orgFit === false)
13571            ? this._orgFit
13572            : true;                       // Vorgabe: einpassen, wenn moeglich
13573        const an = zuBreit && machbar && gewuenscht;
13574        this._orgFitIst = an;
13575
13576        if (an) {
13577            const h = baum.offsetHeight;
13578            baum.style.transformOrigin = 'top left';
13579            baum.style.transform = 'scale(' + faktor.toFixed(4) + ')';
13580            // scale aendert das Layout nicht -- ohne die negative Marge
13581            // bliebe unten der Leerraum der unverkleinerten Hoehe stehen.
13582            baum.style.marginBottom = '-' + Math.round(h * (1 - faktor)) + 'px';
13583            wrap.style.overflowX = 'hidden';
13584        } else if (zuBreit) {
13585            // Mittig starten: am linken Rand sieht man sonst nur die Kante
13586            // der ersten Karte und haelt es fuer denselben Fehler.
13587            wrap.scrollLeft = Math.round((voll - platz) / 2);
13588        }
13589
13590        if (knopf) {
13591            if (!zuBreit || !machbar) {
13592                knopf.style.display = 'none';   // ein Knopf ohne Wirkung ist schlimmer als keiner
13593            } else {
13594                knopf.style.display = '';
13595                knopf.innerHTML = an
13596                    ? '<i class="fas fa-expand me-1"></i>' + t('Originalgröße')
13597                    : '<i class="fas fa-compress me-1"></i>' + t('Einpassen');
13598                knopf.title = an
13599                    ? t('Baum in Originalgröße zeigen — seitlich scrollbar')
13600                    : t('Ganzen Baum auf die Breite einpassen');
13601            }
13602        }
13603        if (hint) {
13604            if (an) {
13605                hint.style.display = '';
13606                hint.innerHTML = '<i class="fas fa-compress me-1 opacity-50"></i>'
13607                    + t('Auf die Breite eingepasst') + ' (' + Math.round(faktor * 100) + '%).';
13608            } else if (zuBreit) {
13609                hint.style.display = '';
13610                hint.innerHTML = '<i class="fas fa-arrows-left-right me-1 opacity-50"></i>'
13611                    + t('Der Baum ist breiter als der Bildschirm — seitlich scrollen, '
13612                        + 'oder für große Teams die Liste nutzen.');
13613            } else {
13614                hint.style.display = 'none';
13615            }
13616        }
13617
13618        // Ohne das bliebe der Baum nach dem Vergroessern des Fensters
13619        // winzig. Einmalig registriert, wirkt nur bei sichtbarem Baum.
13620        if (!this._orgResize) {
13621            this._orgResize = () => {
13622                clearTimeout(this._orgResizeT);
13623                this._orgResizeT = setTimeout(() => {
13624                    if (document.querySelector('.vo-orgwrap')) this._orgEinpassen();
13625                }, 150);
13626            };
13627            window.addEventListener('resize', this._orgResize);
13628        }
13629    }
13630
13631    static _orgKinder(id) {
13632        return (((this._org || {}).mitarbeiter) || [])
13633            .filter(x => (x.vorgesetzter_id || null) === (id || null));
13634    }
13635
13636    static _orgTitel(m) {
13637        return m.titel || this._rolleTitel(m.role_key);
13638    }
13639
13640    /** Ein Kasten im Baum. Klick oeffnet die Akte, das Zahnrad springt
13641     *  direkt in die Einordnung -- ohne diesen zweiten Weg sieht man im
13642     *  Organigramm zwar, dass jemand falsch haengt, kann es aber nicht
13643     *  aendern. */
13644    static _orgKasten(m) {
13645        const farbe = ROLLEN_FARBEN[m.role_key] || '#0d6efd';
13646        const st = STATUS_ANZEIGE[m.status] || ['#6c757d', m.status];
13647        const n = this._orgKinder(m.id).length;
13648        return '<div class="vo-orgkasten" style="border-top:3px solid ' + farbe + '">'
13649            + '<button class="vo-orgzahn" data-action="emp-reiter" data-id="' + esc(m.id) + '" '
13650            + 'data-reiter="einst" title="' + t('Einordnung ändern') + '">'
13651            + '<i class="fas fa-sliders"></i></button>'
13652            + '<div class="vo-orgklick" data-action="akte" data-id="' + esc(m.id) + '" '
13653            + 'title="' + t('Akte öffnen') + '">'
13654            + this._gesichtBild(m, 42, farbe)
13655            + '<div class="vo-orgname">' + esc(m.name) + '</div>'
13656            + '<div class="vo-orgrolle">' + esc(this._orgTitel(m)) + '</div>'
13657            + '<div class="vo-orgfuss">'
13658            + '<span style="color:' + st[0] + ';font-size:.6rem">●</span> ' + esc(t(st[1]))
13659            + (n ? ' <span class="vo-orgzahl">' + n + '</span>' : '')
13660            + '</div></div></div>';
13661    }
13662
13663    /** @param gesehen  Kreis-Schutz. Serverseitig verhindert _kette_ok
13664     *  einen Kreis im Berichtsweg; kaeme er trotzdem einmal zustande
13665     *  (Altdaten, direkter Schreibzugriff), wuerde eine Rekursion ohne
13666     *  diese Menge den Browser aufhaengen statt eine Zeile zu verlieren. */
13667    static _orgKnoten(m, gesehen) {
13668        if (gesehen.has(m.id)) return '';
13669        gesehen.add(m.id);
13670        const kinder = this._orgKinder(m.id);
13671        return '<li>' + this._orgKasten(m)
13672            + (kinder.length
13673                ? '<ul>' + kinder.map(k => this._orgKnoten(k, gesehen)).join('') + '</ul>'
13674                : '')
13675            + '</li>';
13676    }
13677
13678    static get ORG_CSS() {
13679        return '<style>'
13680            + '.vo-orgwrap{overflow-x:auto;padding:6px 2px 20px}'
13681            + '.vo-orgb{display:inline-block;min-width:100%}'
13682            + '.vo-orgb ul{display:flex;justify-content:center;list-style:none;'
13683            + 'margin:0;padding:26px 0 0;position:relative}'
13684            + '.vo-orgb>ul{padding-top:0}'
13685            + '.vo-orgb li{position:relative;padding:26px 9px 0;display:flex;'
13686            + 'flex-direction:column;align-items:center}'
13687            + '.vo-orgb>ul>li{padding-top:0}'
13688            + '.vo-orgb li::before,.vo-orgb li::after{content:"";position:absolute;'
13689            + 'top:0;right:50%;border-top:2px solid #d7dce1;width:50%;height:26px}'
13690            + '.vo-orgb li::after{right:auto;left:50%;border-left:2px solid #d7dce1}'
13691            + '.vo-orgb>ul>li::before,.vo-orgb>ul>li::after{display:none}'
13692            + '.vo-orgb li:only-child::before,.vo-orgb li:only-child::after{display:none}'
13693            + '.vo-orgb li:only-child{padding-top:0}'
13694            + '.vo-orgb li:first-child::before,.vo-orgb li:last-child::after{border:0 none}'
13695            + '.vo-orgb li:last-child::before{border-right:2px solid #d7dce1;'
13696            + 'border-radius:0 7px 0 0}'
13697            + '.vo-orgb li:first-child::after{border-radius:7px 0 0 0}'
13698            + '.vo-orgb ul ul::before{content:"";position:absolute;top:0;left:50%;'
13699            + 'border-left:2px solid #d7dce1;width:0;height:26px}'
13700            + '.vo-orgkasten{position:relative;width:158px;background:#fff;'
13701            + 'border:1px solid #e6e9ec;border-radius:9px;'
13702            + 'box-shadow:0 1px 3px rgba(0,0,0,.06);text-align:center}'
13703            + '.vo-orgklick{padding:12px 8px 10px;cursor:pointer}'
13704            + '.vo-orgkasten:hover{box-shadow:0 3px 10px rgba(0,0,0,.12)}'
13705            + '.vo-orgname{font-weight:600;margin-top:6px;font-size:.9rem;'
13706            + 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
13707            + '.vo-orgrolle{font-size:.76rem;color:#6c757d;line-height:1.25;'
13708            + 'margin-top:1px;overflow:hidden;display:-webkit-box;'
13709            + '-webkit-line-clamp:2;-webkit-box-orient:vertical}'
13710            + '.vo-orgfuss{font-size:.72rem;color:#6c757d;margin-top:7px;'
13711            + 'padding-top:6px;border-top:1px solid #f1f3f5}
13711'
13712            + '.vo-orgzahl{display:inline-block;background:#eef1f4;color:#495057;'
13713            + 'border-radius:9px;padding:0 6px;margin-left:3px;font-weight:600}'
13714            + '.vo-orgzahn{position:absolute;top:5px;right:5px;border:0;'
13715            + 'background:transparent;color:#c3c9cf;font-size:.72rem;padding:2px 4px;'
13716            + 'line-height:1;cursor:pointer}'
13717            + '.vo-orgzahn:hover{color:#0d6efd}'
13718            + '.vo-orgich{width:180px;background:#f6f2ff;border-color:#ddd0fb;'
13719            + 'border-top:3px solid #7048c4;border-radius:9px;padding:12px 10px;'
13720            + 'text-align:center}'
13721            + '</style>';
13722    }
13723
13724    static _orgHtml(d) {
13725        const leute = d.mitarbeiter || [];
13726        const grafik = this._orgAnsicht !== 'liste';
13727        const wurzeln = this._orgKinder(null);
13728        const gesehen = new Set();
13729
13730        const umschalter = '<div class="btn-group btn-group-sm">'
13731            + '<button class="btn btn-' + (grafik ? '' : 'outline-') + 'secondary" '
13732            + 'data-action="org-ansicht" data-id="grafik">'
13733            + '<i class="fas fa-sitemap me-1"></i>' + t('Diagramm') + '</button>'
13734            + '<button class="btn btn-' + (grafik ? 'outline-' : '') + 'secondary" '
13735            + 'data-action="org-ansicht" data-id="liste">'
13736            + '<i class="fas fa-list-ul me-1"></i>' + t('Liste') + '</button></div>'
13737            + (grafik
13738                ? '<button class="btn btn-sm btn-outline-secondary ms-2" id="aiOrgFit" '
13739                  + 'data-action="org-fit" style="display:none"></button>'
13740                : '');
13741
13742        // --- Diagramm
13743        const ich = '<div class="vo-orgich">'
13744            + '<i class="fas fa-user-tie" style="color:#7048c4;font-size:1.15rem"></i>'
13745            + '<div style="font-weight:600;margin-top:5px">' + t('Sie (Inhaber)') + '</div>'
13746            + '<div style="font-size:.76rem;color:#6c757d">'
13747            + wurzeln.length + ' ' + t('berichten direkt') + '</div></div>';
13748        const baum = '<div class="vo-orgwrap"><div class="vo-orgb"><ul><li>' + ich
13749            + (wurzeln.length
13750                ? '<ul>' + wurzeln.map(m => this._orgKnoten(m, gesehen)).join('') + '</ul>'
13751                : '')
13752            + '</li></ul></div></div>'
13753            + '<div id="aiOrgHint" class="text-muted small" '
13754              + 'style="display:none;margin-top:-12px"></div>';
13755
13756        // --- Liste (Rueckfallebene fuer grosse Teams)
13757        const gesehenL = new Set();
13758        const zeile = (m, tiefe) => {
13759            if (gesehenL.has(m.id)) return '';
13760            gesehenL.add(m.id);
13761            const farbe = ROLLEN_FARBEN[m.role_key] || '#0d6efd';
13762            const st = STATUS_ANZEIGE[m.status] || ['#6c757d', m.status];
13763            return '<div style="margin-left:' + (tiefe * 26) + 'px" class="mb-2">'
13764                + '<div class="d-inline-flex align-items-center gap-2 border rounded px-2 py-1" '
13765                + 'style="cursor:pointer;background:#fff" data-action="akte" data-id="'
13766                + esc(m.id) + '">'
13767                + this._gesichtBild(m, 30, farbe)
13768                + '<div class="text-start"><div class="fw-semibold" style="font-size:.9rem">'
13769                + esc(m.name) + '</div>'
13770                + '<div class="text-muted" style="font-size:.75rem">'
13771                + esc(this._orgTitel(m)) + '</div></div>'
13772                + '<span class="badge ms-1" style="background:' + st[0] + '">'
13773                + esc(t(st[1])) + '</span></div>'
13774                + this._orgKinder(m.id).map(k => zeile(k, tiefe + 1)).join('')
13775                + '</div>';
13776        };
13777        const liste = '<div class="d-inline-flex align-items-center gap-2 border '
13778            + 'border-primary rounded px-2 py-1 mb-3">'
13779            + '<i class="fas fa-user-tie text-primary"></i><strong>'
13780            + t('Sie (Inhaber)') + '</strong></div>'
13781            + (wurzeln.length ? wurzeln.map(m => zeile(m, 1)).join('')
13782                : '<div class="text-muted small">'
13783                  + t('Noch keine KI-Mitarbeiter eingestellt.') + '</div>');
13784
13785        // Der Hinweis ist der eigentliche Zweck der Ansicht: eine flache
13786        // Reihe unter "Sie" ist KEINE Organisation. Er nennt die Zahl nur,
13787        // wenn sie ueberhaupt ein Thema ist.
13788        const flach = (wurzeln.length >= 4 && leute.length > wurzeln.length)
13789            ? '<div class="alert alert-light border d-flex align-items-start gap-2 '
13790              + 'py-2 px-3 mb-3" style="font-size:.85rem">'
13791              + '<i class="fas fa-circle-info text-primary mt-1"></i><div>'
13792              + '<strong>' + wurzeln.length + ' ' + t('Mitarbeiter berichten direkt an Sie.')
13793              + '</strong> '
13794              + t('Sie können sie einer Führungskraft unterstellen — über das Zahnrad am '
13795                  + 'Kasten. Ein Vorgesetzter fasst deren Tagesberichte zusammen und kann '
13796                  + 'ihnen Aufträge weitergeben.')
13797              + '</div></div>'
13798            : '';
13799
13800        const abt = (d.abteilungen || []).map(a =>
13801            '<div class="col-12 col-md-6 col-xl-4"><div class="card h-100"><div class="card-body">'
13802            + '<div class="d-flex align-items-center gap-2 mb-2">'
13803            + '<span class="d-inline-flex align-items-center justify-content-center rounded" '
13804            + 'style="width:28px;height:28px;background:' + esc(a.farbe || '#6c757d') + '22">'
13805            + '<i class="fas ' + esc(a.icon || 'fa-building') + '" style="color:'
13806            + esc(a.farbe || '#6c757d') + '"></i></span>'
13807            + '<strong>' + esc(a.name) + '</strong>'
13808            + '<span class="badge bg-light text-dark ms-auto">' + (a.anzahl || 0) + '</span></div>'
13809            + '<div class="small text-muted">'
13810            + (leute.filter(x => x.department_id === a.id).map(x => esc(x.name)).join(', ')
13811               || (a.telefon ? a.telefon + ' ' + t('Telefon-Assistenten') : t('noch niemand')))
13812            + '</div></div></div></div>').join('');
13813
13814        return this.ORG_CSS
13815            + '<div class="card mb-4"><div class="card-body">'
13816            + '<div class="d-flex align-items-center flex-wrap gap-2 mb-1">'
13817            + '<h6 class="mb-0">' + t('Berichtswege') + '</h6>'
13818            + '<span class="ms-auto">' + umschalter + '</span></div>'
13819            + '<div class="text-muted small mb-3">'
13820            + t('Wer berichtet an wen. Sie stehen an der Spitze — die oberste Ebene '
13821                + 'berichtet direkt an Sie.')
13822            + '</div>'
13823            + flach
13824            + (grafik ? baum : liste)
13825            + '</div></div>'
13826            + '<h6 class="mb-2">' + t('Abteilungen') + '</h6>'
13827            + '<div class="row g-3">' + abt + '</div>'
13828            + (d.ohne_abteilung ? '<div class="text-muted small mt-3">' + d.ohne_abteilung + ' '
13829                + t('Mitarbeiter sind keiner Abteilung zugeordnet.') + '</div>' : '');
13830    }
13831}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.