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, '&').replace(/</g, '<').replace(/>/g, '>') 82 .replace(/"/g, '"').replace(/'/g, '''); 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 ? '<' + esc(m.von_adresse) + '>' : '') + '</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')) + '">×</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 + '">×</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) + '">×</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*="/detail/"]" 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) + '">×</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) + '">×</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.