1import { Language } from './language.js'; 2// reception-appointment-edit-v1 / calendar-sync-v1 (2026-07-27): dieselben 3// Termin-Aktionen wie in der Rezeption - hier landet der Mitarbeiter, wenn das 4// Ticket-Modul sichtbar ist. 5import { appointmentActionsHtml, appointmentFormSlotHtml, bindAppointmentActions, 6 isAppointmentFormOpen } from './appointmentEdit.js'; 7import { onCalendarChanged } from './calendarSync.js'; 8// kundenfelder-v2: mandantendefinierte Zusatzfelder (Bereich 'tickets') 9import { ladeKundenfelder, renderKundenfelder, sammleKundenfelder, zeigeFeldVerwaltung } from './customFields.js?v=kf3'; 10 11// Ãbersetzungsfunktion 12function t(text) { 13 if (typeof Language !== 'undefined' && Language.translate) { 14 return Language.translate(text); 15 } 16 return text; 17} 18 19// Helper-Funktion für lokalisierte Datumsformatierung 20function getLocale() { 21 return (typeof Language !== 'undefined' && Language.currentLanguage) ? Language.currentLanguage : 'de-DE'; 22} 23 24class TicketSystem { 25 constructor() { 26 this.tickets = []; 27 this.currentTicket = null; 28 this.filters = { 29 status: '', 30 priority: '', 31 category: '', 32 assignedTo: '' 33 }; 34 this.currentView = 'dashboard'; 35 this.sammel = ''; // ticketui-v1: aktive Sammelsicht der Kennzahl-Kacheln 36 this.suche = ''; // ticksuche-v1: Freitext der Vorgangssuche 37 this.analytics = null; 38 this.dashboardData = null; 39 this.agents = []; 40 this.customers = []; 41 this.isLoading = false; 42 this.tenantId = null; 43 this.currentUser = null; 44 this.userRole = null; 45 // Customer search 46 this.customerSearchDebounce = null; 47 this.customerSearchCache = new Map(); 48 this.selectedCustomer = null; 49 // Determine correct base URL 50 this.baseUrl = this.getCorrectBaseUrl(); 51 this.init(); 52 } 53 54 getCorrectBaseUrl() { 55 // Always use relative URLs - the app is served from the same origin 56 // This works whether we're on port 3001 or the main domain 57 return ''; 58 } 59 60 async init() { 61 // Setup event listeners immediately 62 this.setupEventListeners(); 63 64 // Wait a bit for auth then load data 65 setTimeout(async () => { 66 await this.waitForAuth(); 67 68 // WICHTIG: Lade zuerst Customers, um die korrekte tenantID vom Server zu erhalten 69 await this.loadCustomers(); // Warte auf Completion, um tenantID zu erhalten 70 71 this.loadAgents(); 72 73 // ticket-deeplink-v2: Sprung aus der digitalen Rezeption. Der Vorgang 74 // darf erst JETZT geladen werden - vorher steht die Anmeldung noch 75 // nicht und der Abruf lief in einen 401, dessen Fehlerzweig selbst 76 // auf das Dashboard zurueckschaltete. 77 const _pendingDeepLink = this.__pendingDeepLink || null; 78 this.__pendingDeepLink = null; 79 if (_pendingDeepLink) { 80 this.currentView = 'detail'; 81 this.loadTicketDetail(_pendingDeepLink); 82 } else { 83 this.loadDashboard(); 84 } 85 this.subscribeToTicketUpdates(); 86 }, 1000); 87 } 88 89 async waitForAuth() { 90 // Check if we have Auth module available 91 if (typeof window.Auth !== 'undefined' && window.Auth.currentUser) { 92 console.log('Using Auth module user'); 93 this.currentUser = window.Auth.currentUser; 94 95 console.log('Firebase check:', { 96 firebaseDefined: typeof firebase !== 'undefined', 97 hasAuth: typeof firebase !== 'undefined' && !!firebase.auth, 98 hasFirestore: typeof firebase !== 'undefined' && !!firebase.firestore 99 }); 100 101 // Always try to get user info from Firestore 102 if (typeof firebase !== 'undefined' && firebase.auth && firebase.auth().currentUser) { 103 console.log('Path 1: Getting user info via Firebase auth'); 104 await this.getUserInfo(); 105 } else if (typeof firebase !== 'undefined' && firebase.firestore) { 106 console.log('Path 2: Getting user info via Firestore directly'); 107 // Try to get user info directly using Auth module user ID 108 const db = firebase.firestore(); 109 try { 110 const userDoc = await db.collection('users').doc(window.Auth.currentUser.uid).get(); 111 if (userDoc.exists) { 112 const userData = userDoc.data(); 113 this.userRole = userData.role; 114 this.tenantId = userData.tenantID || userData.tenant_id || userData.tenantId; 115 console.log('Got tenant ID from Firestore via Auth module:', this.tenantId); 116 } 117 } catch (error) { 118 console.error('Error getting user info via Auth module:', error); 119 } 120 } else { 121 console.log('Path 3: Firebase not available, getting tenant ID via API'); 122 // If Firebase is not available, get tenant ID via API 123 await this.getTenantIdViaAPI(); 124 } 125 126 // Force reload tenant ID from first API call if still using UID 127 if (this.tenantId && this.tenantId.length === 28 && this.tenantId.match(/^[A-Za-z0-9]+$/)) { 128 console.warn('Tenant ID appears to be a UID, will fetch correct one from API'); 129 // The loadCustomers call will get the correct tenant ID from server 130 this.needsTenantIdUpdate = true; 131 } 132 133 console.log('Final tenant ID after waitForAuth:', this.tenantId); 134 return true; 135 } 136 137 // Wait for Firebase auth to be initialized 138 const maxWaitTime = 3000; 139 const startTime = Date.now(); 140 141 while (Date.now() - startTime < maxWaitTime) { 142 if (typeof firebase !== 'undefined' && firebase.auth) { 143 try { 144 // Wait for auth state to be determined 145 await new Promise((resolve) => { 146 const unsubscribe = firebase.auth().onAuthStateChanged((user) => { 147 unsubscribe(); 148 resolve(user); 149 }); 150 }); 151 152 if (firebase.auth().currentUser) { 153 console.log('Firebase auth ready with user'); 154 this.currentUser = firebase.auth().currentUser; 155 await this.getUserInfo(); 156 return true; 157 } 158 } catch (e) { 159 console.error('Error checking auth state:', e); 160 } 161 } 162 await new Promise(resolve => setTimeout(resolve, 100)); 163 } 164 console.warn('Auth not ready after timeout, proceeding anyway'); 165 return false;
166 } 167 168 async getTenantIdViaAPI() { 169 // Get tenant ID via API when Firebase is not available 170 console.log('getTenantIdViaAPI called'); 171 try { 172 const headers = await this.getAuthHeaders(); 173 console.log('Got auth headers for API call'); 174 175 // Use the employee management API which returns user info 176 const response = await fetch('/api/tenant/employees', { 177 method: 'GET', 178 credentials: 'include', 179 headers: headers 180 }); 181 182 console.log('Employee API response status:', response.status); 183 184 if (response.ok) { 185 const data = await response.json(); 186 console.log('Employee API data:', data); 187 188 // The API returns current_user_info with tenant information 189 if (data.current_user_info) { 190 const userInfo = data.current_user_info; 191 console.log('Current user info from API:', userInfo); 192 193 this.userRole = userInfo.role; 194 // Get tenant ID from user info - check all possible field names 195 this.tenantId = userInfo.tenantID || userInfo.tenant_id || userInfo.tenantId; 196 console.log('Got tenant ID from employee API:', this.tenantId); 197 console.log('User role from API:', this.userRole); 198 199 // Only use user ID as fallback if no tenant ID found 200 if (!this.tenantId && userInfo.role !== 'employee') { 201 this.tenantId = this.currentUser.uid; 202 console.log('No tenant ID found in API response, using user ID as fallback:', this.tenantId); 203 } 204 } else { 205 console.log('No current_user_info in API response'); 206 // Fallback to user ID 207 this.tenantId = this.currentUser.uid; 208 } 209 } else { 210 console.error('Failed to get user info from employee API, status:', response.status); 211 // Fallback to user ID 212 this.tenantId = this.currentUser.uid; 213 } 214 } catch (error) { 215 console.error('Error getting tenant ID via API:', error); 216 // Fallback to user ID 217 this.tenantId = this.currentUser.uid; 218 } 219 console.log('Final tenant ID from getTenantIdViaAPI:', this.tenantId); 220 } 221 222 async getUserInfo() { 223 // Get user info from Firestore to determine tenant ID and role 224 if (!this.currentUser) return; 225 226 try { 227 if (typeof firebase !== 'undefined' && firebase.firestore) { 228 const db = firebase.firestore(); 229 const userDoc = await db.collection('users').doc(this.currentUser.uid).get(); 230 231 if (userDoc.exists) { 232 const userData = userDoc.data(); 233 this.userRole = userData.role; 234 235 // Get tenant ID from user document - same for all roles 236 // Use tenantID (capital ID) or tenant_id as fallback 237 this.tenantId = userData.tenantID || userData.tenant_id || userData.tenantId; 238 239 console.log('User role:', this.userRole); 240 console.log('Tenant ID from user document:', this.tenantId); 241 242 // Only use user ID as fallback if no tenant ID found 243 if (!this.tenantId && userData.role !== 'employee') { 244 this.tenantId = this.currentUser.uid; 245 console.log('No tenant ID found, using user ID as fallback:', this.tenantId); 246 } 247 } 248 } 249 } catch (error) { 250 console.error('Error getting user info:', error); 251 } 252 } 253 254 async getAuthHeaders() { 255 try { 256 // First try to get token from Auth module (most common case) 257 if (typeof window.Auth !== 'undefined' && window.Auth.currentUser) { 258 const token = await window.Auth.currentUser.getIdToken(); 259 console.log('Got token from Auth module'); 260 return { 261 'Content-Type': 'application/json', 262 'Authorization': `Bearer ${token}` 263 }; 264 } 265 266 // Try Auth.getIdToken() method 267 if (typeof window.Auth !== 'undefined' && typeof window.Auth.getIdToken === 'function') { 268 const token = await window.Auth.getIdToken(); 269 console.log('Got token from Auth.getIdToken()'); 270 return { 271 'Content-Type': 'application/json', 272 'Authorization': `Bearer ${token}` 273 }; 274 } 275 276 // Fallback to firebase auth 277 if (typeof firebase !== 'undefined' && firebase.auth && firebase.auth().currentUser) { 278 const token = await firebase.auth().currentUser.getIdToken(); 279 console.log('Got token from firebase.auth()'); 280 return { 281 'Content-Type': 'application/json', 282 'Authorization': `Bearer ${token}` 283 }; 284 } 285 } catch (error) { 286 console.error('Error getting auth token:', error); 287 } 288 289 console.warn('No auth token available - requests will likely fail'); 290 // Return basic headers as fallback 291 return { 292 'Content-Type': 'application/json' 293 }; 294 } 295 296 formatResolutionTime(hours) { 297 if (!hours || hours === 0) { 298 return t('Keine Daten'); 299 } 300 301 if (hours < 1) { 302 // Less than 1 hour - show minutes 303 const minutes = Math.round(hours * 60); 304 return `${minutes}min`; 305 } else if (hours < 24) { 306 // Less than 24 hours - show hours with 1 decimal 307 return `${hours.toFixed(1)}h`; 308 } else { 309 // More than 24 hours - show days and hours 310 const days = Math.floor(hours / 24); 311 const remainingHours = Math.round(hours % 24); 312 if (remainingHours === 0) { 313 return `${days}d`; 314 } 315 return `${days}d ${remainingHours}h`; 316 } 317 } 318 319 setupEventListeners() { 320 // Make sure jQuery is available 321 if (typeof $ === 'undefined') { 322 console.error('jQuery not available for TicketSystem'); 323 return; 324 } 325 326 // Store reference to this 327 const self = this; 328 329 // Navigation handlers - use body delegation and regular function 330 $('body').off('click.ticketnav'); 331 $('body').on('click.ticketnav', '.ticket-nav-item', function(e) { 332 e.preventDefault(); 333 e.stopPropagation(); 334 e.stopImmediatePropagation(); 335 336 const $this = $(this); 337 const view = $this.data('view') || $this.attr('data-view'); 338 339 console.log('Nav clicked - view:', view, 'element:', this, 'data:', $this.data()); 340 341 if (view) { 342 // ticketui-v1: Der Menuepunkt zeigt IMMER den vollen Bestand. 343 // Ohne dieses Zuruecksetzen klebte die zuletzt angeklickte 344 // Kachel an der Ansicht - "Alle Tickets" zeigte dann still 345 // nur die ueberfaelligen. 346 if (view === 'tickets') { 347 self.sammel = ''; 348 // ticksuche-v1b: Suche und Auswahl gehoeren zum selben 349 // Zuruecksetzen - sonst zeigt "Alle Tickets" weiter nur 350 // die Treffer der letzten Suche. 351 self.suche = ''; 352 self.filters = { status: '', priority: '', category: '', assignedTo: '' }; 353 self.seitengroesse = 50; 354 } 355 self.switchView(view).catch(err => console.error('Error switching view:', err)); 356 } else { 357 console.error('No view attribute found on:', this); 358 } 359 360 return false;
361 }); 362 363 // Idempotency: drop any previously bound delegated handlers from earlier 364 // TicketSystem instantiations (pages.js re-news the class on every navigation). 365 $(document).off('.ticketsys'); 366 367 // Ticket list handlers - updated for table rows 368 $(document).on('click.ticketsys', '.ticket-cell, .ticket-list-item', (e) => { 369 e.preventDefault(); 370 e.stopPropagation(); 371 const ticketId = $(e.currentTarget).closest('tr').data('ticket-id') || $(e.currentTarget).data('ticket-id'); 372 console.log('Ticket clicked, ID:', ticketId); 373 if (ticketId) { 374 this.loadTicketDetail(ticketId); 375 } else { 376 console.error('No ticket ID found on element:', e.currentTarget); 377 } 378 }); 379 380 // Delete ticket handler 381 $(document).on('click.ticketsys', '.delete-ticket-btn', (e) => { 382 e.preventDefault(); 383 e.stopPropagation(); 384 const ticketId = $(e.currentTarget).data('ticket-id'); 385 if (ticketId && confirm('Möchten Sie dieses Ticket wirklich löschen?')) { 386 this.deleteTicket(ticketId); 387 } 388 }); 389 390 // ticketui-v1: Kacheln und "Alle anzeigen" fuehren in die Liste. 391 // Bewusst VOR dem Zeilen-Klickbinder, und die Zeilenbindung schliesst 392 // diese Knoepfe aus - sonst oeffnet ein Klick auf eine Kachel den 393 // zuletzt gerenderten Vorgang. 394 $(document).on('click.ticketsys', '.tk-kachel, .tk-alle', (e) => { 395 e.preventDefault(); 396 e.stopPropagation(); 397 const sammel = $(e.currentTarget).data('tk-sammel'); 398 this.sammel = sammel ? String(sammel) : ''; 399 // Die Auswahlfelder der Liste zeigen sonst weiter "Alle Status", 400 // waehrend die Liste gefiltert ist - der Nutzer haelt das fuer 401 // einen leeren Bestand statt fuer einen aktiven Filter. 402 this.filters = { status: '', priority: '', category: '', assignedTo: '' }; 403 this.seitengroesse = 50; 404 this.switchView('tickets'); 405 }); 406 407 // ticksuche-v1: Freitextsuche. Die Eingabe laeuft verzoegert los, sonst 408 // schickt jeder Tastenanschlag eine Abfrage los und die Liste flackert. 409 $(document).on('input.ticketsys', '#ticket-suche', (e) => { 410 const wert = String($(e.target).val() || ''); 411 clearTimeout(this._sucheTimer); 412 this._sucheTimer = setTimeout(() => { 413 // Nur neu laden, wenn sich der Begriff wirklich geaendert hat - 414 // ein angehaengtes Leerzeichen soll die Liste nicht neu bauen. 415 if (wert.trim() === String(this.suche || '').trim()) return; 416 this.suche = wert; 417 this.seitengroesse = 50; 418 this.applyFilters(); 419 }, 350); 420 }); 421 422 // Eingabetaste sucht sofort, ohne die Verzoegerung abzuwarten. 423 $(document).on('keydown.ticketsys', '#ticket-suche', (e) => { 424 if (e.key !== 'Enter') return; 425 e.preventDefault(); 426 clearTimeout(this._sucheTimer); 427 const wert = String($(e.target).val() || ''); 428 if (wert.trim() === String(this.suche || '').trim()) return; 429 this.suche = wert; 430 this.seitengroesse = 50; 431 this.applyFilters(); 432 }); 433 434 $(document).on('click.ticketsys', '#ticket-suche-leeren', (e) => { 435 e.preventDefault(); 436 clearTimeout(this._sucheTimer); 437 if (!String(this.suche || '')) { $('#ticket-suche').val('').focus(); return; } 438 this.suche = ''; 439 this.seitengroesse = 50; 440 this.applyFilters(); 441 }); 442 443 // ticksuche-v1: aus dem Leer-Hinweis heraus alles zuruecksetzen. 444 $(document).on('click.ticketsys', '#ticket-filter-zuruecksetzen', (e) => { 445 e.preventDefault(); 446 clearTimeout(this._sucheTimer); 447 this.suche = ''; 448 this.sammel = ''; 449 this.filters = { status: '', priority: '', category: '', assignedTo: '' }; 450 this.seitengroesse = 50; 451 this.applyFilters(); 452 }); 453 454 // Filter handlers 455 $(document).on('change.ticketsys', '.ticket-filter', (e) => { 456 const filterType = $(e.target).data('filter-type'); 457 const value = $(e.target).val(); 458 this.filters[filterType] = value; 459 this.seitengroesse = 50; // tick-ui-v3: neuer Filter -> wieder von vorn 460 this.applyFilters(); 461 }); 462 463 // tick-ui-v3: Die Liste holte fest die neuesten 50 Vorgänge und sagte nicht, 464 // dass es mehr gibt - ab dem 51. war ein Vorgang über die Oberfläche 465 // unerreichbar (ein Mandant liegt genau darüber). 466 $(document).on('click.ticketsys', '#ticket-mehr-laden', (e) => { 467 e.preventDefault(); 468 this.seitengroesse = (this.seitengroesse || 50) + 50; 469 this.applyFilters(); 470 }); 471 472 // Create ticket button 473 $(document).on('click.ticketsys', '#create-ticket-btn', async () => { 474 await this.showCreateTicketModal(); 475 }); 476 477 // Submit new ticket 478 $(document).on('submit.ticketsys', '#create-ticket-form', (e) => { 479 e.preventDefault(); 480 this.createTicket(); 481 }); 482 483 // Update ticket status 484 $(document).on('change.ticketsys', '#ticket-status-select', (e) => { 485 console.log('Status select changed to:', $(e.target).val()); 486 console.log('Current ticket:', this.currentTicket); 487 if (this.currentTicket && this.currentTicket.id) { 488 this.updateTicketStatus(this.currentTicket.id, $(e.target).val()); 489 } else { 490 console.error('No current ticket or ticket ID to update'); 491 } 492 }); 493 494 // Assign ticket 495 $(document).on('click.ticketsys', '#assign-ticket-btn', () => {
496 this.showAssignModal(); 497 }); 498 499 // Add comment 500 $(document).on('submit.ticketsys', '#add-comment-form', (e) => { 501 e.preventDefault(); 502 this.addComment(); 503 }); 504 505 // Refresh dashboard 506 $(document).on('click.ticketsys', '#refresh-dashboard-btn', () => { 507 this.loadDashboard(); 508 }); 509 } 510 511 async loadAgents() { 512 // phase8-tickets-migrated: load team via /api/users/team (single call, PG-mirror backed) 513 try { 514 if (!this.currentUser) { 515 console.log('No current user for loading agents'); 516 this.agents = []; 517 return; 518 } 519 520 const { usersApi } = await import('./usersApi.js'); 521 const teamUsers = await usersApi.team(); 522 const me = await usersApi.me(); 523 const tenantId = me.tenantID || me.tenant_id; 524 525 this.agents = teamUsers.map(u => { 526 const isOwner = !u.is_employee && (u.role === 'user' || u.role === 'admin' || u.role === 'superadmin' || !u.parent_user_id);
527 const displayName = u.name || u.displayName || u.email || (u.is_employee ? 'Employee' : 'Unknown'); 528 return { 529 id: u.uid, 530 name: isOwner ? `${displayName} (Hauptbenutzer)` : displayName, 531 email: u.email, 532 role: u.role || (u.is_employee ? 'employee' : 'user'), 533 department: isOwner ? 'Geschäftsführung' : '', 534 team: '', 535 isOwner, 536 status: 'active', 537 }; 538 }); 539 540 this.agents.sort((a, b) => { 541 if (a.isOwner && !b.isOwner) return -1; 542 if (!a.isOwner && b.isOwner) return 1; 543 return (a.name || '').localeCompare(b.name || ''); 544 }); 545 546 console.log(`Loaded ${this.agents.length} agents via /api/users/team for tenant ${tenantId}`); 547 } catch (error) { 548 console.error('Error loading agents:', error); 549 this.agents = []; 550 } 551 } 552 553 // Fallback method using API 554 async loadAgentsViaAPI() { 555 try { 556 const headers = await this.getAuthHeaders(); 557 const url = this.baseUrl + '/api/tenant/employees'; 558 console.log('Loading employees/agents from API:', url); 559 560 const response = await fetch(url, { 561 method: 'GET', 562 credentials: 'include', 563 headers: headers 564 }); 565 566 if (response.ok) { 567 const data = await response.json(); 568 console.log('Employee data received:', data); 569 570 if (data.employees && Array.isArray(data.employees)) { 571 this.agents = data.employees.map(emp => ({ 572 id: emp.uid || emp.id, 573 name: emp.display_name || emp.email || 'Unknown', 574 email: emp.email, 575 role: emp.role || 'employee', 576 department: emp.department || '', 577 status: emp.status || 'active' 578 })); 579 580 // Check if tenant_owner is in response 581 if (data.tenant_owner) { 582 const ownerName = data.tenant_owner.display_name || 583 data.tenant_owner.email || 584 'Hauptbenutzer'; 585 const ownerAgent = { 586 id: data.tenant_owner.id, 587 name: `${ownerName} (Hauptbenutzer)`, 588 email: data.tenant_owner.email, 589 role: 'owner', 590 department: 'Geschäftsführung', 591 status: 'active', 592 isOwner: true 593 }; 594 console.log('Adding tenant owner from API response:', ownerAgent); 595 this.agents.unshift(ownerAgent); 596 } else { 597 // If no tenant_owner in response but we have currentUser info, add them manually 598 if (this.currentUser && window.Auth && window.Auth.currentUser) { 599 const currentUserData = window.Auth.currentUser; 600 // Check if current user is already in the agents list 601 const userInList = this.agents.find(a => a.id === currentUserData.uid); 602 if (!userInList) { 603 // Assume current user might be the owner if not in employee list 604 const ownerAgent = { 605 id: currentUserData.uid, 606 name: `${currentUserData.displayName || currentUserData.email || 'Hauptbenutzer'} (Hauptbenutzer)`, 607 email: currentUserData.email, 608 role: 'owner', 609 department: 'Geschäftsführung', 610 status: 'active', 611 isOwner: true 612 }; 613 console.log('Adding current user as potential owner:', ownerAgent); 614 this.agents.unshift(ownerAgent); 615 } 616 } 617 } 618 619 console.log(`Successfully loaded ${this.agents.length} agents from API (including owner)`); 620 } else { 621 console.warn('No employees data in response'); 622 this.agents = []; 623 } 624 } else { 625 console.error('Failed to load employees:', response.status); 626 this.agents = []; 627 } 628 } catch (error) { 629 console.error('Error loading employees via API:', error); 630 this.agents = []; 631 } 632 } 633 634 async loadCustomers() { 635 // Only load initial set for display 636 try { 637 const headers = await this.getAuthHeaders(); 638 const params = new URLSearchParams({ 639 page: '1', 640 limit: '50', 641 search: '' 642 }); 643 const url = this.baseUrl + '/api/contacts?' + params.toString(); 644 const response = await fetch(url, { 645 method: 'GET', 646 credentials: 'include', 647 headers: headers 648 }); 649 if (response.ok) { 650 const data = await response.json(); 651 652 // WICHTIG: Hole die korrekte tenantID vom Server 653 if (data.debug_info) { 654 console.log('Customer Load Debug Info:', data.debug_info); 655 656 // IMMER die tenantID vom Server verwenden, da sie die korrekte ist! 657 if (data.debug_info.tenant_id) { 658 if (this.tenantId !== data.debug_info.tenant_id) { 659 console.warn(`WICHTIG: TenantID korrigiert! Alt: ${this.tenantId}, Neu: ${data.debug_info.tenant_id}`); 660 } 661 this.tenantId = data.debug_info.tenant_id; 662 console.log('TenantID vom Server gesetzt:', this.tenantId); 663 } 664 } else { 665 console.warn('WARNUNG: Keine debug_info in API-Response! Server läuft möglicherweise mit alter Version.'); 666 } 667 668 if (data.contacts) { 669 this.customers = data.contacts; 670 console.log(`${data.contacts.length} Kunden geladen`); 671 } 672 } else { 673 console.error('Failed to load customers:', response.status, response.statusText); 674 } 675 } catch (error) { 676 console.error('Error loading customers:', error); 677 } 678 } 679 680 async searchCustomers(searchTerm) { 681 console.log('Searching for customers with term:', searchTerm); 682
683 // First ensure all contacts are loaded 684 if (!this.allContacts || this.allContacts.length === 0) { 685 await this.loadAllContacts(); 686 } 687 688 if (!searchTerm || searchTerm.length < 2) { 689 return []; 690 } 691 692 const searchLower = searchTerm.toLowerCase().trim(); 693 694 // Filter contacts locally like voiceControl.js does 695 const results = this.allContacts.filter(contact => { 696 const name = (contact.name || '').toLowerCase(); 697 const company = (contact.company || '').toLowerCase(); 698 699 // Exact match gets highest priority 700 if (name === searchLower) return true; 701 if (company === searchLower) return true; 702 703 // Contains match 704 if (name.includes(searchLower)) return true; 705 if (company.includes(searchLower)) return true; 706 707 return false; 708 }); 709 710 // Sort results by relevance 711 results.sort((a, b) => { 712 const aName = (a.name || '').toLowerCase(); 713 const bName = (b.name || '').toLowerCase(); 714 const aCompany = (a.company || '').toLowerCase(); 715 const bCompany = (b.company || '').toLowerCase(); 716 717 // Exact matches first 718 if (aName === searchLower && bName !== searchLower) return -1; 719 if (bName === searchLower && aName !== searchLower) return 1; 720 721 // Then starts with 722 if (aName.startsWith(searchLower) && !bName.startsWith(searchLower)) return -1; 723 if (bName.startsWith(searchLower) && !aName.startsWith(searchLower)) return 1; 724 725 // Then alphabetical 726 return aName.localeCompare(bName); 727 }); 728 729 console.log(`Found ${results.length} contacts for "${searchTerm}"`); 730 if (results.length > 0) { 731 console.log('First few results:'); 732 results.slice(0, 3).forEach((contact, i) => { 733 console.log(` ${i+1}. ${contact.name} - ${contact.company || 'No company'}`); 734 }); 735 } 736 737 return results.slice(0, 50); // Return top 50 results 738 } 739 740 async loadAllContacts() { 741 console.log('Loading all contacts for tenant...'); 742 try { 743 // First try API 744 const headers = await this.getAuthHeaders(); 745 const params = new URLSearchParams({ 746 page: '1', 747 limit: '10000', 748 search: '' 749 }); 750 751 const url = this.baseUrl + '/api/contacts?' + params.toString(); 752 console.log('Loading all contacts from:', url); 753 754 const response = await fetch(url, { 755 method: 'GET', 756 credentials: 'include', 757 headers: headers 758 }); 759 760 if (response.ok) { 761 const data = await response.json(); 762 763 // KRITISCH: Hole die korrekte tenantID vom Server 764 if (data.debug_info) { 765 console.log('loadAllContacts API Debug Info:', data.debug_info); 766 767 // IMMER die tenantID vom Server verwenden! 768 if (data.debug_info.tenant_id) { 769 if (this.tenantId !== data.debug_info.tenant_id) { 770 console.warn(`KRITISCH: TenantID korrigiert in loadAllContacts! Alt: ${this.tenantId}, Neu: ${data.debug_info.tenant_id}`); 771 } 772 this.tenantId = data.debug_info.tenant_id; 773 console.log('TenantID vom Server in loadAllContacts gesetzt:', this.tenantId); 774 } 775 } else { 776 console.warn('KRITISCH: Keine debug_info in loadAllContacts Response!'); 777 } 778 779 this.allContacts = data.contacts || []; 780 console.log(`Loaded ${this.allContacts.length} total contacts from API`); 781 782 // If API returns 0 contacts, treat it as a failure and try Firestore 783 if (this.allContacts.length === 0) { 784 console.log('API returned 0 contacts, will try Firestore directly...'); 785 } 786 } else { 787 console.error('API failed, will try Firestore directly...'); 788 this.allContacts = []; 789 } 790 791 // If no contacts from API, try loading directly from Firestore 792 console.log('Checking Firestore availability:', { 793 contactsLength: this.allContacts.length, 794 firebaseDefined: typeof firebase !== 'undefined', 795 firestoreAvailable: typeof firebase !== 'undefined' ? !!firebase.firestore : false, 796 tenantId: this.tenantId 797 }); 798 799 if (this.allContacts.length === 0 && typeof firebase !== 'undefined' && firebase.firestore) { 800 console.log('Attempting to load contacts directly from Firestore...'); 801 const db = firebase.firestore(); 802 803 // Use the tenant ID from user document 804 const tenantIds = [ 805 this.tenantId // This should now be the correct tenant ID from the user document 806 ].filter(id => id); 807 808 console.log('Tenant IDs to search:', tenantIds); 809 810 // Add user ID as fallback only if no tenant ID 811 if (tenantIds.length === 0 && this.currentUser?.uid) { 812 console.log('No tenant ID found, using user ID as fallback:', this.currentUser.uid); 813 tenantIds.push(this.currentUser.uid); 814 } 815 816 let allContacts = []; 817 const addedIds = new Set(); 818 819 for (const tid of tenantIds) { 820 try { 821 // Use toString() to ensure it's a string, like contacts.js does 822 const tenantIdStr = tid.toString(); 823 console.log(`Trying to load contacts for tenantID: ${tenantIdStr}`); 824 825 // Query with tenantID (uppercase ID) - this is what contacts.js uses 826 const q1 = db.collection('contacts').where('tenantID', '==', tenantIdStr).get(); 827 const snapshot1 = await q1; 828 console.log(`Found ${snapshot1.size} contacts with tenantID=${tenantIdStr}`); 829
830 snapshot1.forEach(doc => { 831 if (!addedIds.has(doc.id)) { 832 addedIds.add(doc.id); 833 const data = doc.data(); 834 allContacts.push({ 835 id: doc.id, 836 name: data.name || data.displayName || 'Unbekannt', 837 email: data.email || '', 838 phone: data.phone_number || data.phone || '', // phone_number is used in contacts.js 839 company: data.company || '', 840 ...data 841 }); 842 } 843 }); 844 } catch (error) { 845 console.error(`Error loading contacts for tenant ${tid}:`, error); 846 } 847 } 848 849 this.allContacts = allContacts; 850 console.log(`Loaded ${this.allContacts.length} contacts directly from Firestore`); 851 } 852 853 // Cache for 10 minutes 854 if (this.allContacts.length > 0) { 855 setTimeout(() => { 856 this.allContacts = null; 857 }, 10 * 60 * 1000); 858 } 859 } catch (error) { 860 console.error('Error loading all contacts:', error); 861 this.allContacts = []; 862 } 863 } 864 865 async loadDashboard() { 866 this.showLoading(); 867 try { 868 const headers = await this.getAuthHeaders(); 869 const url = this.baseUrl + '/api/tickets/dashboard'; 870 const response = await fetch(url, { 871 method: 'GET', 872 credentials: 'include', 873 headers: headers 874 }); 875 if (response.ok) { 876 const data = await response.json(); 877 if (data.success) { 878 this.dashboardData = data.dashboard; 879 this.renderDashboard(); 880 } 881 } else { 882 console.error('Dashboard response not ok:', response.status); 883 // Try to get error details 884 try { 885 const errorData = await response.json(); 886 console.error('Dashboard error:', errorData); 887 } catch (e) { 888 console.error('Could not parse error response'); 889 } 890 } 891 } catch (error) { 892 console.error('Error loading dashboard:', error); 893 this.showError('Dashboard konnte nicht geladen werden'); 894 } finally { 895 this.hideLoading(); 896 } 897 } 898 899 async loadTickets(filters = {}) { 900 // ticket-viewguard-v1: Navigationsabsicht sofort setzen, damit ein 901 // spaeter eintreffender Dashboard-Render nicht ueberschreibt. 902 this.currentView = 'tickets'; 903 this.showLoading(); 904 try { 905 const headers = await this.getAuthHeaders(); 906 const params = new URLSearchParams(filters); 907 const response = await fetch(`${this.baseUrl}/api/tickets?${params}`, { 908 method: 'GET', 909 credentials: 'include', 910 headers: headers 911 }); 912 const data = await response.json(); 913 if (data.success) { 914 this.tickets = data.tickets; 915 // tick-ui-v2: Der Server liefert jetzt die echte Gesamtzahl mit. 916 this.ticketsGesamt = (typeof data.total === 'number') ? data.total : (data.tickets || []).length; 917 this.renderTicketList(); 918 } else { 919 // tick-ui-v2: Ein Serverfehler lief bisher stumm aus - die Liste 920 // blieb einfach auf dem alten Stand stehen. 921 this.showError(data.error || 'Tickets konnten nicht geladen werden'); 922 } 923 } catch (error) { 924 console.error('Error loading tickets:', error); 925 this.showError('Tickets konnten nicht geladen werden'); 926 } finally { 927 this.hideLoading(); 928 } 929 } 930 931 async loadTicketDetail(ticketId) { 932 console.log('Loading ticket detail for ID:', ticketId); 933 if (!ticketId) { 934 console.error('No ticket ID provided to loadTicketDetail'); 935 this.showError('Keine Ticket-ID angegeben'); 936 return; 937 } 938 939 // ticket-viewguard-v1: expliziter Sprung in einen Vorgang gewinnt gegen 940 // den 1s-verzoegerten loadDashboard() aus init(). 941 this.currentView = 'detail'; 942 943 this.showLoading(); 944 try { 945 const headers = await this.getAuthHeaders(); 946 const url = `${this.baseUrl}/api/tickets/${ticketId}`; 947 console.log('Fetching ticket from:', url); 948 949 const response = await fetch(url, { 950 method: 'GET', 951 credentials: 'include', 952 headers: headers 953 }); 954 955 console.log('Response status:', response.status); 956 957 if (!response.ok) { 958 const errorText = await response.text(); 959 console.error('Error response:', errorText); 960 throw new Error(`HTTP error! status: ${response.status}`); 961 } 962 963 const data = await response.json(); 964 console.log('Ticket data received:', data); 965 966 if (data.success && data.ticket) { 967 this.currentTicket = data.ticket; 968 this.renderTicketDetail(); 969 } else { 970 console.error('Invalid ticket data:', data); 971 // ticket-viewguard-v1: Vorgang nicht ladbar - Ansicht freigeben, 972 // damit die Seite nicht leer stehen bleibt. 973 this.currentView = 'dashboard'; 974 await this.loadDashboard(); 975 this.showError(data.error || 'Fehler beim Laden des Tickets'); 976 } 977 } catch (error) { 978 console.error('Error loading ticket detail:', error); 979 // ticket-viewguard-v1: Vorgang nicht ladbar - Ansicht freigeben, 980 // damit die Seite nicht leer stehen bleibt. 981 this.currentView = 'dashboard'; 982 await this.loadDashboard(); 983 this.showError('Ticket-Details konnten nicht geladen werden: ' + error.message); 984 } finally { 985 this.hideLoading(); 986 } 987 } 988 989 async createTicket() { 990 // Guard against double-submit (duplicate delegated handlers / rapid double-click) 991 if (this._creatingTicket) return; 992 this._creatingTicket = true; 993 const form = document.getElementById('create-ticket-form'); 994 const formData = new FormData(form); 995 const ticketData = {}; 996 997 // Get file attachments 998 const fileInput = form.querySelector('input[name="attachments"]'); 999 const files = fileInput ? Array.from(fileInput.files) : []; 1000 1001 formData.forEach((value, key) => { 1002 // kundenfelder-v2: custom__-Eingaben nicht als flache Felder mitschicken 1003 if (key !== 'attachments' && !key.startsWith('custom__')) { 1004 ticketData[key] = value; 1005 } 1006 }); 1007 // kundenfelder-v2: eigene Felder strukturiert unter 'custom' 1008 ticketData.custom = sammleKundenfelder(form, this._cfDefs || []); 1009 1010 // Use selected customer if available 1011 if (this.selectedCustomer) { 1012 ticketData.customerId = this.selectedCustomer.id; 1013 ticketData.customerName = this.selectedCustomer.name; 1014 ticketData.customerEmail = this.selectedCustomer.email; 1015 ticketData.customerPhone = this.selectedCustomer.phone || ''; 1016 } else { 1017 // Fallback: Find customer details from customers list 1018 const customer = this.customers.find(c => c.id === ticketData.customerId); 1019 if (customer) { 1020 ticketData.customerName = customer.name; 1021 ticketData.customerEmail = customer.email; 1022 ticketData.customerPhone = customer.phone || ''; 1023 } 1024 } 1025 1026 // Validate customer selection 1027 if (!ticketData.customerId || !ticketData.customerName) { 1028 this.showError('Bitte wählen Sie einen Kunden aus'); 1029 this._creatingTicket = false;
1030 return; 1031 } 1032 1033 this.showLoading(); 1034 try { 1035 const headers = await this.getAuthHeaders(); 1036 const response = await fetch(`${this.baseUrl}/api/tickets`, { 1037 method: 'POST', 1038 credentials: 'include', 1039 headers: headers, 1040 body: JSON.stringify(ticketData) 1041 }); 1042 const data = await response.json(); 1043 if (data.success) { 1044 // Upload attachments if any 1045 // tick-ui-v2: Das Backend liefert die Kennung als data.ticket.id - 1046 // 'data.ticketId' gab es nie. Ausgewählte Dateien wurden deshalb 1047 // stillschweigend verworfen. 1048 const neueId = (data.ticket && data.ticket.id) || data.ticketId; 1049 if (files.length > 0 && neueId) { 1050 this.showInfo('Dateien werden hochgeladen...'); 1051 const attachments = await this.uploadAttachments(files, neueId); 1052 if (attachments.length > 0) { 1053 await this.updateTicketAttachments(neueId, attachments); 1054 } else { 1055 this.showError('Die Dateien konnten nicht hochgeladen werden - der Vorgang wurde ohne Anhänge angelegt.'); 1056 } 1057 } 1058 1059 this.showSuccess(data.message); 1060 const modal = bootstrap.Modal.getInstance(document.getElementById('createTicketModal')); 1061 if (modal) modal.hide(); 1062 this.loadTickets(); 1063 } else { 1064 this.showError(data.error || 'Fehler beim Erstellen des Tickets'); 1065 } 1066 } catch (error) { 1067 console.error('Error creating ticket:', error); 1068 this.showError('Ticket konnte nicht erstellt werden'); 1069 } finally { 1070 this.hideLoading(); 1071 this._creatingTicket = false; 1072 } 1073 } 1074 1075 async updateTicketStatus(ticketId, status) { 1076 console.log('Updating ticket status:', { ticketId, status }); 1077 this.showLoading(); 1078 try { 1079 const headers = await this.getAuthHeaders(); 1080 const response = await fetch(`${this.baseUrl}/api/tickets/${ticketId}`, { 1081 method: 'PUT', 1082 credentials: 'include', 1083 headers: headers, 1084 body: JSON.stringify({ status }) 1085 }); 1086 1087 console.log('Response status:', response.status); 1088 const data = await response.json(); 1089 console.log('Response data:', data); 1090 1091 if (data.success) { 1092 this.showSuccess('Status wurde aktualisiert'); 1093 if (this.currentTicket) { 1094 this.currentTicket.status = status; 1095 this.renderTicketDetail(); 1096 } 1097 // Reload tickets list if we're on the list view 1098 if (this.currentView === 'tickets') { 1099 this.loadTickets(); 1100 } 1101 } else { 1102 console.error('Update failed:', data.error || 'Unknown error'); 1103 this.showError(data.error || 'Status konnte nicht aktualisiert werden'); 1104 } 1105 } catch (error) { 1106 console.error('Error updating ticket status:', error); 1107 this.showError('Status konnte nicht aktualisiert werden'); 1108 } finally { 1109 this.hideLoading(); 1110 } 1111 } 1112 1113 async deleteTicket(ticketId) { 1114 this.showLoading(); 1115 try { 1116 const headers = await this.getAuthHeaders(); 1117 const response = await fetch(`${this.baseUrl}/api/tickets/${ticketId}`, { 1118 method: 'DELETE', 1119 credentials: 'include', 1120 headers: headers 1121 }); 1122 1123 if (!response.ok) { 1124 throw new Error(`HTTP error! status: ${response.status}`); 1125 } 1126 1127 const data = await response.json(); 1128 if (data.success) { 1129 this.showSuccess('Ticket wurde gelöscht'); 1130 1131 // Remove ticket from list 1132 this.tickets = this.tickets.filter(t => t.id !== ticketId); 1133 1134 // If we're viewing the deleted ticket, go back to list 1135 if (this.currentTicket && this.currentTicket.id === ticketId) { 1136 this.switchView('tickets'); 1137 } else { 1138 // Just refresh the list 1139 this.renderTicketList(); 1140 } 1141 } else { 1142 this.showError(data.error || 'Ticket konnte nicht gelöscht werden'); 1143 } 1144 } catch (error) { 1145 console.error('Error deleting ticket:', error); 1146 this.showError('Ticket konnte nicht gelöscht werden'); 1147 } finally { 1148 this.hideLoading(); 1149 } 1150 } 1151 1152 async assignTicket(ticketId, agentId) { 1153 const agent = this.agents.find(a => a.id === agentId); 1154 if (!agent) return; 1155 1156 this.showLoading(); 1157 try { 1158 const headers = await this.getAuthHeaders(); 1159 const response = await fetch(`/api/tickets/${ticketId}
1159/assign`, { 1160 method: 'POST', 1161 credentials: 'include', 1162 headers: headers, 1163 body: JSON.stringify({ 1164 agentId: agentId, 1165 agentName: agent.name 1166 }) 1167 }); 1168 const data = await response.json(); 1169 if (data.success) { 1170 this.showSuccess(data.message); 1171 const modal = bootstrap.Modal.getInstance(document.getElementById('assignModal')); 1172 if (modal) modal.hide(); 1173 this.loadTicketDetail(ticketId); 1174 } else { 1175 // tick-ui-v2: Eine gescheiterte Zuweisung blieb ohne jede Rückmeldung - 1176 // der Ladebalken verschwand und nichts war passiert. 1177 this.showError(data.error || 'Ticket konnte nicht zugewiesen werden'); 1178 } 1179 } catch (error) { 1180 console.error('Error assigning ticket:', error); 1181 this.showError('Ticket konnte nicht zugewiesen werden'); 1182 } finally { 1183 this.hideLoading(); 1184 } 1185 } 1186 1187 async addComment() { 1188 if (!this.currentTicket) return; 1189 1190 const formData = new FormData(document.getElementById('add-comment-form')); 1191 const commentData = { 1192 content: formData.get('content'), 1193 isInternal: formData.get('isInternal') === 'on', 1194 isHtml: formData.get('isHtml') === 'on' 1195 }; 1196 1197 this.showLoading(); 1198 try { 1199 const headers = await this.getAuthHeaders(); 1200 const response = await fetch(`${this.baseUrl}/api/tickets/${this.currentTicket.id}/comments`, { 1201 method: 'POST', 1202 credentials: 'include', 1203 headers: headers, 1204 body: JSON.stringify(commentData) 1205 }); 1206 const data = await response.json(); 1207 if (data.success) { 1208 this.showSuccess(data.message); 1209 document.getElementById('add-comment-form').reset(); 1210 this.loadTicketDetail(this.currentTicket.id); 1211 } 1212 } catch (error) { 1213 console.error('Error adding comment:', error); 1214 this.showError('Kommentar konnte nicht hinzugefügt werden'); 1215 } finally { 1216 this.hideLoading(); 1217 } 1218 } 1219 1220 renderDashboard() { 1221 // ticket-viewguard-v1: nur rendern wenn Dashboard noch die aktive Ansicht ist. 1222 if (this.currentView !== 'dashboard') return; 1223 if (!this.dashboardData) return; 1224 1225 const { summary, recentTickets, urgentTickets, myTickets } = this.dashboardData; 1226 1227 const z = summary || {}; 1228 1229 // ticketui-v1: Jede Kachel traegt die Sammelsicht, die der Server 1230 // GENAU so zaehlt wie die Kachel selbst (SAMMELSICHTEN in 1231 // routes/tickets.py). Ohne diese Kopplung zeigte ein Klick auf 1232 // "Offen 13" eine Liste mit 9 Eintraegen - schlimmer als eine 1233 // tote Kachel, weil der Nutzer der Zahl dann nicht mehr glaubt. 1234 const kacheln = [ 1235 { sammel: 'offen', wert: z.openTickets || 0, etikett: t('Offen'), 1236 hinweis: t('neu, offen, wartend, in Arbeit'), 1237 akzent: 'var(--v2-primary, #2E6B94)', ikone: 'fa-inbox' }, 1238 { sammel: 'aktiv', wert: z.inProgress || 0, etikett: t('In Arbeit'), 1239 hinweis: t('jemand kuemmert sich'), 1240 akzent: 'var(--v2-primary-light, #5B8FB9)', ikone: 'fa-spinner' }, 1241 { sammel: 'heute_geloest', wert: z.resolvedToday || 0, etikett: t('Heute gelöst'), 1242 hinweis: t('seit Mitternacht'), 1243 akzent: 'var(--v2-success, #2d9d78)', ikone: 'fa-circle-check' }, 1244 { sammel: 'ueberfaellig', wert: z.slaViolations || 0, etikett: t('Ãberfällig'), 1245 hinweis: t('Antwortfrist überschritten'), 1246 akzent: 'var(--v2-danger, #d94452)', ikone: 'fa-triangle-exclamation' } 1247 ]; 1248 1249 const kachelHtml = kacheln.map(k => ` 1250 <button type="button" class="stat-card tk-kachel${k.wert ? '' : ' tk-leer'}" 1251 data-tk-sammel="${k.sammel}" style="--tk-akzent: ${k.akzent}" 1252 title="${this.escapeHtml(k.etikett)} â ${this.escapeHtml(k.hinweis)}">
1253 <span class="tk-kachel-titel"><i class="fas ${k.ikone}"></i>${this.escapeHtml(k.etikett)}</span> 1254 <span class="stat-value">${k.wert}</span> 1255 <span class="tk-kachel-hinweis">${this.escapeHtml(k.hinweis)}</span> 1256 </button> 1257 `).join(''); 1258 1259 // Die drei Spalten tragen jeweils einen eigenen Weg in die Vollansicht. 1260 // Vorher endete jede Spalte im Nichts: fuenf Eintraege, dann Leere, 1261 // ohne Hinweis darauf, dass es mehr gibt. 1262 const spalte = (titel, ikone, liste, sammel, akzent) => { 1263 const n = (liste || []).length; 1264 return ` 1265 <section class="dashboard-section tk-spalte" style="--tk-akzent: ${akzent}"> 1266 <div class="tk-spalte-kopf"> 1267 <h3><i class="fas ${ikone}"></i>${this.escapeHtml(titel)}</h3> 1268 <span class="tk-zaehler">${n}</span> 1269 </div> 1270 <div class="ticket-list compact"> 1271 ${this.renderCompactTicketList(liste)} 1272 </div> 1273 ${n ? `<button type="button" class="tk-alle" data-tk-sammel="${sammel}"> 1274 ${t('Alle anzeigen')} <i class="fas fa-arrow-right"></i> 1275 </button>` : ''} 1276 </section>`; 1277 }; 1278 1279 let html = ` 1280 <div class="ticket-dashboard"> 1281 <div class="dashboard-header"> 1282 <div class="tk-kopf-text"> 1283 <h2>${t('Tickets')}</h2> 1284 <p class="tk-kopf-zeile">${t('Stand')} ${this.tkUhrzeit(new Date())} ${t('Uhr')}</p> 1285 </div> 1286 <button id="refresh-dashboard-btn" class="btn btn-sm btn-outline-primary"> 1287 <i class="fas fa-sync-alt"></i> ${t('Aktualisieren')} 1288 </button> 1289 </div> 1290 1291 <div class="dashboard-stats">${kachelHtml}</div> 1292 1293 <div class="dashboard-sections"> 1294 ${spalte(t('Dringend'), 'fa-fire', urgentTickets, 'dringend', 'var(--v2-danger, #d94452)')} 1295 ${spalte(t('Meine Tickets'), 'fa-user-check', myTickets, '', 'var(--v2-primary, #2E6B94)')} 1296 ${spalte(t('Zuletzt eingegangen'), 'fa-clock-rotate-left', recentTickets, '', 'var(--v2-text-muted, #8a97a8)')} 1297 </div> 1298 </div> 1299 `; 1300 1301 $('#ticket-content').html(html); 1302 } 1303 1304 renderTicketList() { 1305 // ticket-viewguard-v1: nur rendern wenn die Liste noch die aktive Ansicht ist. 1306 if (this.currentView !== 'tickets') return; 1307 let html = ` 1308 <div class="container-fluid"> 1309 <div class="row mb-4"> 1310 <div class="col-12"> 1311 <div class="d-flex justify-content-between align-items-center"> 1312 <h1>Tickets</h1> 1313 <button id="create-ticket-btn" class="btn btn-primary"> 1314 <i class="fas fa-plus"></i> ${t('Neues Ticket')} 1315 </button> 1316 </div> 1317 </div> 1318 </div> 1319 1320 <!-- ticksuche-v1: Suche ueber Nummer, Betreff, Beschreibung, 1321 Name, E-Mail und Rufnummer. Sie laeuft auf dem Server, damit 1322 sie auch Vorgaenge jenseits der angezeigten 50 findet. --> 1323 <div class="row mb-3"> 1324 <div class="col-md-6"> 1325 <div class="input-group"> 1326 <span class="input-group-text"><i class="fas fa-search"></i></span> 1327 <input type="search" class="form-control" id="ticket-suche" 1328 autocomplete="off" 1329 placeholder="${t('Suche nach Nummer, Betreff, Name, E-Mail oder Rufnummer')}"> 1330 <button class="btn btn-outline-secondary" type="button" id="ticket-suche-leeren" 1331 title="${t('Suche zurücksetzen')}"><i class="fas fa-times"></i></button> 1332 </div> 1333 </div> 1334 </div> 1335 1336 <div class="row mb-3"> 1337 <div class="col-md-4"> 1338 <div class="input-group">
1339 <span class="input-group-text"><i class="fas fa-filter"></i></span> 1340 <select class="form-select ticket-filter" data-filter-type="status"> 1341 <option value="">${t('Alle Status')}</option> 1342 <option value="new">${t('Neu')}</option> 1343 <option value="open">${t('Offen')}</option> 1344 <option value="in_progress">${t('In Bearbeitung')}</option> 1345 <option value="pending">${t('Wartend')}</option> 1346 <option value="resolved">${t('Gelöst')}</option> 1347 <option value="closed">${t('Geschlossen')}</option> 1348 </select> 1349 </div> 1350 </div> 1351 <div class="col-md-4"> 1352 <select class="form-select ticket-filter" data-filter-type="priority"> 1353 <option value="">${t('Alle Prioritäten')}</option> 1354 <option value="low">${t('Niedrig')}</option> 1355 <option value="normal">${t('Normal')}</option> 1356 <option value="high">${t('Hoch')}</option> 1357 <!-- ticketprio-v1 (14.09.2026): 'urgent' hier entfernt. Der Wert 1358 stammt aus der AUFGABEN-Skala und kam ueber die KI-Werkzeuge 1359 herein; kein Vorgangs-Formular konnte ihn je erzeugen. 1360 GEMESSEN: 0 von 117 Vorgaengen trugen ihn - der Filter lieferte 1361 also immer eine leere Liste. Etikett und Farbe bleiben 1362 erhalten, falls doch ein Altbestand auftaucht. --> 1363 <option value="critical">${t('Kritisch')}</option> 1364 </select> 1365 </div> 1366 <div class="col-md-4"> 1367 <select class="form-select ticket-filter" data-filter-type="category"> 1368 <option value="">${t('Alle Kategorien')}</option> 1369 <option value="support">${t('Support')}</option> 1370 <option value="appointment">${t('Termin')}</option> 1371 <option value="complaint">${t('Beschwerde')}</option> 1372 <option value="inquiry">${t('Anfrage')}</option> 1373 <option value="rueckruf">${t('Rückrufbitte')}</option> 1374 <option value="termin">${t('Terminanfrage')}</option> 1375 <option value="rezept">${t('Rezeptbestellung')}</option> 1376 <option value="au">${t('Krankschreibung (AU)')}</option> 1377 <option value="ueberweisung">${t('Ãberweisung')}</option> 1378 <option value="befund">${t('Befundanfrage')}</option> 1379 <option value="frage">${t('Allgemeine Anfrage')}</option> 1380 <option value="funktionswunsch">${t('Funktionswunsch')}</option> 1381 </select> 1382 </div> 1383 </div> 1384 1385 <div class="row"> 1386 <div class="col-12"> 1387 <div class="card"> 1388 <div class="card-body"> 1389 <div class="table-responsive"> 1390 <table class="table table-hover"> 1391 <thead> 1392 <tr> 1393 <!-- tick-ui-v5: 'Kategorie' brach schon vorher in der Kopfzeile um; 1394 mit den jetzt lesbaren Bezeichnungen der Rezeption 1395 ('Rezeptbestellung', 'Krankschreibung (AU)') wurde es enger. --> 1396 <th style="width: 9%">${t('Ticket-Nr.')}</th> 1397 <th style="width: 20%">${t('Betreff')}</th> 1398 <th style="width: 17%">${t('Kunde')}</th> 1399 <th style="width: 10%">${t('Status')}</th> 1400 <th style="width: 10%">${t('Priorität')}</th> 1401 <th style="width: 14%; white-space: nowrap">${t('Kategorie')}</th> 1402 <th style="width: 10%">${t('Erstellt')}</th> 1403 <th style="width: 10%" class="text-center">${t('Aktion')}</th> 1404 </tr> 1405 </thead> 1406 <tbody id="ticketListBody"> 1407 ${this.renderFullTicketList(this.tickets)} 1408 </tbody> 1409 </table> 1410 </div> 1411 ${(this.ticketsGesamt || 0) > (this.tickets || []).length ? ` 1412 <div class="text-muted small mt-2"> 1413 <i class="fas fa-info-circle me-1"></i> 1414 ${(this.tickets || []).length} ${t('von')} ${this.ticketsGesamt} ${String(this.suche || '').trim() ? t('Treffern angezeigt') : t('Vorgängen angezeigt')} â 1415 <a href="javascript:void(0)" id="ticket-mehr-laden">${t('mehr laden')}</a> 1416 </div>` : (String(this.suche || '').trim() && (this.tickets || []).length ? ` 1417 <div class="text-muted small mt-2"> 1418 <i class="fas fa-search me-1"></i> 1419 ${(this.tickets || []).length} ${(this.tickets || []).length === 1 ? t('Treffer') : t('Treffer')} 1420 </div>` : '')} 1421 </div> 1422 </div> 1423 </div> 1424 </div> 1425 </div> 1426 `; 1427 1428 // ticksuche-v1: Der Abgleich baut die Seite alle 10 Sekunden neu auf. 1429 // Ohne diese Rettung verliert das Suchfeld bei jedem Neuaufbau seinen 1430 // Inhalt und den Schreibpunkt - man kann waehrend des Tippens nicht 1431 // weiterschreiben. Die Auswahlfelder sprangen dabei bisher sichtbar 1432 // auf "Alle ...", obwohl die Liste weiter gefiltert war. 1433 let _fokusSuche = false, _schreibpunkt = null; 1434 try { 1435 const _f = document.getElementById('ticket-suche'); 1436 if (_f && document.activeElement === _f) { 1437 _fokusSuche = true; 1438 _schreibpunkt = _f.selectionStart; 1439 } 1440 } catch (e) { /* Zustand retten darf den Aufbau nie verhindern */ } 1441
1442 $('#ticket-content').html(html); 1443 1444 try { 1445 const suchwert = String(this.suche || ''); 1446 const feld = document.getElementById('ticket-suche'); 1447 if (feld) { 1448 feld.value = suchwert; 1449 if (_fokusSuche) { 1450 feld.focus(); 1451 const pos = (_schreibpunkt === null || _schreibpunkt === undefined) 1452 ? suchwert.length : Math.min(_schreibpunkt, suchwert.length); 1453 feld.setSelectionRange(pos, pos); 1454 } 1455 } 1456 Object.keys(this.filters || {}).forEach((art) => { 1457 const wert = this.filters[art]; 1458 if (!wert) return; 1459 $(`.ticket-filter[data-filter-type="${art}"]`).val(wert); 1460 }); 1461 } catch (e) { 1462 console.warn('Suchfeld/Filter konnten nicht wiederhergestellt werden:', e); 1463 } 1464 } 1465 1466 // kundenfelder-v2: Verwaltung der eigenen Felder aus dem Ticket-Modal bzw. 1467 // der Detailansicht heraus; danach beide Abschnitte neu aufbauen. 1468 verwalteEigeneFelder() { 1469 // kundenfelder-v3: vor dem Neuaufbau retten, was der Nutzer schon 1470 // eingetippt hat - sonst ist die Eingabe nach dem Verwalten weg. 1471 const alteDefs = this._cfDefs || []; 1472 const detForm = document.getElementById('ticket-cf-form'); 1473 const detEingetippt = detForm ? sammleKundenfelder(detForm, alteDefs) : null; 1474 zeigeFeldVerwaltung(async (felder) => { 1475 this._cfDefs = felder; 1476 const wrap = document.getElementById('ticket-cf-wrap'); 1477 if (wrap) { 1478 const eingetippt = sammleKundenfelder(wrap.closest('form'), alteDefs); 1479 const kopf = wrap.querySelector('.col-12'); 1480 wrap.innerHTML = ''; 1481 if (kopf) wrap.appendChild(kopf); 1482 wrap.insertAdjacentHTML('beforeend', renderKundenfelder(felder, eingetippt)); 1483 } 1484 if (document.getElementById('ticket-cf-detail') && this.currentTicket) { 1485 await this._renderTicketDetailKundenfelder(this.currentTicket, detEingetippt); 1486 } 1487 }, 'tickets'); 1488 } 1489 1490 // kundenfelder-v2: Abschnitt "Eigene Felder" in der Ticket-Detailansicht. 1491 // Asynchron nach dem Rendern gefuellt, weil renderTicketDetail synchron ist. 1492 async _renderTicketDetailKundenfelder(ticket, zusatzWerte) { 1493 try { 1494 const wrap = document.getElementById('ticket-cf-detail'); 1495 if (!wrap) return; 1496 const defs = await ladeKundenfelder(false, 'tickets'); 1497 this._cfDefs = defs; 1498 if (!defs || !defs.length) { wrap.innerHTML = ''; return; } 1499 wrap.innerHTML = ` 1500 <div class="mb-3"> 1501 <div class="d-flex justify-content-between align-items-center mb-1"> 1502 <h6 class="text-muted mb-0">${t('Eigene Felder')}</h6> 1503 <button type="button" class="btn btn-sm btn-outline-secondary" onclick="ticketSystem.verwalteEigeneFelder()"> 1504 <i class="fas fa-cog me-1"></i>${t('Felder verwalten')} 1505 </button> 1506 </div> 1507 <form id="ticket-cf-form" onsubmit="return false"> 1508 <div class="row g-2">${renderKundenfelder(defs, Object.assign({}, ticket.custom || {}, zusatzWerte || {}))}</div> 1509 <button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="ticketSystem.speichereTicketKundenfelder('${ticket.id}')"> 1510 <i class="fas fa-save me-1"></i>${t('Eigene Felder speichern')} 1511 </button> 1512 </form> 1513 </div>`; 1514 } catch (e) { console.warn('Ticket-Kundenfelder:', e); } 1515 } 1516 1517 async speichereTicketKundenfelder(ticketId) { 1518 try { 1519 const form = document.getElementById('ticket-cf-form'); 1520 const custom = sammleKundenfelder(form, this._cfDefs || []); 1521 const headers = await this.getAuthHeaders(); 1522 const response = await fetch(`${this.baseUrl}/api/tickets/${ticketId}`, { 1523 method: 'PUT', 1524 credentials: 'include', 1525 headers: headers, 1526 body: JSON.stringify({ custom }) 1527 }); 1528 const data = await response.json(); 1529 if (!response.ok || data.error) throw new Error(data.error || ('HTTP ' + response.status)); 1530 // kundenfelder-v3: der Server mischt - lokal genauso, sonst zeigt die 1531 // Ansicht weniger an als gespeichert ist. 1532 if (this.currentTicket && this.currentTicket.id === ticketId) { 1533 this.currentTicket.custom = Object.assign({}, this.currentTicket.custom || {}, custom); 1534 } 1535 this.showSuccess(t('Eigene Felder gespeichert')); 1536 } catch (e) { 1537 this.showError(t('Eigene Felder konnten nicht gespeichert werden') + ': ' + e.message); 1538 } 1539 } 1540 1541 renderTicketDetail() { 1542 if (!this.currentTicket) return; 1543 // ticket-viewguard-v1: nur rendern wenn der Vorgang noch die aktive Ansicht ist 1544 // (sonst wischt der 10s-Poller die Liste weg). 1545 if (this.currentView !== 'detail') return; 1546 1547 const ticket = this.currentTicket; 1548 const activities = ticket.activities || [];
1549 const statusClass = this.getStatusBadgeClass(ticket.status); 1550 const priorityClass = this.getPriorityBadgeClass(ticket.priority); 1551 1552 let html = ` 1553 <div class="container-fluid"> 1554 <div class="row mb-4"> 1555 <div class="col-12"> 1556 <div class="d-flex justify-content-between align-items-center"> 1557 <h1>Ticket ${this.escapeHtml(ticket.ticketNumber)}</h1> 1558 <div> 1559 <button class="btn btn-danger me-2" onclick="if(confirm('Möchten Sie dieses Ticket wirklich löschen?')) ticketSystem.deleteTicket('${ticket.id}')"> 1560 <i class="fas fa-trash"></i> Ticket löschen 1561 </button> 1562 <button class="btn btn-outline-secondary" onclick="ticketSystem.switchView('tickets')"> 1563 <i class="fas fa-arrow-left"></i> Zurück zur Ãbersicht 1564 </button> 1565 </div> 1566 </div> 1567 </div> 1568 </div> 1569 1570 <div class="row"> 1571 <div class="col-lg-8"> 1572 <div class="card mb-4"> 1573 <div class="card-header"> 1574 <h5 class="mb-0">${this.escapeHtml(ticket.subject)}</h5> 1575 </div> 1576 <div class="card-body"> 1577 <div class="mb-3"> 1578 <h6 class="text-muted">${t('Beschreibung')}</h6> 1579 <div class="ticket-description">${ticket.description ? this.escapeHtml(ticket.description).replace(/\n/g, '<br>') : '<em>Keine Beschreibung vorhanden</em>'}</div> 1580 </div> 1581 1582 <div id="ticket-cf-detail"></div> 1583 1584 ${ticket.audioUrl ? ` 1585 <div class="mb-3"> 1586 <h6 class="text-muted">${t('Audio-Aufnahme')}</h6> 1587 <audio controls class="w-100"> 1588 <source src="${this.sicherLink(ticket.audioUrl)}" type="audio/mpeg"> 1589 Ihr Browser unterstützt keine Audio-Wiedergabe. 1590 </audio> 1591 </div> 1592 ` : ''} 1593 1594 ${ticket.attachments && ticket.attachments.length > 0 ? ` 1595 <div class="mb-3"> 1596 <h6 class="text-muted"><i class="fas fa-paperclip me-2"></i>${t('Anhänge')}</h6> 1597 <div class="attachment-list"> 1598 ${ticket.attachments.map(att => ` 1599 <a href="${this.sicherLink(att.url)}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-secondary me-2 mb-2"> 1600 <i class="fas fa-file me-1"></i> 1601 ${this.escapeHtml(att.name)}
1601 1602 <small class="text-muted">(${(att.size / 1024).toFixed(1)} KB)</small> 1603 </a> 1604 `).join('')} 1605 </div> 1606 </div> 1607 ` : ''} 1608 </div> 1609 </div> 1610 1611 <div class="card mb-4"> 1612 <div class="card-header"> 1613 <h5 class="mb-0">${t('Aktivitäten')}</h5> 1614 </div> 1615 <div class="card-body"> 1616 <div class="timeline"> 1617 ${this.renderActivities(activities)} 1618 </div> 1619 </div> 1620 </div> 1621 1622 <div class="card"> 1623 <div class="card-header"> 1624 <h5 class="mb-0">Kommentar hinzufügen</h5> 1625 </div> 1626 <div class="card-body"> 1627 <form id="add-comment-form"> 1628 <div class="form-group"> 1629 <textarea id="comment-content" name="content" class="form-control" rows="3" placeholder="Schreiben Sie hier Ihren Kommentar..." required></textarea> 1630 </div> 1631 <div class="form-check mb-2"> 1632 <input type="checkbox" name="isInternal" class="form-check-input" id="internal-comment"> 1633 <label class="form-check-label" for="internal-comment"> 1634 Interne Notiz (nur für Mitarbeiter sichtbar) 1635 </label> 1636 </div> 1637 <div class="form-check mb-3"> 1638 <input type="checkbox" name="isHtml" class="form-check-input" id="html-comment"> 1639 <label class="form-check-label" for="html-comment"> 1640 HTML-Formatierung verwenden 1641 </label> 1642 </div> 1643 <div class="d-flex gap-2"> 1644 <button type="submit" class="btn btn-primary"> 1645 <i class="fas fa-comment"></i> Kommentar hinzufügen 1646 </button> 1647 <button type="button" class="btn btn-outline-primary" onclick="ticketSystem.showAIResponseModal()"> 1648 <i class="fas fa-magic"></i> Mit KI generieren 1649 </button> 1650 </div> 1651 </form> 1652 </div> 1653 </div> 1654 </div> 1655 1656 <div class="col-lg-4"> 1657 <div class="card mb-4"> 1658 <div class="card-header"> 1659 <h5 class="mb-0">Ticket-Details</h5> 1660 </div> 1661 <div class="card-body"> 1662 <div class="mb-3"> 1663 <label class="text-muted small">Status</label> 1664 <div> 1665 <select id="ticket-status-select" class="form-select form-select-sm"> 1666 <option value="new" ${ticket.status === 'new' ? 'selected' : ''}>Neu</option> 1667 <option value="open" ${ticket.status === 'open' ? 'selected' : ''}>Offen</option> 1668 <option value="in_progress" ${ticket.status === 'in_progress' ? 'selected' : ''}>In Bearbeitung</option> 1669 <option value="pending" ${ticket.status === 'pending' ? 'selected' : ''}>Wartend</option> 1670 <option value="resolved" ${ticket.status === 'resolved' ? 'selected' : ''}>Gelöst</option> 1671 <option value="closed" ${ticket.status === 'closed' ? 'selected' : ''}>Geschlossen</option> 1672 </select> 1673 </div> 1674 </div> 1675 1676 <div class="mb-3"> 1677 <label class="text-muted small">Priorität</label> 1678 <div>
1679 <span class="badge ${priorityClass}">${this.getPriorityLabel(ticket.priority)}</span> 1680 </div> 1681 </div> 1682 1683 <div class="mb-3"> 1684 <label class="text-muted small">Kategorie</label> 1685 <div> 1686 <span class="badge bg-secondary">${this.getCategoryLabel(ticket.category)}</span> 1687 </div> 1688 </div> 1689 1690 <div class="mb-3"> 1691 <label class="text-muted small">Zugewiesen an</label> 1692 <div> 1693 <div class="d-flex align-items-center justify-content-between"> 1694 <span>${this.escapeHtml(ticket.assignedToName) || 'Nicht zugewiesen'}</span> 1695 <button id="assign-ticket-btn" class="btn btn-sm btn-outline-primary"> 1696 <i class="fas fa-user-plus"></i> 1697 </button> 1698 </div> 1699 </div> 1700 </div> 1701 1702 <div class="mb-3"> 1703 <label class="text-muted small">Erstellt</label> 1704 <div>${this.formatDateTime(ticket.createdAt)}</div> 1705 </div> 1706 1707 ${ticket.updatedAt ? ` 1708 <div class="mb-3"> 1709 <label class="text-muted small">Zuletzt aktualisiert</label> 1710 <div>${this.formatDateTime(ticket.updatedAt)}</div> 1711 </div> 1712 ` : ''} 1713 </div> 1714 </div> 1715 1716 <div class="card"> 1717 <div class="card-header"> 1718 <h5 class="mb-0">Kunde</h5> 1719 </div> 1720 <div class="card-body"> 1721 <div class="mb-3"> 1722 <label class="text-muted small">Name</label> 1723 <div>${this.escapeHtml(ticket.customerName)}</div> 1724 </div> 1725 1726 <div class="mb-3"> 1727 <label class="text-muted small">E-Mail</label> 1728 <div> 1729 <a href="mailto:${encodeURIComponent(ticket.customerEmail || '')}">${this.escapeHtml(ticket.customerEmail)}</a> 1730 </div> 1731 </div> 1732 1733 ${ticket.customerPhone ? ` 1734 <div class="mb-3"> 1735 <label class="text-muted small">Telefon</label> 1736 <div> 1737 <a href="tel:${encodeURIComponent(ticket.customerPhone || '')}">${this.escapeHtml(ticket.customerPhone)}</a> 1738 </div> 1739 </div> 1740 ` : ''} 1741 1742 <!-- reception-appointments-v1: Termine aus dem Kalender --> 1743 <div id="ticket-next-appointment"></div> 1744 </div> 1745 </div> 1746 </div> 1747 </div> 1748 </div> 1749 `; 1750 1751 $('#ticket-content').html(html); 1752 this._renderTicketDetailKundenfelder(ticket); // kundenfelder-v2 1753 // reception-appointments-v1: Kalendertermine des Kunden/Patienten nachladen 1754 this.loadTicketAppointment(ticket); 1755 } 1756 1757 /** 1758 * reception-appointments-v1 (2026-07-27): Ein Vorgang traegt nur Kontaktdaten. 1759 * Wann der Patient das naechste Mal kommt, stand bisher nur im Kalender - hier 1760 * wird beides zusammengefuehrt (Zuordnung ueber E-Mail, Rufnummer oder Namen). 1761 */ 1762 async loadTicketAppointment(ticket) { 1763 const box = document.getElementById('ticket-next-appointment'); 1764 if (!box || !ticket) return; 1765 if (!(ticket.customerEmail || ticket.customerPhone || ticket.customerName)) { box.innerHTML = ''; return; } 1766 box.innerHTML = `<div class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>${t('Termine werden geladen...')}</div>`; 1767 try { 1768 const headers = await this.getAuthHeaders(); 1769 const q = new URLSearchParams({ 1770 email: ticket.customerEmail || '', 1771 phone: ticket.customerPhone || '', 1772 name: ticket.customerName || '' 1773 }); 1774 const response = await fetch(`${this.baseUrl}/api/calendar/contact-appointments?${q.toString()}`, { 1775 method: 'GET', credentials: 'include', headers: headers 1776 }); 1777 if (!response.ok) throw new Error('HTTP ' + response.status); 1778 const appt = await response.json(); 1779 // Ansicht koennte inzwischen gewechselt haben - dann nichts mehr schreiben. 1780 if (document.getElementById('ticket-next-appointment') !== box) return; 1781 box.innerHTML = this.renderAppointmentBox(appt); 1782 // reception-appointment-edit-v1: Verschieben/Absagen verdrahten. 1783 bindAppointmentActions(box, {
1784 data: appt, ticketId: ticket.id, 1785 onChanged: () => this.loadTicketAppointment(ticket), 1786 }); 1787 } catch (error) { 1788 console.warn('Naechster Termin nicht ladbar:', error && error.message || error); 1789 if (document.getElementById('ticket-next-appointment') === box) box.innerHTML = ''; 1790 } 1791 } 1792 1793 formatAppointmentDateTime(iso) { 1794 const d = new Date(iso); 1795 if (isNaN(d.getTime())) return String(iso || ''); 1796 return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }) 1797 + ', ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr'; 1798 } 1799 1800 renderAppointmentBox(appt) { 1801 const escape = s => String(s == null ? '' : s).replace(/[&<>"']/g, 1802 c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); 1803 const a = appt || {}; 1804 const rows = []; 1805 if (a.next) { 1806 rows.push(`<div>${escape(this.formatAppointmentDateTime(a.next.start))}</div>` 1807 + (a.next.title ? `<div class="small text-muted">${escape(a.next.title)}</div>` : '') 1808 + (a.next.location ? `<div class="small text-muted">${escape(a.next.location)}</div>` : '')); 1809 if (a.upcomingCount > 1) { 1810 rows.push(`<div class="small text-muted">${t('Weitere Termine')}: ${a.upcomingCount - 1}</div>`); 1811 } 1812 } else { 1813 rows.push(`<div class="text-muted">${t('Kein kommender Termin im Kalender')}</div>`); 1814 } 1815 if (a.previous) { 1816 rows.push(`<div class="small text-muted mt-1">${t('Letzter Termin')}: ${escape(this.formatAppointmentDateTime(a.previous.start))}</div>`); 1817 } 1818 if (a.matchedBy === 'name') { 1819 rows.push(`<div class="small text-muted mt-1"><i class="fas fa-info-circle me-1"></i>${t('Ãber den Namen zugeordnet â bitte prüfen.')}</div>`); 1820 } 1821 return `<div class="mb-3" data-appt-box> 1822 <label class="text-muted small">${t('Nächster Termin')}</label> 1823 ${rows.join('')} 1824 <div class="d-flex gap-2 flex-wrap mt-2">${appointmentActionsHtml(a)}</div> 1825 ${appointmentFormSlotHtml()} 1826 </div>`; 1827 } 1828 1829 renderCompactTicketList(tickets) { 1830 if (!tickets || tickets.length === 0) { 1831 // Ein nackter Satz in Grau sah aus wie ein Ladefehler. Das Haus 1832 // zeigt bei leeren Listen Symbol und Satz. 1833 return `<div class="tk-leerzustand"> 1834 <i class="fas fa-check"></i> 1835 <span>${t('Nichts offen')}</span> 1836 </div>`; 1837 } 1838 1839 // ticketui-v1: ZWEI Zeilen statt drei konkurrierender Spalten. 1840 // Kopfzeile traegt Nummer, Alter und die Marken; der Betreff steht 1841 // darunter ueber die volle Breite. Vorher rangen Nummer, Betreff und 1842 // Marken in einer Zeile um denselben Platz - bei drei Spalten auf 1843 // einem 1280er Bildschirm blieben je Feld gut 100 Pixel, und die 1844 // Ticketnummer brach an ihren Bindestrichen ueber drei Zeilen um. 1845 // Muster uebernommen von .aiv-kopf/.aiv-erg aus dem KI-Team-Modul. 1846 return tickets.map(ticket => { 1847 const ueberfaellig = ticket.fristVerletzt === true; 1848 return ` 1849 <div class="ticket-list-item compact${ueberfaellig ? ' tk-ueberfaellig' : ''}" 1850 data-ticket-id="${this.escapeHtml(ticket.id)}" 1851 title="${this.escapeHtml(ticket.subject || '')}"> 1852 <div class="tk-zeile-kopf"> 1853 <span class="ticket-number">${this.escapeHtml(ticket.ticketNumber)}</span> 1854 <span class="tk-alter">${this.escapeHtml(this.tkAlter(ticket.createdAt))}</span> 1855 <span class="tk-marken"> 1856 ${ueberfaellig ? `<span class="tk-marke-frist" title="${t('Antwortfrist überschritten')}"><i class="fas fa-triangle-exclamation"></i>${t('überfällig')}</span>` : ''} 1857 ${this.prioAbzeichen(ticket.priority)} 1858 ${this.statusAbzeichen(ticket.status)} 1859 </span> 1860 </div> 1861 <div class="ticket-subject">${this.escapeHtml(ticket.subject)}</div> 1862 </div>`; 1863 }).join(''); 1864 } 1865 1866 // ---------------------------------------------------------- Abzeichen 1867 // Hausregel aus dem KI-Team-Modul: "Symbol UND Wort - nie Farbe allein." 1868 // Das ist hier keine Kuer: die Prioritaeten waren bisher vier Grauwerte, 1869 // "Hoch" und "Normal" liessen sich auf dem Schirm nicht unterscheiden. 1870 prioAbzeichen(prioritaet) {
1871 const p = this.normPrio(prioritaet); 1872 const ikonen = { 1873 critical: 'fa-circle-exclamation', 1874 high: 'fa-arrow-up', 1875 normal: 'fa-minus', 1876 low: 'fa-arrow-down' 1877 }; 1878 return `<span class="priority-badge priority-${p}"><i class="fas ${ikonen[p] || 'fa-minus'}"></i>${this.escapeHtml(this.getPriorityLabel(p))}</span>`; 1879 } 1880 1881 statusAbzeichen(status) { 1882 const st = String(status || '').trim().toLowerCase(); 1883 const ikonen = { 1884 new: 'fa-sparkles', 1885 open: 'fa-envelope-open', 1886 in_progress: 'fa-spinner', 1887 pending: 'fa-hourglass-half', 1888 resolved: 'fa-circle-check', 1889 closed: 'fa-lock' 1890 }; 1891 return `<span class="status-badge status-${this.escapeHtml(st)}"><i class="fas ${ikonen[st] || 'fa-circle'}"></i>${this.escapeHtml(this.getStatusLabel(st))}</span>`; 1892 } 1893 1894 // Spiegelt services/ticket_sla.normiere_prioritaet. 'medium' und 'urgent' 1895 // stammen aus der Aufgaben-Skala und kamen ueber die KI-Werkzeuge herein; 1896 // sie werden gelesen, nicht mehr erzeugt (ticketprio-v1). Ohne diese 1897 // Normierung traegt ein solcher Altbestand gar keine Farbe. 1898 normPrio(wert) { 1899 const w = String(wert || '').trim().toLowerCase(); 1900 const synonym = { medium: 'normal', mittel: 'normal', niedrig: 'low', 1901 hoch: 'high', urgent: 'critical', dringend: 'critical', 1902 kritisch: 'critical' }; 1903 const k = synonym[w] || w; 1904 return ['low', 'normal', 'high', 'critical'].includes(k) ? k : 'normal'; 1905 } 1906 1907 // ------------------------------------------------------------ Zeiten 1908 // formatDate() macht new Date(wert) und scheitert damit an den beiden 1909 // anderen Formen, die im Bestand vorkommen: dem Firestore-Stempel 1910 // ({_seconds}) und der {iso,__type__}-Form aus Postgres. 1911 tkDatum(wert) { 1912 if (!wert) return null; 1913 let d = null; 1914 if (typeof wert === 'object') { 1915 if (typeof wert._seconds === 'number') d = new Date(wert._seconds * 1000); 1916 else if (wert.iso) d = new Date(wert.iso); 1917 else if (typeof wert.seconds === 'number') d = new Date(wert.seconds * 1000); 1918 } else { 1919 d = new Date(wert); 1920 } 1921 return (d && !isNaN(d.getTime())) ? d : null; 1922 } 1923 1924 tkUhrzeit(d) { 1925 try { 1926 return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); 1927 } catch (e) { return ''; } 1928 } 1929 1930 // Alter statt Datum: Bei einem Vorgang ist "vor 3 Tagen" die Information, 1931 // nach der gehandelt wird - "14.09.2026" muss man erst umrechnen. 1932 tkAlter(wert) { 1933 const d = this.tkDatum(wert); 1934 if (!d) return ''; 1935 const min = Math.floor((Date.now() - d.getTime()) / 60000); 1936 if (min < 1) return t('gerade eben'); 1937 if (min < 60) return t('vor') + ' ' + min + ' ' + t('Min.'); 1938 const std = Math.floor(min / 60); 1939 if (std < 24) return t('vor') + ' ' + std + ' ' + t('Std.'); 1940 const tage = Math.floor(std / 24); 1941 if (tage === 1) return t('gestern'); 1942 if (tage < 31) return t('vor') + ' ' + tage + ' ' + t('Tagen'); 1943 const monate = Math.floor(tage / 30); 1944 if (monate < 12) return t('vor') + ' ' + monate + ' ' + t('Mon.'); 1945 return t('vor über einem Jahr'); 1946 } 1947 1948 renderFullTicketList(tickets) { 1949 if (!tickets || tickets.length === 0) { 1950 // ticksuche-v1: Ohne diese Unterscheidung stand bei einer erfolglosen 1951 // Suche "Keine Tickets vorhanden" samt Aufforderung, einen neuen 1952 // anzulegen - der Bestand sah leer aus, obwohl nur der Filter nicht passte. 1953 const eingeschraenkt = !!(String(this.suche || '').trim() || this.sammel || 1954 Object.keys(this.filters || {}).some(k => this.filters[k])); 1955 if (eingeschraenkt) { 1956 return ` 1957 <tr> 1958 <td colspan="8" class="text-center"> 1959 <div class="p-4"> 1960 <i class="fas fa-search fa-3x text-muted mb-3"></i> 1961 <h4 class="text-muted">${t('Keine Treffer')}</h4> 1962 <p class="text-muted"> 1963 ${t('Zu dieser Suche oder Auswahl gibt es keinen Vorgang.')} 1964 <a href="javascript:void(0)" id="ticket-filter-zuruecksetzen">${t('Auswahl zurücksetzen')}</a> 1965 </p> 1966 </div> 1967 </td> 1968 </tr> 1969 `; 1970 } 1971 return ` 1972 <tr> 1973 <td colspan="8" class="text-center"> 1974 <div class="p-4"> 1975 <i class="fas fa-ticket-alt fa-3x text-muted mb-3"></i> 1976 <h4 class="text-muted">${t('Keine Tickets vorhanden')}</h4> 1977 <p class="text-muted">${t('Klicken Sie auf "Neues Ticket", um ein Ticket zu erstellen.')}</p> 1978 </div> 1979 </td> 1980 </tr> 1981 `; 1982 } 1983 1984 return tickets.map(ticket => {
1985 const statusClass = this.getStatusBadgeClass(ticket.status); 1986 const priorityClass = this.getPriorityBadgeClass(ticket.priority); 1987 1988 return ` 1989 <tr class="ticket-row" data-ticket-id="${this.escapeHtml(ticket.id)}"> 1990 <td class="align-middle ticket-cell" style="cursor: pointer;"> 1991 <span class="ticket-number">${this.escapeHtml(ticket.ticketNumber)}</span> 1992 </td> 1993 <td class="align-middle ticket-cell" style="cursor: pointer;">${this.escapeHtml(ticket.subject)}</td> 1994 <td class="align-middle ticket-cell" style="cursor: pointer;"> 1995 <div> 1996 <div class="fw-semibold">${this.escapeHtml(ticket.customerName)}</div> 1997 <small class="text-muted">${this.escapeHtml(ticket.customerEmail)}</small> 1998 </div> 1999 </td> 2000 <td class="align-middle ticket-cell" style="cursor: pointer;"> 2001 ${this.statusAbzeichen(ticket.status)} 2002 </td> 2003 <td class="align-middle ticket-cell" style="cursor: pointer;"> 2004 ${this.prioAbzeichen(ticket.priority)} 2005 </td> 2006 <td class="align-middle ticket-cell" style="cursor: pointer;"> 2007 <span class="badge bg-secondary">${this.getCategoryLabel(ticket.category)}</span> 2008 </td> 2009 <td class="align-middle ticket-cell" style="cursor: pointer;"> 2010 <small>${this.formatDate(ticket.createdAt)}</small> 2011 </td> 2012 <td class="align-middle text-center"> 2013 <button class="btn btn-sm btn-danger delete-ticket-btn" data-ticket-id="${this.escapeHtml(ticket.id)}" title="Ticket löschen" style="display: inline-block;"> 2014 <i class="fas fa-trash"></i> 2015 </button> 2016 </td> 2017 </tr> 2018 `; 2019 }).join(''); 2020 } 2021 2022 getStatusBadgeClass(status) { 2023 const classes = { 2024 new: 'bg-secondary', 2025 open: 'bg-warning text-dark', 2026 in_progress: 'bg-primary', 2027 pending: 'bg-info', 2028 resolved: 'bg-success', 2029 closed: 'bg-secondary' 2030 }; 2031 return classes[status] || 'bg-secondary'; 2032 } 2033 2034 getPriorityBadgeClass(priority) { 2035 const classes = { 2036 low: 'bg-light text-dark', 2037 normal: 'bg-info', 2038 high: 'bg-warning text-dark', 2039 urgent: 'bg-danger', // tick-ui-v1 2040 critical: 'bg-danger' 2041 }; 2042 return classes[priority] || 'bg-info'; 2043 } 2044 2045 renderActivities(activities) { 2046 if (!activities || activities.length === 0) { 2047 return '<p class="text-muted">Keine Aktivitäten vorhanden</p>'; 2048 } 2049 2050 return activities.map(activity => { 2051 const icon = this.getActivityIcon(activity.type); 2052 const isInternal = activity.isInternal ? 'internal' : ''; 2053 2054 // tick-ui-v4: Die frühere Erkennung stufte einen Eintrag schon dann als 2055 // HTML ein, wenn irgendwo '>' oder '<br>' vorkam - und fügte ihn dann 2056 // ungeprüft ein. Kundenkommentare kommen über /api/public/tickets/<id>/comments 2057 // von auÃen (Nachweis ist allein die E-Mail-Adresse), damit war das ein 2058 // Ausführungsweg in die angemeldete Oberfläche des Mitarbeiters. 2059 // Gemessen: kein einziger der 385 Einträge trug isHtml=true - es geht 2060 // also keine bestehende Formatierung verloren. 2061 const isHtml = activity.isHtml === true && activity.createdBy !== 'customer'; 2062 2063 // Wenn HTML, direkt einfügen; sonst escapen und Zeilenumbrüche zu <br> konvertieren 2064 const displayContent = isHtml 2065 ? activity.content 2066 : this.escapeHtml(activity.content).replace(/\n/g, '<br>'); 2067 2068 return ` 2069 <div class="timeline-item ${isInternal}"> 2070 <div class="timeline-icon">${icon}</div> 2071 <div class="timeline-content"> 2072 <div class="timeline-header">
2073 <span class="timeline-author">${this.escapeHtml(activity.createdByName)}</span> 2074 <span class="timeline-date">${this.formatDateTime(activity.createdAt)}</span> 2075 </div> 2076 <div class="timeline-body">${displayContent}</div> 2077 </div> 2078 </div> 2079 `; 2080 }).join(''); 2081 } 2082 2083 async showCreateTicketModal() { 2084 // Ensure agents are loaded before showing modal 2085 if (this.agents.length === 0) { 2086 await this.loadAgents(); 2087 } 2088 2089 // Reset selected customer 2090 this.selectedCustomer = null; 2091 2092 // kundenfelder-v2: Definitionen der eigenen Felder laden (Bereich 'tickets') 2093 const cfDefs = await ladeKundenfelder(false, 'tickets'); 2094 this._cfDefs = cfDefs; 2095 2096 const modal = ` 2097 <div class="modal fade" id="createTicketModal"> 2098 <div class="modal-dialog modal-xl"> 2099 <div class="modal-content"> 2100 <div class="modal-header bg-primary text-white"> 2101 <h5 class="modal-title"><i class="fas fa-ticket-alt me-2"></i>${t('Neues Ticket erstellen')}</h5> 2102 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button> 2103 </div> 2104 <div class="modal-body"> 2105 <form id="create-ticket-form"> 2106 <div class="row mb-3"> 2107 <div class="col-md-12"> 2108 <label class="form-label fw-bold">${t('Betreff')} *</label> 2109 <input type="text" name="subject" class="form-control form-control-lg" placeholder="${t('Kurze Beschreibung des Problems')}" required> 2110 </div> 2111 </div> 2112 2113 <div class="row mb-3"> 2114 <div class="col-md-12"> 2115 <label class="form-label fw-bold">${t('Beschreibung')} *</label> 2116 <textarea name="description" class="form-control" rows="5" placeholder="${t('Detaillierte Beschreibung...')}" required></textarea> 2117 </div> 2118 </div> 2119 2120 <div class="row mb-3"> 2121 <div class="col-md-6"> 2122 <label class="form-label fw-bold">${t('Kunde')} *</label> 2123 <div class="customer-search-container"> 2124 <div class="input-group"> 2125 <span class="input-group-text"><i class="fas fa-search"></i></span> 2126 <input type="text" 2127 id="customer-search-input" 2128 class="form-control" 2129 placeholder="${t('Name oder E-Mail eingeben...')}" 2130 autocomplete="off"> 2131 <button type="button" class="btn btn-outline-primary" onclick="ticketSystem.showNewContactForm()"> 2132 <i class="fas fa-plus"></i> ${t('Neu')} 2133 </button> 2134 </div> 2135 <input type="hidden" name="customerId" id="selected-customer-id"> 2136 <div id="customer-search-results" class="customer-search-results"></div> 2137 <div id="selected-customer-display" class="selected-customer-display mt-2"></div> 2138 </div> 2139 </div> 2140 <div class="col-md-6"> 2141 <label class="form-label fw-bold">${t('Priorität')}</label> 2142 <select name="priority" class="form-select"> 2143 <option value="low">ð¢ ${t('Niedrig')}</option> 2144 <option value="normal" selected>ð¡ ${t('Normal')}</option> 2145 <option value="high">ð ${t('Hoch')}</option> 2146 <option value="critical">ð´ ${t('Kritisch')}</option> 2147 </select> 2148 </div> 2149 </div> 2150 2151 <div class="row mb-3"> 2152 <div class="col-md-6"> 2153 <label class="form-label fw-bold">${t('Kategorie')}</label> 2154 <select name="category" class="form-select"> 2155 <option value="support" selected>ð¬ ${t('Support')}</option> 2156 <option value="appointment">ð ${t('Termin')}</option> 2157 <option value="complaint">â ï¸ ${t('Beschwerde')}</option> 2158 <option value="inquiry">â ${t('Anfrage')}</option> 2159 </select> 2160 </div> 2161 </div> 2162 2163 <div class="row mb-3" id="ticket-cf-wrap"> 2164 <div class="col-12 d-flex justify-content-between align-items-center"> 2165 <label class="form-label fw-bold mb-0">${t('Eigene Felder')}</label> 2166 <button type="button" class="btn btn-sm btn-outline-secondary" onclick="ticketSystem.verwalteEigeneFelder()"> 2167 <i class="fas fa-cog me-1"></i>${t('Felder verwalten')} 2168 </button> 2169 </div> 2170 ${renderKundenfelder(cfDefs, {})} 2171 </div> 2172 2173 <div class="row mb-3"> 2174 <div class="col-md-12"> 2175 <label class="form-label fw-bold">${t('Zuweisen an')}</label> 2176 <select name="assignedTo" class="form-select"> 2177 <option value="">-- ${t('Nicht zugewiesen')} --</option> 2178 ${this.agents.length > 0 ? 2179 this.agents.map(a => ` 2180 <option value="${a.id}"> 2181 ${a.name} 2182 </option> 2183 `).join('') : 2184 `<option disabled>${t('Keine Mitarbeiter verfügbar')}</option>` 2185 } 2186 </select> 2187 </div> 2188 </div> 2189 2190 <div class="row mb-3"> 2191 <div class="col-md-12"> 2192 <label class="form-label fw-bold"> 2193 <i class="fas fa-paperclip me-2"></i>${t('Anhänge')} 2194 </label> 2195 <div class="custom-file-input-wrapper"> 2196 <input type="file" name="attachments" id="ticket-file-input" class="d-none" multiple accept=".pdf,.png,.jpg,.jpeg,.doc,.docx,.txt"> 2197 <button type="button" class="btn btn-outline-secondary" id="custom-file-btn"> 2198 <i class="fas fa-folder-open me-2"></i><span data-i18n="Dateien auswählen">${t('Dateien auswählen')}</span> 2199 </button>
2200 <span class="ms-3 text-muted" id="file-selection-text" data-i18n="Keine Dateien ausgewählt">${t('Keine Dateien ausgewählt')}</span> 2201 </div> 2202 <small class="text-muted d-block mt-1">${t('Max. 5 Dateien, je max. 10MB. Erlaubte Formate: PDF, Bilder, Word, Text')}</small> 2203 <div id="attachment-preview" class="mt-2"></div> 2204 </div> 2205 </div> 2206 </form> 2207 </div> 2208 <div class="modal-footer bg-light"> 2209 <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="Abbrechen"> 2210 <i class="fas fa-times me-2"></i>${t('Abbrechen')} 2211 </button> 2212 <button type="submit" form="create-ticket-form" class="btn btn-primary" data-i18n="Ticket erstellen"> 2213 <i class="fas fa-check me-2"></i>${t('Ticket erstellen')} 2214 </button> 2215 </div> 2216 </div> 2217 </div> 2218 </div> 2219 `; 2220 2221 // Remove existing modal and any leftover backdrops 2222 $('#createTicketModal').remove(); 2223 $('.modal-backdrop').remove(); 2224 $('body').removeClass('modal-open'); 2225 $('body').css('overflow', ''); 2226 2227 $('body').append(modal); 2228 2229 // Use Bootstrap 5 modal API 2230 const modalElement = document.getElementById('createTicketModal'); 2231 const bsModal = new bootstrap.Modal(modalElement, { 2232 backdrop: 'static', 2233 keyboard: true 2234 }); 2235 2236 bsModal.show(); 2237 2238 // Setup customer search 2239 this.setupCustomerSearch(); 2240 2241 // Setup file upload preview 2242 this.setupFileUploadPreview(); 2243 2244 // Clean up on modal hidden 2245 modalElement.addEventListener('hidden.bs.modal', function() { 2246 // Remove the modal 2247 $(this).remove(); 2248 // Clean up any remaining backdrops (safety measure) 2249 setTimeout(() => { 2250 $('.modal-backdrop').remove(); 2251 $('body').removeClass('modal-open'); 2252 $('body').css('overflow', ''); 2253 }, 100); 2254 }); 2255 } 2256 2257 showAssignModal() { 2258 if (!this.currentTicket) return; 2259 2260 const modal = ` 2261 <div class="modal fade" id="assignModal"> 2262 <div class="modal-dialog"> 2263 <div class="modal-content"> 2264 <div class="modal-header"> 2265 <h5 class="modal-title">Ticket zuweisen</h5> 2266 <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> 2267 </div> 2268 <div class="modal-body"> 2269 <div class="form-group"> 2270 <label>Agent auswählen:</label> 2271 <select id="assign-agent-select" class="form-control"> 2272 <option value="">Nicht zugewiesen</option> 2273 ${this.agents.map(a => ` 2274 <option value="${a.id}" ${this.currentTicket.assignedTo === a.id ? 'selected' : ''}> 2275 ${a.name} 2276 </option> 2277 `).join('')} 2278 </select> 2279 </div> 2280 </div> 2281 <div class="modal-footer"> 2282 <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="Abbrechen">${t('Abbrechen')}</button> 2283 <button type="button" class="btn btn-primary" onclick="ticketSystem.assignTicket('${this.currentTicket.id}', document.getElementById('assign-agent-select').value)" data-i18n="Zuweisen"> 2284 ${t('Zuweisen')} 2285 </button> 2286 </div> 2287 </div> 2288 </div> 2289 </div> 2290 `; 2291 2292 // Remove existing modal if present 2293 $('#assignModal').remove(); 2294 $('body').append(modal); 2295 2296 // Use Bootstrap 5 modal API 2297 const modalElement = document.getElementById('assignModal'); 2298 const bsModal = new bootstrap.Modal(modalElement); 2299 bsModal.show(); 2300 2301 modalElement.addEventListener('hidden.bs.modal', function() { 2302 $(this).remove(); 2303 }); 2304 } 2305 2306 cleanupModals() { 2307 // Clean up any leftover modal backdrops 2308 $('.modal-backdrop').remove(); 2309 $('body').removeClass('modal-open'); 2310 $('body').css('overflow', ''); 2311 $('body').css('padding-right', ''); 2312 } 2313 2314 async switchView(view) { 2315 console.log('switchView called with:', view); 2316 this.currentView = view; 2317 // Clean up any modals when switching views 2318 this.cleanupModals(); 2319 $('.ticket-nav-item').removeClass('active'); 2320 $(`.ticket-nav-item[data-view="${view}"]`).addClass('active'); 2321 2322 switch(view) { 2323 case 'dashboard': 2324 console.log('Loading dashboard...'); 2325 this.loadDashboard(); 2326 break; 2327 case 'tickets': 2328 console.log('Loading tickets...'); 2329 // ticketui-v1: Kam der Wechsel von einer Kachel, traegt 2330 // this.sammel die Sammelsicht. Ein Klick auf "Alle Tickets" 2331 // im Menue setzt sie vorher zurueck (siehe Menuebindung). 2332 // ticksuche-v1b: mit dem ANGEZEIGTEN Zustand laden. V
2332orher lud 2333 // dieser Zweig ungefiltert, waehrend Suchfeld und Auswahlfelder 2334 // weiter ihren Filter zeigten - Anzeige und Liste widersprachen 2335 // sich, und "mehr laden" sprang danach auf die Treffermenge. 2336 this.loadTickets(this.aktiveFilterParams()); 2337 break; 2338 case 'new': 2339 console.log('Showing create ticket modal...'); 2340 await this.showCreateTicketModal(); 2341 break; 2342 case 'analytics': 2343 console.log('Loading analytics...'); 2344 this.loadAnalytics(); 2345 break; 2346 default: 2347 console.error('Unknown view:', view); 2348 } 2349 } 2350 2351 async loadAnalytics() { 2352 console.log('loadAnalytics called'); 2353 this.showLoading(); 2354 try { 2355 const headers = await this.getAuthHeaders(); 2356 const response = await fetch(`${this.baseUrl}/api/tickets/analytics`, { 2357 method: 'GET', 2358 credentials: 'include', 2359 headers: headers 2360 }); 2361 const data = await response.json(); 2362 console.log('Analytics data received:', data); 2363 if (data.success) { 2364 this.analytics = data.analytics; 2365 this.renderAnalytics(); 2366 } else { 2367 console.error('Analytics response not successful:', data); 2368 } 2369 } catch (error) { 2370 console.error('Error loading analytics:', error); 2371 this.showError('Analytics konnten nicht geladen werden'); 2372 } finally { 2373 this.hideLoading(); 2374 } 2375 } 2376 2377 renderAnalytics() { 2378 if (!this.analytics) { 2379 $('#ticket-content').html('<div class="text-center p-4"><h3>Keine Analytics-Daten verfügbar</h3></div>'); 2380 return; 2381 } 2382 2383 const data = this.analytics; 2384 console.log('Analytics data:', data); 2385 2386 let html = ` 2387 <div class="container-fluid"> 2388 <div class="row mb-4"> 2389 <div class="col-12 d-flex justify-content-between align-items-center"> 2390 <h1>${t('Ticket Analytics')}</h1> 2391 <div> 2392 <button class="btn btn-success btn-sm" onclick="window.ticketSystem.showWidgetConfig()"> 2393 <i class="fas fa-code"></i> ${t('Widget Integration')} 2394 </button> 2395 </div> 2396 </div> 2397 </div> 2398 2399 <!-- Key Metrics --> 2400 <div class="row mb-4"> 2401 <div class="col-md-3"> 2402 <div class="card"> 2403 <div class="card-body text-center"> 2404 <h3 class="text-primary">${data.totalTickets || 0}</h3> 2405 <p class="text-muted mb-0">${t('Gesamt Tickets')}</p> 2406 </div> 2407 </div> 2408 </div> 2409 <div class="col-md-3"> 2410 <div class="card"> 2411 <div class="card-body text-center"> 2412 <h3 class="text-success">${data.timeMetrics?.ticketsLast7Days || 0}</h3> 2413 <p class="text-muted mb-0">${t('Letzte 7 Tage')}</p> 2414 </div> 2415 </div> 2416 </div> 2417 <div class="col-md-3"> 2418 <div class="card"> 2419 <div class="card-body text-center"> 2420 <h3 class="text-info">${this.formatResolutionTime(data.timeMetrics?.averageResolutionTime)}</h3> 2421 <p class="text-muted mb-0">${t('à Lösungszeit')}</p> 2422 </div> 2423 </div> 2424 </div> 2425 <div class="col-md-3"> 2426 <div class="card"> 2427 <div class="card-body text-center"> 2428 <h3 class="text-warning">${data.customerStats?.uniqueCustomers || 0}</h3> 2429 <p class="text-muted mb-0">${t('Unique Kunden')}</p> 2430 </div> 2431 </div> 2432 </div> 2433 </div> 2434 2435 <!-- Status & Priority Charts --> 2436 <div class="row mb-4"> 2437 <div class="col-md-6"> 2438 <div class="card"> 2439 <div class="card-header"> 2440 <h5 class="mb-0">${t('Status Verteilung')}</h5> 2441 </div> 2442 <div class="card-body">
2443 <canvas id="statusChart" height="300"></canvas> 2444 <div class="mt-3"> 2445 ${this.renderStatusBreakdown(data.statusBreakdown || {})} 2446 </div> 2447 </div> 2448 </div> 2449 </div> 2450 <div class="col-md-6"> 2451 <div class="card"> 2452 <div class="card-header"> 2453 <h5 class="mb-0">${t('Priorität Verteilung')}</h5> 2454 </div> 2455 <div class="card-body"> 2456 <canvas id="priorityChart" height="300"></canvas> 2457 <div class="mt-3"> 2458 ${this.renderPriorityBreakdown(data.priorityBreakdown || {})} 2459 </div> 2460 </div> 2461 </div> 2462 </div> 2463 </div> 2464 2465 <!-- Trend Chart --> 2466 <div class="row mb-4"> 2467 <div class="col-12"> 2468 <div class="card"> 2469 <div class="card-header"> 2470 <h5 class="mb-0">${t('30-Tage Trend')}</h5> 2471 </div> 2472 <div class="card-body"> 2473 <canvas id="trendChart" height="100"></canvas> 2474 </div> 2475 </div> 2476 </div> 2477 </div> 2478 2479 <!-- Agent Performance --> 2480 <div class="row mb-4"> 2481 <div class="col-md-6"> 2482 <div class="card"> 2483 <div class="card-header"> 2484 <h5 class="mb-0">${t('Mitarbeiter Performance')}</h5> 2485 </div> 2486 <div class="card-body"> 2487 <div class="table-responsive"> 2488 <table class="table table-sm"> 2489 <thead> 2490 <tr> 2491 <th>${t('Mitarbeiter')}</th> 2492 <th>${t('Tickets')}</th> 2493 <th>${t('Gelöst')}</th> 2494 <th>${t('à Zeit')}</th> 2495 </tr> 2496 </thead> 2497 <tbody> 2498 ${this.renderAgentPerformance(data.agentPerformance || {})} 2499 </tbody> 2500 </table> 2501 </div> 2502 </div> 2503 </div> 2504 </div> 2505 2506 <!-- Top Customers --> 2507 <div class="col-md-6"> 2508 <div class="card"> 2509 <div class="card-header"> 2510 <h5 class="mb-0">${t('Top Kunden')}</h5> 2511 </div> 2512 <div class="card-body"> 2513 <div class="table-responsive"> 2514 <table class="table table-sm"> 2515 <thead> 2516 <tr> 2517 <th>${t('Kunde')}</th> 2518 <th>${t('E-Mail')}</th> 2519 <th>${t('Tickets')}</th> 2520 </tr> 2521 </thead> 2522 <tbody> 2523 ${this.renderTopCustomers(data.customerStats?.topCustomers || [])} 2524 </tbody> 2525 </table> 2526 </div> 2527 </div> 2528 </div> 2529 </div> 2530 </div> 2531 2532 <!-- Category Distribution --> 2533 <div class="row mb-4"> 2534 <div class="col-md-12"> 2535 <div class="card"> 2536 <div class="card-header"> 2537 <h5 class="mb-0">${t('Kategorie Verteilung')}</h5> 2538 </div> 2539 <div class="card-body"> 2540 <div class="row"> 2541 ${this.renderCategoryBreakdown(data.categoryBreakdown || {})} 2542 </div> 2543 </div> 2544 </div> 2545 </div> 2546 </div> 2547 </div> 2548 `; 2549
2550 $('#ticket-content').html(html); 2551 2552 // Render charts after DOM is ready 2553 setTimeout(() => { 2554 this.renderCharts(data); 2555 }, 100); 2556 } 2557 2558 renderStatusBreakdown(statusData) { 2559 if (!statusData || Object.keys(statusData).length === 0) { 2560 return `<p class="text-muted text-center">${t('Keine Daten verfügbar')}</p>`; 2561 } 2562 2563 return Object.entries(statusData).map(([status, count]) => { 2564 const totalTickets = this.analytics?.totalTickets || 0; 2565 const percentage = totalTickets > 0 2566 ? ((count / totalTickets) * 100).toFixed(1) 2567 : 0; 2568 const badgeClass = this.getStatusBadgeClass(status); 2569 return ` 2570 <div class="d-flex justify-content-between align-items-center mb-2"> 2571 <span class="badge ${badgeClass}">${this.getStatusLabel(status)}</span> 2572 <span>${count} (${percentage}%)</span> 2573 </div> 2574 `; 2575 }).join(''); 2576 } 2577
2578 renderPriorityBreakdown(priorityData) { 2579 if (!priorityData || Object.keys(priorityData).length === 0) { 2580 return `<p class="text-muted text-center">${t('Keine Daten verfügbar')}</p>`; 2581 } 2582 2583 return Object.entries(priorityData).map(([priority, count]) => { 2584 const totalTickets = this.analytics?.totalTickets || 0; 2585 const percentage = totalTickets > 0 2586 ? ((count / totalTickets) * 100).toFixed(1) 2587 : 0; 2588 const badgeClass = this.getPriorityBadgeClass(priority); 2589 return ` 2590 <div class="d-flex justify-content-between align-items-center mb-2"> 2591 <span class="badge ${badgeClass}">${this.getPriorityLabel(priority)}</span> 2592 <span>${count} (${percentage}%)</span> 2593 </div> 2594 `; 2595 }).join(''); 2596 } 2597 2598 renderCategoryBreakdown(categoryData) { 2599 if (!categoryData || Object.keys(categoryData).length === 0) { 2600 return `<div class="col-12"><p class="text-muted text-center">${t('Keine Daten verfügbar')}</p></div>`; 2601 } 2602 2603 return Object.entries(categoryData).map(([category, count]) => { 2604 const totalTickets = this.analytics?.totalTickets || 0; 2605 const percentage = totalTickets > 0 2606 ? ((count / totalTickets) * 100).toFixed(1) 2607 : 0; 2608 return ` 2609 <div class="col-md-3 mb-3"> 2610 <div class="text-center"> 2611 <h4 class="text-primary">${count}</h4> 2612 <p class="text-muted mb-1">${this.getCategoryLabel(category)}</p> 2613 <small class="text-muted">${percentage}%</small> 2614 </div> 2615 </div> 2616 `; 2617 }).join(''); 2618 } 2619 2620 renderAgentPerformance(agentData) { 2621 if (Object.keys(agentData).length === 0) { 2622 return `<tr><td colspan="4" class="text-center text-muted">${t('Keine Daten verfügbar')}</td></tr>`; 2623 } 2624 2625 return Object.entries(agentData).map(([agentId, stats]) => { 2626 const resolutionRate = stats.totalTickets > 0 2627 ? ((stats.resolvedTickets / stats.totalTickets) * 100).toFixed(0) 2628 : 0; 2629 return ` 2630 <tr> 2631 <td>${stats.name}</td> 2632 <td>${stats.totalTickets}</td> 2633 <td>${stats.resolvedTickets} (${resolutionRate}%)</td> 2634 <td>${stats.avgResolutionTime ? stats.avgResolutionTime.toFixed(1) + 'h' : '-'}</td> 2635 </tr> 2636 `; 2637 }).join(''); 2638 } 2639 2640 renderTopCustomers(customers) { 2641 if (customers.length === 0) { 2642 return `<tr><td colspan="3" class="text-center text-muted">${t('Keine Daten verfügbar')}</td></tr>`; 2643 } 2644 2645 return customers.map(customer => ` 2646 <tr> 2647 <td>${customer.name}</td> 2648 <td><small>${customer.email}</small></td> 2649 <td><span class="badge bg-primary">${customer.ticketCount}</span></td> 2650 </tr> 2651 `).join(''); 2652 } 2653 2654 renderCharts(data) { 2655 // Status Pie Chart 2656 const statusCtx = document.getElementById('statusChart'); 2657 if (statusCtx && data.statusBreakdown) { 2658 const statusLabels = Object.keys(data.statusBreakdown).map(s => this.getStatusLabel(s)); 2659 const statusValues = Object.values(data.statusBreakdown); 2660 2661 if (statusLabels.length > 0 && statusValues.length > 0) { 2662 // Simple bar chart using CSS 2663 this.renderSimpleBarChart(statusCtx, statusLabels, statusValues, 2664 ['#28a745', '#ffc107', '#007bff', '#17a2b8', '#28a745', '#6c757d']); 2665 } 2666 } 2667 2668 // Priority Pie Chart 2669 const priorityCtx = document.getElementById('priorityChart'); 2670 if (priorityCtx && data.priorityBreakdown) { 2671 const priorityLabels = Object.keys(data.priorityBreakdown).map(p =>
2671 this.getPriorityLabel(p)); 2672 const priorityValues = Object.values(data.priorityBreakdown); 2673 2674 if (priorityLabels.length > 0 && priorityValues.length > 0) { 2675 // Simple bar chart using CSS 2676 this.renderSimpleBarChart(priorityCtx, priorityLabels, priorityValues, 2677 ['#f8f9fa', '#17a2b8', '#ffc107', '#dc3545']); 2678 } 2679 } 2680 2681 // Trend Line Chart 2682 const trendCtx = document.getElementById('trendChart'); 2683 if (trendCtx && data.trends?.daily?.length > 0) { 2684 this.renderTrendChart(trendCtx, data.trends.daily); 2685 } 2686 } 2687 2688 renderSimpleBarChart(canvas, labels, values, colors) { 2689 const maxValue = Math.max(...values); 2690 const chartHtml = labels.map((label, index) => { 2691 const percentage = maxValue > 0 ? (values[index] / maxValue) * 100 : 0; 2692 return ` 2693 <div class="mb-3"> 2694 <div class="d-flex justify-content-between mb-1"> 2695 <small>${label}</small> 2696 <small>${values[index]}</small> 2697 </div> 2698 <div class="progress" style="height: 20px;"> 2699 <div class="progress-bar" role="progressbar" 2700 style="width: ${percentage}%; background-color: ${colors[index]}"> 2701 </div> 2702 </div> 2703 </div> 2704 `; 2705 }).join(''); 2706 2707 canvas.parentElement.innerHTML = chartHtml; 2708 } 2709 2710 renderTrendChart(canvas, dailyData) { 2711 // Simple line chart using SVG 2712 const width = canvas.parentElement.offsetWidth; 2713 const height = 300; 2714 const padding = 40; 2715 2716 const maxCreated = Math.max(...dailyData.map(d => d.created)); 2717 const maxResolved = Math.max(...dailyData.map(d => d.resolved)); 2718 const maxValue = Math.max(maxCreated, maxResolved, 1); 2719 2720 const xStep = (width - 2 * padding) / (dailyData.length - 1); 2721 const yScale = (height - 2 * padding) / maxValue; 2722 2723 const createdPoints = dailyData.map((d, i) => 2724 `${padding + i * xStep},${height - padding - d.created * yScale}` 2725 ).join(' '); 2726 2727 const resolvedPoints = dailyData.map((d, i) => 2728 `${padding + i * xStep},${height - padding - d.resolved * yScale}` 2729 ).join(' '); 2730 2731 const svg = ` 2732 <svg width="${width}" height="${height}" style="background: #f8f9fa;"> 2733 <!-- Grid lines --> 2734 ${[0, 0.25, 0.5, 0.75, 1].map(i => { 2735 const y = padding + i * (height - 2 * padding); 2736 const value = Math.round(maxValue * (1 - i)); 2737 return ` 2738 <line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" 2739 stroke="#e0e0e0" stroke-width="1"/> 2740 <text x="${padding - 5}
2740" y="${y + 5}" text-anchor="end" 2741 font-size="12" fill="#666">${value}</text> 2742 `; 2743 }).join('')} 2744 2745 <!-- Created line --> 2746 <polyline points="${createdPoints}" 2747 fill="none" stroke="#007bff" stroke-width="2"/> 2748 2749 <!-- Resolved line --> 2750 <polyline points="${resolvedPoints}" 2751 fill="none" stroke="#28a745" stroke-width="2"/> 2752 2753 <!-- Legend --> 2754 <rect x="${width - 150}" y="10" width="15" height="15" fill="#007bff"/> 2755 <text x="${width - 130}" y="22" font-size="12" fill="#666">${t('Erstellt')}</text> 2756 2757 <rect x="${width - 150}" y="30" width="15" height="15" fill="#28a745"/> 2758 <text x="${width - 130}" y="42" font-size="12" fill="#666">${t('Gelöst')}</text> 2759 2760 <!-- Date labels --> 2761 ${dailyData.filter((d, i) => i % 5 === 0).map((d, idx, arr) => { 2762 const i = dailyData.indexOf(d); 2763 const x = padding + i * xStep; 2764 const date = new Date(d.date); 2765 const label = `${date.getDate()}.${date.getMonth() + 1}`; 2766 return ` 2767 <text x="${x}" y="${height - 10}" text-anchor="middle" 2768 font-size="10" fill="#666">${label}</text> 2769 `; 2770 }).join('')} 2771 </svg> 2772 `; 2773 2774 canvas.parentElement.innerHTML = svg; 2775 } 2776 2777 // ticksuche-v1: EINE Quelle fuer die aktiven Filter. Vorher baute die 2778 // Nachlade-Schleife des Abgleichs ihre Parameter selbst zusammen und 2779 // liess dabei die Sammelsicht weg - nach einem neuen Vorgang stand die 2780 // Liste still wieder ungefiltert da, obwohl oben ein Filter aktiv war. 2781 aktiveFilterParams() { 2782 const params = {}; 2783 if (this.sammel) params.sammel = this.sammel; // ticketui-v1 2784 Object.keys(this.filters || {}).forEach(key => { 2785 if (this.filters[key]) params[key] = this.filters[key]; 2786 }); 2787 const such = String(this.suche || '').trim(); 2788 if (such) params.suche = such; 2789 params.limit = this.seitengroesse || 50; // tick-ui-v3 2790 return params; 2791 } 2792 2793 applyFilters() { 2794 this.loadTickets(this.aktiveFilterParams()); 2795 } 2796 2797 subscribeToTicketUpdates() { 2798 // Migration 2026-05-07: Realtime via Firestore onSnapshot ersetzt durch Polling. 2799 // Backend ist nach Postgres-Cutover (SUPPORT_BACKEND=postgres) Source of Truth, 2800 // Firestore wird nicht mehr beschrieben - onSnapshot wuerde leere Updates liefern. 2801 // 10s-Polling ist fuer Support-Tickets ausreichend (nicht zeitkritisch). 2802 2803 // If tenantId is not set yet, try to get it from Auth module 2804 if (!this.tenantId && typeof window.Auth !== 'undefined' && window.Auth.currentUser) { 2805 this.tenantId = window.Auth.currentUser.uid; 2806 console.log('Setting tenant ID from Auth module for subscription:', this.tenantId); 2807 } 2808 2809 if (!this.tenantId) { 2810 console.warn('Cannot start ticket-poll - missing tenant ID'); 2811 return; 2812 } 2813 2814 // Stop any existing poller before starting a new one 2815 this.unsubscribeFromTicketUpdates(); 2816 2817 console.log('Starting ticket-update poller (10s) for tenant:', this.tenantId); 2818 2819 const POLL_INTERVAL_MS = 10000; 2820 const fetchAndDiff = async () => { 2821 try { 2822 const headers = await this.getAuthHeaders(); 2823 if (!headers || !headers['Authorization']) return; 2824 const params = new URLSearchParams({ limit: '50', offset: '0' }); 2825 const resp = await fetch(`/api/tickets?${params.toString()}`, { headers }); 2826 if (!resp.ok) return; 2827 const payload = await resp.json(); 2828 const incoming = (payload && payload.tickets) || []; 2829 2830 // Build map of current tickets by id for diffing 2831 const currentById = {}; 2832 for (const t of (this.tickets || [])) { 2833 if (t && t.id) currentById[t.id] = t; 2834 } 2835 2836 let hadAnyChange = false;
2837 let neueVorgaenge = false; 2838 for (const ticket of incoming) { 2839 if (!ticket || !ticket.id) continue; 2840 // tick-ui-v2: Der frühere Abgleich 'ticket.tenantId !== this.tenantId' 2841 // warf alles weg, was die digitale Rezeption unter der numerischen 2842 // Mandanten-Kennung anlegt - und bei Mitarbeitern sogar den gesamten 2843 // Bestand. Die Abgrenzung leistet der Server, hier ist sie schädlich. 2844 2845 const prev = currentById[ticket.id]; 2846 if (!prev) { 2847 // tick-ui-v2: Ein neuer Vorgang setzte zwar 'hadAnyChange', wurde 2848 // aber nie in this.tickets aufgenommen -> die Liste rendert sich 2849 // alle 10 Sekunden neu und zeigt ihn trotzdem nicht. 2850 neueVorgaenge = true; 2851 hadAnyChange = true; 2852 continue; 2853 } 2854 const prevUpdated = prev.updatedAt && (prev.updatedAt._seconds || prev.updatedAt.seconds || prev.updatedAt); 2855 const newUpdated = ticket.updatedAt && (ticket.updatedAt._seconds || ticket.updatedAt.seconds || ticket.updatedAt); 2856 if (String(prevUpdated || '') !== String(newUpdated || '')) { 2857 // Ticket modified 2858 const idx = this.tickets.findIndex(t => t.id === ticket.id); 2859 if (idx !== -1) this.tickets[idx] = ticket; 2860 if (this.currentTicket && this.currentTicket.id === ticket.id) { 2861 this.currentTicket = { ...this.currentTicket, ...ticket }; 2862 if (typeof this.renderTicketDetail === 'function') this.renderTicketDetail(); 2863 } 2864 if (typeof this.showNotification === 'function') { 2865 this.showNotification(`Ticket ${ticket.ticketNumber} wurde aktualisiert`); 2866 } 2867 hadAnyChange = true; 2868 } 2869 } 2870 2871 if (neueVorgaenge) { 2872 // tick-ui-v2: sauber nachladen - respektiert die aktiven Filter, 2873 // die der Abgleich oben (limit 50, ungefiltert) nicht kennt. 2874 const aktiveFilter = this.aktiveFilterParams(); // ticksuche-v1 2875 if (this.currentView === 'tickets') await this.loadTickets(aktiveFilter); 2876 else if (this.currentView === 'dashboard') await this.loadDashboard(); 2877 } else if (hadAnyChange && typeof this.renderTicketList === 'function') { 2878 this.renderTicketList(); 2879 } 2880 } catch (e) { 2881 console.warn('Ticket poll iteration failed:', e); 2882 } 2883 }; 2884 2885 this._ticketPollTimer = setInterval(fetchAndDiff, POLL_INTERVAL_MS); 2886 } 2887 2888 unsubscribeFromTicketUpdates() { 2889 if (this._ticketPollTimer) { 2890 clearInterval(this._ticketPollTimer); 2891 this._ticketPollTimer = null; 2892 console.log('Stopped ticket-update poller'); 2893 } 2894 } 2895 2896 // Helper methods 2897 getPriorityLabel(priority) { 2898 const labels = { 2899 low: t('Niedrig'), 2900 normal: t('Normal'), 2901 high: t('Hoch'), 2902 urgent: t('Dringend'), // tick-ui-v1 2903 critical: t('Kritisch') 2904 }; 2905 return labels[priority] || priority; 2906 } 2907 2908 getStatusLabel(status) { 2909 const labels = { 2910 new: t('Neu'), 2911 open: t('Offen'), 2912 in_progress: t('In Bearbeitung'), 2913 pending: t('Wartend'), 2914 resolved: t('Gelöst'), 2915 closed: t('Geschlossen') 2916 }; 2917 return labels[status] || status; 2918 } 2919 2920 getCategoryLabel(category) { 2921 // tick-ui-v1: Die Kategorien der digitalen Rezeption fehlten hier komplett - 2922 // bei Praxen, deren Vorgaenge ausschliesslich per Telefon entstehen, stand 2923 // in der Spalte 'Kategorie' der technische Schluessel ('rueckruf', 'au'). 2924 const labels = { 2925 support: t('Support'), 2926 appointment: t('Termin'), 2927 complaint: t('Beschwerde'), 2928 inquiry: t('Anfrage'), 2929 termin: t('Terminanfrage'), 2930 rezept: t('Rezeptbestellung'), 2931 befund: t('Befundanfrage'), 2932 au: t('Krankschreibung (AU)'), 2933 ueberweisung: t('Ãberweisung'), 2934 rueckruf: t('Rückrufbitte'), 2935 frage: t('Allgemeine Anfrage'), 2936 verordnung: t('Verordnung'), 2937 funktionswunsch: t('Funktionswunsch') 2938 }; 2939 return labels[category] || category; 2940 } 2941 2942 getActivityIcon(type) { 2943 const icons = { 2944 ticket_created: '<i class="fas fa-plus-circle"></i>', 2945 comment: '<i class="fas fa-comment"></i>', 2946 status_change: '<i class="fas fa-exchange-alt"></i>', 2947 assignment: '<i class="fas fa-user-tag"></i>', 2948 update: '<i class="fas fa-edit"></i>', 2949 email: '<i class="fas fa-envelope"></i>',
2950 deletion: '<i class="fas fa-trash"></i>' 2951 }; 2952 return icons[type] || '<i class="fas fa-circle"></i>'; 2953 } 2954 2955 formatDate(dateString) { 2956 if (!dateString) return '-'; 2957 const date = new Date(dateString); 2958 return date.toLocaleDateString(getLocale()); 2959 } 2960 2961 formatDateTime(dateString) { 2962 if (!dateString) return '-'; 2963 const date = new Date(dateString); 2964 return date.toLocaleString(getLocale()); 2965 } 2966 2967 // tick-ui-v1: nur http/https zulassen - eine Adresse aus einem Vorgang kann 2968 // aus einem Telefonat oder dem oeffentlichen Widget stammen, javascript: 2969 // waere dort ein direkter Ausfuehrungsweg. 2970 sicherLink(url) { 2971 const s = String(url == null ? '' : url).trim(); 2972 if (!/^https?:\/\//i.test(s)) return '#'; 2973 return this.escapeHtml(s); 2974 } 2975 2976 escapeHtml(text) { 2977 if (!text) return ''; 2978 const map = { 2979 '&': '&', 2980 '<': '<', 2981 '>': '>', 2982 '"': '"', 2983 "'": ''' 2984 }; 2985 return text.toString().replace(/[&<>"']/g, m => map[m]); 2986 } 2987 2988 showLoading() { 2989 this.isLoading = true; 2990 $('#ticket-content').prepend('<div class="loading-overlay"><i class="fas fa-spinner fa-spin"></i> Laden...</div>'); 2991 } 2992 2993 hideLoading() { 2994 this.isLoading = false; 2995 $('.loading-overlay').remove(); 2996 } 2997 2998 showSuccess(message) { 2999 if (typeof toastr !== 'undefined') { 3000 toastr.success(message); 3001 } else { 3002 console.log('Success:', message); 3003 alert('Erfolg: ' + message); 3004 } 3005 } 3006 3007 showError(message) { 3008 if (typeof toastr !== 'undefined') { 3009 toastr.error(message); 3010 } else { 3011 console.error('Error:', message); 3012 alert('Fehler: ' + message); 3013 } 3014 } 3015 3016 showSuccess(message) { 3017 if (typeof toastr !== 'undefined') { 3018 toastr.success(message); 3019 } else { 3020 console.log('Success:', message); 3021 // Optional: Show success in UI 3022 const alertHtml = `<div class="alert alert-success alert-dismissible fade show" role="alert"> 3023 ${message} 3024 <button type="button" class="btn-close" data-bs-dismiss="alert"></button> 3025 </div>`; 3026 $('#ticket-content').prepend(alertHtml); 3027 setTimeout(() => $('.alert-success').fadeOut(), 3000); 3028 } 3029 } 3030 3031 showInfo(message) { 3032 if (typeof toastr !== 'undefined') { 3033 toastr.info(message); 3034 } else { 3035 console.info('Info:', message); 3036 // Optional: Show info in UI 3037 const alertHtml = `<div class="alert alert-info alert-dismissible fade show" role="alert"> 3038 ${message} 3039 <button type="button" class="btn-close" data-bs-dismiss="alert"></button> 3040 </div>`; 3041 $('#ticket-content').prepend(alertHtml); 3042 setTimeout(() => $('.alert-info').fadeOut(), 3000); 3043 } 3044 } 3045 3046 showNotification(message) { 3047 this.showInfo(message); 3048 } 3049 3050 showAIResponseModal() { 3051 if (!this.currentTicket) { 3052 this.showError('Kein Ticket ausgewählt'); 3053 return; 3054 } 3055 3056 const ticket = this.currentTicket; 3057 3058 const modalHtml = ` 3059 <div class="modal fade" id="aiResponseModal" tabindex="-1" aria-hidden="true"> 3060 <div class="modal-dialog modal-lg"> 3061 <div class="modal-content"> 3062 <div class="modal-header bg-primary text-white"> 3063 <h5 class="modal-title"> 3064 <i class="fas fa-magic"></i> KI-Antwort generieren 3065 </h5> 3066 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> 3067 </div> 3068 <div class="modal-body"> 3069 <div class="mb-3"> 3070 <label class="form-label">Ticket-Kontext:</label> 3071 <div class="card bg-light"> 3072 <div class="card-body"> 3073 <p class="mb-1"><strong>Kunde:</strong> ${ticket.customerName}</p> 3074 <p class="mb-1"><strong>Betreff:</strong> ${ticket.subject}</p> 3075 <p class="mb-0"><strong>Problem:</strong> ${ticket.description}</p> 3076 </div> 3077 </div> 3078 </div> 3079 3080 <div class="mb-3"> 3081 <label for="ai-prompt" class="form-label"> 3082 Was möchten Sie antworten? (Beschreiben Sie kurz die gewünschte Antwort) 3083 </label> 3084 <div class="input-group"> 3085 <textarea id="ai-prompt" class="form-control" rows="3" 3086 placeholder="z.B. Erkläre dem Kunden, dass wir das Problem identifiziert haben und an einer Lösung arbeiten..."></textarea> 3087 <button type="button" class="btn btn-outline-secondary" id="voice-input-btn" title="Spracheingabe"> 3088 <i class="fas fa-microphone"></i> 3089 </button> 3090 </div> 3091 <div id="voice-status" class="text-muted small mt-1" style="display: none;"> 3092 <i class="fas fa-circle text-danger"></i> Sprechen Sie jetzt... 3093 </div> 3094 </div> 3095 3096 <div class="mb-3"> 3097 <label for="ai-tone" class="form-label">Tonalität</label> 3098 <select id="ai-tone" class="form-select"> 3099 <option value="professional">Professionell</option> 3100 <option value="friendly">Freundlich</option> 3101 <option value="technical">Technisch</option> 3102 <option value="apologetic">Entschuldigend</option> 3103 </select> 3104 </div> 3105 3106 <div id="ai-response-result" style="display: none;"> 3107 <label class="form-label">Generierte Antwort:</label> 3108 <div class="card"> 3109 <div class="card-body"> 3110 <div id="ai-response-content"></div> 3111 </div> 3112 </div> 3113 </div> 3114 </div> 3115 <div class="modal-footer"> 3116 <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> 3117 Abbrechen 3118 </button> 3119 <button type="button" class="btn btn-primary" id="generate-ai-response-btn" onclick="ticketSystem.generateAIResponse()"> 3120 <i class="fas fa-magic"></i> Antwort generieren 3121 </button> 3122 <button type="button" class="btn btn-success" id="use-ai-response-btn" style="display: none;" onclick="ticketSystem.useAIResponse()"> 3123 <i class="fas fa-check"></i> Antwort verwenden 3124 </button> 3125 </div> 3126 </div> 3127 </div> 3128 </div> 3129 `; 3130 3131 // Remove existing modal if any 3132 $('#aiResponseModal').remove(); 3133 3134 // Add modal to body 3135 $('body').append(modalHtml); 3136 3137 // Show modal 3138 const modal = new bootstrap.Modal(document.getElementById('aiResponseModal')); 3139 modal.show(); 3140 3141 // Setup voice input 3142 this.setupVoiceInput(); 3143 } 3144 3145 setupVoiceInput() { 3146 const voiceBtn = document.getElementById('voice-input-btn'); 3147 const aiPromptTextarea = document.getElementById('ai-prompt'); 3148 const voiceStatus = document.getElementById('voice-status'); 3149 3150 if (!voiceBtn) return; 3151 3152 // Check if browser supports speech recognition 3153 const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; 3154 3155 if (!SpeechRecognition) { 3156 voiceBtn.style.display = 'none'; 3157 return; 3158 } 3159 3160 let recognition = null; 3161 let isRecording = false;
3162 3163 voiceBtn.addEventListener('click', () => { 3164 if (isRecording) { 3165 // Stop recording 3166 if (recognition) { 3167 recognition.stop(); 3168 } 3169 isRecording = false; 3170 voiceBtn.classList.remove('btn-danger'); 3171 voiceBtn.classList.add('btn-outline-secondary'); 3172 voiceBtn.innerHTML = '<i class="fas fa-microphone"></i>'; 3173 voiceStatus.style.display = 'none'; 3174 } else { 3175 // Start recording 3176 recognition = new SpeechRecognition(); 3177 recognition.lang = 'de-DE'; 3178 recognition.continuous = true; 3179 recognition.interimResults = true; 3180 3181 isRecording = true; 3182 voiceBtn.classList.remove('btn-outline-secondary'); 3183 voiceBtn.classList.add('btn-danger'); 3184 voiceBtn.innerHTML = '<i class="fas fa-stop"></i>'; 3185 voiceStatus.style.display = 'block'; 3186 3187 let finalTranscript = aiPromptTextarea.value || ''; 3188 3189 recognition.onresult = (event) => { 3190 let interimTranscript = ''; 3191 3192 for (let i = event.resultIndex; i < event.results.length; i++) { 3193 const transcript = event.results[i][0].transcript; 3194 if (event.results[i].isFinal) { 3195 finalTranscript += transcript + ' '; 3196 } else { 3197 interimTranscript += transcript; 3198 } 3199 } 3200 3201 // Update textarea with current transcription 3202 aiPromptTextarea.value = finalTranscript + interimTranscript; 3203 }; 3204 3205 recognition.onerror = (event) => { 3206 console.error('Speech recognition error:', event.error); 3207 this.showError('Spracherkennung fehlgeschlagen: ' + event.error); 3208 3209 // Reset button 3210 isRecording = false; 3211 voiceBtn.classList.remove('btn-danger'); 3212 voiceBtn.classList.add('btn-outline-secondary'); 3213 voiceBtn.innerHTML = '<i class="fas fa-microphone"></i>'; 3214 voiceStatus.style.display = 'none'; 3215 }; 3216 3217 recognition.onend = () => { 3218 // Reset button if still recording 3219 if (isRecording) { 3220 isRecording = false; 3221 voiceBtn.classList.remove('btn-danger'); 3222 voiceBtn.classList.add('btn-outline-secondary'); 3223 voiceBtn.innerHTML = '<i class="fas fa-microphone"></i>'; 3224 voiceStatus.style.display = 'none'; 3225 } 3226 }; 3227 3228 try { 3229 recognition.start(); 3230 } catch (error) { 3231 console.error('Failed to start speech recognition:', error); 3232 this.showError('Konnte Spracherkennung nicht starten'); 3233 3234 // Reset button 3235 isRecording = false; 3236 voiceBtn.classList.remove('btn-danger'); 3237 voiceBtn.classList.add('btn-outline-secondary'); 3238 voiceBtn.innerHTML = '<i class="fas fa-microphone"></i>'; 3239 voiceStatus.style.display = 'none'; 3240 } 3241 } 3242 }); 3243 } 3244 3245 async generateAIResponse() { 3246 const prompt = $('#ai-prompt').val().trim(); 3247 const tone = $('#ai-tone').val(); 3248 3249 if (!prompt) { 3250 this.showError('Bitte geben Sie eine Beschreibung ein'); 3251 return; 3252 } 3253 3254 const ticket = this.currentTicket; 3255 const generateBtn = $('#generate-ai-response-btn'); 3256 const originalText = generateBtn.html(); 3257 3258 generateBtn.prop('disabled', true); 3259 generateBtn.html('<i class="fas fa-spinner fa-spin"></i> Generiere...'); 3260 3261 try { 3262 // Create detailed prompt for ChatGPT 3263 const fullPrompt = `Du bist ein professioneller Support-Mitarbeiter. Schreibe eine Antwort für folgendes Support-Ticket: 3264 3265Kunde: ${ticket.customerName} 3266Ticket-Betreff: ${ticket.subject} 3267Problem-Beschreibung: ${ticket.description} 3268Kategorie: ${this.getCategoryLabel(ticket.category)}
3269Priorität: ${this.getPriorityLabel(ticket.priority)} 3270 3271Anweisung für die Antwort: ${prompt} 3272Tonalität: ${tone === 'professional' ? 'Professionell und sachlich' : 3273 tone === 'friendly' ? 'Freundlich und hilfsbereit' : 3274 tone === 'technical' ? 'Technisch detailliert' : 3275 'Entschuldigend und verständnisvoll'} 3276 3277Wichtige Hinweise: 3278- Adressiere den Kunden mit "${ticket.customerName}" 3279- Beziehe dich auf das spezifische Problem 3280- Sei lösungsorientiert 3281- Die Antwort sollte strukturiert und klar sein 3282- Verwende HTML-Formatierung (<p> für Absätze, <strong> für Hervorhebungen) 3283 3284Schreibe NUR die Antwort, ohne zusätzliche Erklärungen.`; 3285 3286 // Call ChatGPT API 3287 const headers = await this.getAuthHeaders(); 3288 const response = await fetch('/api/openai/complete', { 3289 method: 'POST', 3290 headers: headers, 3291 body: JSON.stringify({ 3292 messages: [{ 3293 role: 'system', 3294 content: 'Du bist ein professioneller Support-Mitarbeiter. Antworte immer höflich, kompetent und lösungsorientiert.' 3295 }, { 3296 role: 'user', 3297 content: fullPrompt 3298 }], 3299 temperature: 0.7, 3300 max_tokens: 800 3301 }) 3302 }); 3303 3304 if (!response.ok) { 3305 throw new Error('API-Fehler'); 3306 } 3307 3308 const data = await response.json(); 3309 3310 if (data.choices && data.choices[0] && data.choices[0].message) { 3311 const aiResponse = data.choices[0].message.content; 3312 3313 // Show generated response 3314 $('#ai-response-content').html(aiResponse); 3315 $('#ai-response-result').show(); 3316 $('#use-ai-response-btn').show(); 3317 generateBtn.hide(); 3318 3319 // Store response for use 3320 this.generatedAIResponse = aiResponse; 3321 } else { 3322 throw new Error('Keine Antwort erhalten'); 3323 } 3324 3325 } catch (error) { 3326 console.error('Error generating AI response:', error); 3327 this.showError('Fehler beim Generieren der Antwort'); 3328 generateBtn.prop('disabled', false); 3329 generateBtn.html(originalText); 3330 } 3331 } 3332 3333 useAIResponse() { 3334 if (this.generatedAIResponse) { 3335 // Insert response into comment field 3336 $('#comment-content').val(this.generatedAIResponse); 3337 3338 // Close modal 3339 const modal = bootstrap.Modal.getInstance(document.getElementById('aiResponseModal')); 3340 modal.hide(); 3341 3342 // Focus on comment field 3343 $('#comment-content').focus(); 3344 3345 this.showSuccess('KI-Antwort wurde übernommen'); 3346 } 3347 } 3348 3349 setupFileUploadPreview() { 3350 const fileInput = document.getElementById('ticket-file-input'); 3351 const customBtn = document.getElementById('custom-file-btn'); 3352 const fileSelectionText = document.getElementById('file-selection-text'); 3353 const previewDiv = document.getElementById('attachment-preview'); 3354 3355 if (!fileInput || !previewDiv) return; 3356 3357 // Custom button click handler 3358 if (customBtn) { 3359 customBtn.addEventListener('click', function() { 3360 fileInput.click(); 3361 }); 3362 } 3363 3364 fileInput.addEventListener('change', function(e) { 3365 const files = Array.from(e.target.files); 3366 previewDiv.innerHTML = ''; 3367 3368 // Update file selection text 3369 if (fileSelectionText) { 3370 if (files.length === 0) { 3371 fileSelectionText.textContent = t('Keine Dateien ausgewählt'); 3372 } else if (files.length === 1) { 3373 fileSelectionText.textContent = files[0].name; 3374 } else { 3375 fileSelectionText.textContent = t('{count} Dateien ausgewählt').replace('{count}', files.length); 3376 } 3377 } 3378 3379 // Validate files 3380 if (files.length > 5) { 3381 alert(t('Maximal 5 Dateien erlaubt')); 3382 fileInput.value = ''; 3383 if (fileSelectionText) { 3384 fileSelectionText.textContent = t('Keine Dateien ausgewählt'); 3385 } 3386 return; 3387 } 3388 3389 let hasInvalidFile = false;
3390 files.forEach(file => { 3391 // Check file size (10MB max) 3392 if (file.size > 10 * 1024 * 1024) { 3393 alert(t('Datei zu groÃ').replace('{name}', file.name)); 3394 hasInvalidFile = true; 3395 return; 3396 } 3397 3398 // Show preview 3399 const fileItem = document.createElement('div'); 3400 fileItem.className = 'badge bg-light text-dark me-2 mb-2 p-2'; 3401 fileItem.innerHTML = ` 3402 <i class="fas fa-file me-1"></i> 3403 ${file.name} (${(file.size / 1024).toFixed(1)} KB) 3404 `; 3405 previewDiv.appendChild(fileItem); 3406 }); 3407 3408 if (hasInvalidFile) { 3409 fileInput.value = ''; 3410 previewDiv.innerHTML = ''; 3411 if (fileSelectionText) { 3412 fileSelectionText.textContent = t('Keine Dateien ausgewählt'); 3413 } 3414 } 3415 }); 3416 } 3417 3418 async updateTicketAttachments(ticketId, attachments) { 3419 try { 3420 const headers = await this.getAuthHeaders(); 3421 const response = await fetch(`${this.baseUrl}/api/tickets/${ticketId}`, { 3422 method: 'PUT', 3423 credentials: 'include', 3424 headers: headers, 3425 body: JSON.stringify({ attachments }) 3426 }); 3427 3428 if (!response.ok) { 3429 console.error('Failed to update ticket with attachments'); 3430 } 3431 } catch (error) { 3432 console.error('Error updating ticket attachments:', error); 3433 } 3434 } 3435 3436 async uploadAttachments(files, ticketId) { 3437 if (!files || files.length === 0) return []; 3438 3439 const attachments = []; 3440 3441 try { 3442 // Check if Firebase Storage is available 3443 if (!window.storage || !window.storageUtils) { 3444 console.warn('Firebase Storage not available, skipping file uploads'); 3445 return []; 3446 } 3447 3448 for (const file of files) { 3449 try { 3450 // Create storage reference 3451 const timestamp = Date.now(); 3452 const fileName = `${timestamp}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`; 3453 const storageRef = window.storageUtils.ref( 3454 window.storage, 3455 `tickets/${this.tenantId}/${ticketId}/${fileName}` 3456 ); 3457 3458 // Upload file 3459 await window.storageUtils.uploadBytes(storageRef, file); 3460 3461 // Get download URL 3462 const downloadURL = await window.storageUtils.getDownloadURL(storageRef); 3463 3464 attachments.push({ 3465 name: file.name, 3466 size: file.size, 3467 type: file.type, 3468 url: downloadURL, 3469 uploadedAt: new Date().toISOString() 3470 }); 3471 3472 console.log(`Uploaded attachment: ${file.name}`); 3473 } catch (error) { 3474 console.error(`Error uploading file ${file.name}:`, error); 3475 // Continue with other files even if one fails 3476 } 3477 } 3478 } catch (error) { 3479 console.error('Error in uploadAttachments:', error); 3480 } 3481 3482 return attachments; 3483 } 3484 3485 setupCustomerSearch() { 3486 const searchInput = document.getElementById('customer-search-input'); 3487 const resultsContainer = document.getElementById('customer-search-results'); 3488 const selectedDisplay = document.getElementById('selected-customer-display'); 3489 const hiddenInput = document.getElementById('selected-customer-id'); 3490 3491 if (!searchInput) return; 3492 3493 // Search input handler with debouncing 3494 searchInput.addEventListener('input', (e) => { 3495 const searchTerm = e.target.value.trim(); 3496 3497 // Clear previous timeout 3498 if (this.customerSearchDebounce) { 3499 clearTimeout(this.customerSearchDebounce); 3500 } 3501 3502 // Clear results if search is empty 3503 if (searchTerm.length < 2) { 3504 resultsContainer.innerHTML = ''; 3505 resultsContainer.style.display = 'none'; 3506 return; 3507 } 3508 3509 // Show loading 3510 resultsContainer.innerHTML = '<div class="search-loading">Suche...</div>'; 3511 resultsContainer.style.display = 'block'; 3512 3513 // Debounce search 3514 this.customerSearchDebounce = setTimeout(async () => { 3515 const results = await this.searchCustomers(searchTerm); 3516 this.displayCustomerSearchResults(results, resultsContainer, searchInput, selectedDisplay, hiddenInput); 3517 }, 300); 3518 }); 3519 3520 // Click outside to close results 3521 document.addEventListener('click', (e) => { 3522 if (!e.target.closest('.customer-search-container')) { 3523 resultsContainer.style.display = 'none'; 3524 } 3525 }); 3526 } 3527 3528 displayCustomerSearchResults(results, container, searchInput, selectedDisplay, hiddenInput) { 3529 if (results.length === 0) { 3530 container.innerHTML = '<div class="no-results">Keine Kunden gefunden</div>'; 3531 return; 3532 } 3533 3534 container.innerHTML = results.map(customer => ` 3535 <div class="search-result-item" 3536 data-customer-id="${customer.id}" 3537 data-customer-name="${customer.name || ''}" 3538 data-customer-company="${customer.company || ''}" 3539 data-customer-email="${customer.email || ''}" 3540 data-customer-phone="${customer.phone || customer.phone_number || ''}"> 3541 <div class="customer-name">${customer.name || 'Unbekannt'}</div> 3542 <div class="customer-meta">${customer.company || 'Keine Firma'}</div> 3543 </div> 3544 `).join(''); 3545 3546 // Add click handlers to results 3547 container.querySelectorAll('.search-result-item').forEach(item => { 3548 item.addEventListener('click', () => { 3549 const customerId = item.dataset.customerId; 3550 const customerName = item.dataset.customerName; 3551 const customerCompany = item.dataset.customerCompany; 3552 const customerEmail = item.dataset.customerEmail; 3553 const customerPhone = item.dataset.customerPhone; 3554 3555 // Set selected customer 3556 this.selectedCustomer = { 3557 id: customerId, 3558 name: customerName, 3559 company: customerCompany, 3560 email: customerEmail, 3561 phone: customerPhone 3562 }; 3563 3564 // Update display 3565 hiddenInput.value = customerId; 3566 searchInput.value = ''; 3567 selectedDisplay.innerHTML = ` 3568 <div class="selected-customer">
3569 <span> 3570 <strong>${customerName}</strong> 3571 ${customerCompany ? ` - ${customerCompany}` : ''} 3572 </span> 3573 <button type="button" class="btn btn-sm btn-link" onclick="ticketSystem.clearSelectedCustomer()"> 3574 <i class="fas fa-times"></i> 3575 </button> 3576 </div> 3577 `; 3578 selectedDisplay.style.display = 'block'; 3579 container.style.display = 'none'; 3580 }); 3581 }); 3582 } 3583 3584 clearSelectedCustomer() { 3585 this.selectedCustomer = null; 3586 document.getElementById('selected-customer-id').value = ''; 3587 document.getElementById('selected-customer-display').innerHTML = ''; 3588 document.getElementById('selected-customer-display').style.display = 'none'; 3589 } 3590 3591 showNewContactForm() { 3592 // Save current ticket form values before opening contact modal 3593 // (Bootstrap 5 stacked modals can interfere with the first modal's form state) 3594 const ticketForm = document.getElementById('create-ticket-form'); 3595 if (ticketForm) { 3596 this._savedTicketFormValues = { 3597 subject: ticketForm.querySelector('[name="subject"]')?.value || '', 3598 description: ticketForm.querySelector('[name="description"]')?.value || '', 3599 priority: ticketForm.querySelector('[name="priority"]')?.value || 'normal', 3600 category: ticketForm.querySelector('[name="category"]')?.value || 'support', 3601 assignedTo: ticketForm.querySelector('[name="assignedTo"]')?.value || '' 3602 }; 3603 } 3604 3605 // Create modal for new contact 3606 const modalHtml = ` 3607 <div class="modal fade" id="newContactModal" tabindex="-1"> 3608 <div class="modal-dialog modal-lg"> 3609 <div class="modal-content"> 3610 <div class="modal-header bg-primary text-white"> 3611 <h5 class="modal-title"><i class="fas fa-user-plus me-2"></i>Neuen Kontakt anlegen</h5> 3612 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> 3613 </div> 3614 <div class="modal-body"> 3615 <form id="new-contact-form"> 3616 <div class="row mb-3"> 3617 <div class="col-md-6"> 3618 <label class="form-label fw-bold">Name *</label> 3619 <input type="text" name="name" class="form-control" required> 3620 </div> 3621 <div class="col-md-6"> 3622 <label class="form-label fw-bold">Firma</label> 3623 <input type="text" name="company" class="form-control"> 3624 </div> 3625 </div> 3626 <div class="row mb-3"> 3627 <div class="col-md-6"> 3628 <label class="form-label fw-bold">E-Mail *</label> 3629 <input type="email" name="email" class="form-control" required> 3630 </div> 3631 <div class="col-md-6"> 3632 <label class="form-label fw-bold">Telefon</label> 3633 <input type="tel" name="phone" class="form-control"> 3634 </div> 3635 </div> 3636 <div class="row mb-3"> 3637 <div class="col-md-12"> 3638 <label class="form-label fw-bold">StraÃe</label> 3639 <input type="text" name="street" class="form-control"> 3640 </div> 3641 </div> 3642 <div class="row mb-3"> 3643 <div class="col-md-4"> 3644 <label class="form-label fw-bold">PLZ</label> 3645 <input type="text" name="zip" class="form-control"> 3646 </div> 3647 <div class="col-md-8"> 3648 <label class="form-label fw-bold">Stadt</label> 3649 <input type="text" name="city" class="form-control"> 3650 </div> 3651 </div> 3652 <div class="row mb-3"> 3653 <div class="col-md-6"> 3654 <label class="form-label fw-bold">Website</label> 3655 <input type="url" name="website" class="form-control"> 3656 </div> 3657 <div class="col-md-6"> 3658 <label class="form-label fw-bold">Branche</label> 3659 <select name="industry" class="form-select"> 3660 <option value="">-- Wählen --</option> 3661 <option value="Immobilien">Immobilien</option> 3662 <option value="IT">IT</option> 3663 <option value="Handel">Handel</option> 3664 <option value="Dienstleistung">Dienstleistung</option> 3665 <option value="Produktion">Produktion</option> 3666 <option value="Sonstiges">Sonstiges</option> 3667 </select> 3668 </div> 3669 </div> 3670 <div class="row mb-3"> 3671 <div class="col-md-6"> 3672 <label class="form-label fw-bold">Lead-Status</label> 3673 <select name="lead_status" class="form-select"> 3674 <option value="Neu" selected>Neu</option> 3675 <option value="Kontaktiert">Kontaktiert</option> 3676 <option value="Qualifiziert">Qualifiziert</option> 3677 <option value="Angebot">
3677Angebot</option> 3678 <option value="Kunde">Kunde</option> 3679 </select> 3680 </div> 3681 <div class="col-md-6"> 3682 <label class="form-label fw-bold">Aufgabe</label> 3683 <select name="task" class="form-select"> 3684 <option value="">-- Keine --</option> 3685 <option value="Anrufen">Anrufen</option> 3686 <option value="E-Mail senden">E-Mail senden</option> 3687 <option value="Meeting">Meeting</option> 3688 <option value="Follow-up">Follow-up</option> 3689 </select> 3690 </div> 3691 </div> 3692 </form> 3693 </div> 3694 <div class="modal-footer"> 3695 <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> 3696 <i class="fas fa-times me-2"></i>Abbrechen 3697 </button> 3698 <button type="button" class="btn btn-primary" onclick="ticketSystem.saveNewContact()"> 3699 <i class="fas fa-save me-2"></i>Kontakt speichern 3700 </button> 3701 </div> 3702 </div> 3703 </div> 3704 </div> 3705 `; 3706 3707 // Remove existing modal if present 3708 $('#newContactModal').remove(); 3709 $('body').append(modalHtml); 3710 3711 // Prevent accidental form submission on Enter (would reload page and lose ticket data) 3712 document.getElementById('new-contact-form').addEventListener('submit', (e) => { 3713 e.preventDefault(); 3714 this.saveNewContact(); 3715 }); 3716 3717 // Show modal 3718 const modal = new bootstrap.Modal(document.getElementById('newContactModal')); 3719 modal.show(); 3720 } 3721 3722 async saveNewContact() { 3723 const form = document.getElementById('new-contact-form'); 3724 if (!form.checkValidity()) { 3725 form.classList.add('was-validated'); 3726 return; 3727 } 3728 3729 try { 3730 // Get form data 3731 const formData = new FormData(form); 3732 const contactData = { 3733 name: formData.get('name'), 3734 email: formData.get('email'), 3735 company: formData.get('company') || '', 3736 phone_number: formData.get('phone') || '', 3737 street: formData.get('street') || '', 3738 zip: formData.get('zip') || '', 3739 city: formData.get('city') || '', 3740 website: formData.get('website') || '', 3741 industry: formData.get('industry') || '', 3742 lead_status: formData.get('lead_status') || 'Neu', 3743 task: formData.get('task') || '', 3744 customer_id: Math.floor(Math.random() * 10000000000).toString() 3745 }; 3746 3747 // Get current user and tenant ID 3748 let user = null; 3749 3750 // Try to get user from Auth module first 3751 if (typeof window.Auth !== 'undefined' && window.Auth.currentUser) { 3752 user = window.Auth.currentUser; 3753 } else if (typeof firebase !== 'undefined' && firebase.auth && firebase.auth().currentUser) { 3754 user = firebase.auth().currentUser; 3755 } 3756 3757 if (!user) { 3758 alert('Nicht angemeldet'); 3759 return; 3760 } 3761 3762 // phase8-tickets-migrated: tenant via /api/users/me, save via /api/contacts 3763 const { usersApi } = await import('./usersApi.js'); 3764 const me = await usersApi.me(); 3765 const tenantId = me.tenantID || me.tenant_id; 3766 if (!tenantId) { 3767 alert('Keine Mandanten-ID gefunden'); 3768 return; 3769 } 3770 contactData.uid = user.uid; 3771 contactData.tenantID = tenantId.toString(); 3772 const _idToken = await user.getIdToken(); 3773 const _resp = await fetch('/api/contacts', { 3774 method: 'POST', 3775 headers: { 3776 'Authorization': 'Bearer ' + _idToken, 3777 'Content-Type': 'application/json', 3778 }, 3779 body: JSON.stringify(contactData), 3780 }); 3781 if (!_resp.ok) { 3782 const _err = await _resp.json().catch(() => ({})); 3783 alert('Fehler beim Speichern: ' + (_err.error || _resp.status)); 3784 return; 3785 } 3786 const _saved = await _resp.json(); 3787 const _newId = _saved?.id || _saved?.contact_id || _saved?.contact?.id; 3788 const docRef = { id: _newId }; 3789 3790 // Set this as selected customer 3791 this.selectedCustomer = { 3792 id: docRef.id, 3793 name: contactData.name, 3794 company: contactData.company, 3795 email: contactData.email, 3796 phone: contactData.phone_number 3797 }; 3798 3799 // Update UI 3800 document.getElementById('selected-customer-id').value = docRef.id; 3801 document.getElementById('customer-search-input').value = contactData.name; 3802 3803 const selectedDisplay = document.getElementById('selected-customer-display'); 3804 selectedDisplay.innerHTML = ` 3805 <div class="selected-customer">
3806 <span class="customer-info"> 3807 <strong>${contactData.name}</strong> 3808 ${contactData.company ? `<br><small>${contactData.company}</small>` : ''} 3809 </span> 3810 <button type="button" class="btn btn-sm btn-link" onclick="ticketSystem.clearSelectedCustomer()"> 3811 <i class="fas fa-times"></i> 3812 </button> 3813 </div> 3814 `; 3815 selectedDisplay.style.display = 'block'; 3816 3817 // Hide search results 3818 document.getElementById('customer-search-results').style.display = 'none'; 3819 3820 // Clear contacts cache to force reload 3821 try { 3822 // Clear all contact caches 3823 Object.keys(localStorage).filter(k => k.includes('contacts_')).forEach(k => localStorage.removeItem(k)); 3824 // Also clear last cache key 3825 const lastCacheKey = localStorage.getItem('last_contacts_cache_key'); 3826 if (lastCacheKey) { 3827 localStorage.removeItem(lastCacheKey); 3828 } 3829 } catch (e) { 3830 console.log('Could not clear cache:', e); 3831 } 3832 3833 // Reload customers list 3834 await this.loadCustomers(); 3835 3836 // Close modal 3837 const modal = bootstrap.Modal.getInstance(document.getElementById('newContactModal')); 3838 modal.hide(); 3839 3840 // Remove modal from DOM and restore ticket form values 3841 setTimeout(() => { 3842 $('#newContactModal').remove(); 3843 3844 // Restore saved ticket form values (Bootstrap stacked modals can reset them) 3845 if (this._savedTicketFormValues) { 3846 const ticketForm = document.getElementById('create-ticket-form'); 3847 if (ticketForm) { 3848 const subjectField = ticketForm.querySelector('[name="subject"]'); 3849 const descField = ticketForm.querySelector('[name="description"]'); 3850 const priorityField = ticketForm.querySelector('[name="priority"]'); 3851 const categoryField = ticketForm.querySelector('[name="category"]'); 3852 const assignedField = ticketForm.querySelector('[name="assignedTo"]'); 3853 if (subjectField) subjectField.value = this._savedTicketFormValues.subject; 3854 if (descField) descField.value = this._savedTicketFormValues.description; 3855 if (priorityField) priorityField.value = this._savedTicketFormValues.priority; 3856 if (categoryField) categoryField.value = this._savedTicketFormValues.category; 3857 if (assignedField) assignedField.value = this._savedTicketFormValues.assignedTo; 3858 } 3859 this._savedTicketFormValues = null; 3860 } 3861 }, 500); 3862 3863 // Show success message 3864 this.showNotification('Kontakt wurde erfolgreich angelegt'); 3865 3866 } catch (error) { 3867 console.error('Error creating contact:', error); 3868 alert('Fehler beim Anlegen des Kontakts: ' + error.message); 3869 } 3870 } 3871 3872 async createTicketFromCall(callData) { 3873 // Extract relevant data from call 3874 const { 3875 customerName, 3876 customerPhone, 3877 customerEmail, 3878 summary, 3879 transcript, 3880 callId, 3881 agentName, 3882 timestamp, 3883 audioUrl, 3884 hasAudio 3885 } = callData; 3886 3887 // Load agents if not already loaded 3888 if (this.agents.length === 0) { 3889 await this.loadAgents(); 3890 } 3891 3892 // Show modal to confirm/edit ticket details 3893 const modalHtml = ` 3894 <div class="modal fade" id="createTicketFromCallModal" tabindex="-1"> 3895 <div class="modal-dialog modal-lg"> 3896 <div class="modal-content"> 3897 <div class="modal-header"> 3898 <h5 class="modal-title">${t('Ticket aus Anruf erstellen')}</h5> 3899 <button type="button" class="btn-close" data-bs-dismiss="modal"></button> 3900 </div> 3901 <div class="modal-body"> 3902 <form id="ticket-from-call-form"> 3903 <div class="mb-3"> 3904 <label class="form-label">${t('Kunde')}</label> 3905 <input type="text" class="form-control" name="customerName" value="${customerName || ''}" required> 3906 </div> 3907 <div class="row"> 3908 <div class="col-md-6 mb-3"> 3909 <label class="form-label">${t('E-Mail')}</label> 3910 <input type="email" class="form-control" name="customerEmail" value="${customerEmail || ''}" required> 3911 </div> 3912 <div class="col-md-6 mb-3"> 3913 <label class="form-label">${t('Telefon')}</label> 3914 <input type="tel" class="form-control" name="customerPhone" value="${customerPhone || ''}"> 3915 </div> 3916 </div> 3917 <div class="mb-3"> 3918 <label class="form-label">${t('Betreff')}</label> 3919 <input type="text" class="form-control" name="subject" value="${t('Anruf vom')}
3919 ${timestamp ? new Date(timestamp).toLocaleString(getLocale()) : t('unbekannt')}" required> 3920 </div> 3921 <div class="mb-3"> 3922 <label class="form-label">${t('Beschreibung')}</label> 3923 <textarea class="form-control" name="description" rows="4" required>${summary || t('Keine Zusammenfassung verfügbar')}${audioUrl ? '\n\n' + t('Audio-Aufnahme') + ': ' + audioUrl : ''}</textarea> 3924 </div> 3925 ${audioUrl ? ` 3926 <div class="alert alert-info"> 3927 <i class="fas fa-microphone me-2"></i> 3928 ${t('Audio-Aufnahme wird mit dem Ticket verknüpft')} 3929 </div> 3930 ` : ''} 3931 <div class="row"> 3932 <div class="col-md-6 mb-3"> 3933 <label class="form-label">${t('Priorität')}</label> 3934 <select class="form-select" name="priority"> 3935 <option value="low">${t('Niedrig')}</option> 3936 <option value="normal" selected>${t('Normal')}</option> 3937 <option value="high">${t('Hoch')}</option> 3938 <option value="critical">${t('Kritisch')}</option> 3939 </select> 3940 </div> 3941 <div class="col-md-6 mb-3"> 3942 <label class="form-label">${t('Kategorie')}</label> 3943 <select class="form-select" name="category"> 3944 <option value="support" selected>${t('Support')}</option> 3945 <option value="sales">${t('Vertrieb')}</option> 3946 <option value="technical">${t('Technisch')}</option> 3947 <option value="billing">${t('Rechnung')}</option> 3948 <option value="other">${t('Sonstiges')}</option> 3949 </select> 3950 </div> 3951 </div> 3952 <div class="mb-3"> 3953 <label class="form-label">${t('Mitarbeiter zuweisen')}</label> 3954 <select class="form-select" name="assignedTo"> 3955 <option value="">${t('Nicht zugewiesen')}</option> 3956 ${this.agents.map(agent => 3957 `<option value="${agent.id}" ${agent.name === agentName ? 'selected' : ''}>${agent.name}</option>` 3958 ).join('')} 3959 </select> 3960 </div> 3961 <input type="hidden" name="callId" value="${callId || ''}"> 3962 <input type="hidden" name="audioUrl" value="${audioUrl || ''}"> 3963 ${transcript ? `<input type="hidden" name="transcript" value="${transcript.replace(/"/g, '"')}">` : ''} 3964 </form> 3965 </div> 3966 <div class="modal-footer"> 3967 <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="Abbrechen">${t('Abbrechen')}</button> 3968 <button type="button" class="btn btn-primary" onclick="ticketSystem.submitTicketFromCall()" data-i18n="Ticket erstellen">${t('Ticket erstellen')}</button> 3969 </div> 3970 </div> 3971 </div> 3972 </div> 3973 `; 3974 3975 // Remove existing modal if present 3976 const existingModal = document.getElementById('createTicketFromCallModal'); 3977 if (existingModal) { 3978 existingModal.remove(); 3979 } 3980 3981 // Add modal to DOM 3982 document.body.insertAdjacentHTML('beforeend', modalHtml); 3983 3984 // Show modal 3985 const modal = new bootstrap.Modal(document.getElementById('createTicketFromCallModal')); 3986 modal.show(); 3987 } 3988 3989 async submitTicketFromCall() { 3990 const form = document.getElementById('ticket-from-call-form'); 3991 const formData = new FormData(form); 3992 3993 // Find customer or use provided data 3994 let customerId = ''; 3995 const customerEmail = formData.get('customerEmail'); 3996 3997 // Search for existing customer 3998 if (customerEmail) { 3999 const customers = await this.searchCustomers(customerEmail); 4000 if (customers && customers.length > 0) { 4001 customerId = customers[0].id; 4002 } 4003 } 4004 4005 // Get assigned agent name 4006 const assignedToId = formData.get('assignedTo'); 4007 const assignedToName = assignedToId ? 4008 this.agents.find(a => a.id === assignedToId)?.name || '' : ''; 4009 4010 const ticketData = { 4011 customerId: customerId || 'call-customer-' + Date.now(), 4012 customerName: formData.get('customerName'), 4013 customerEmail: formData.get('customerEmail'), 4014 customerPhone: formData.get('customerPhone'), 4015 subject: formData.get('subject'), 4016 description: formData.get('description'), 4017 priority: formData.get('priority'), 4018 category: formData.get('category'), 4019 assignedTo: assignedToId, 4020 assignedToName: assignedToName, 4021 callId: formData.get('callId'), 4022 audioUrl: formData.get('audioUrl'), 4023 conversationId: formData.get('transcript') ? 'call-' + Date.now() : '', 4024 status: 'new' 4025 }; 4026 4027 this.showLoading(); 4028 try { 4029 const headers = await this.getAuthHeaders(); 4030 const response = await fetch(`${this.baseUrl}/api/tickets`, { 4031 method: 'POST', 4032 credentials: 'include', 4033 headers: headers, 4034 body: JSON.stringify(ticketData) 4035 }); 4036 const data = await response.json(); 4037 if (data.success) { 4038 this.showSuccess(data.message); 4039 4040 // Close modal 4041 const modal = bootstrap.Modal.getInstance(document.getElementById('createTicketFromCallModal')); 4042 if (modal) modal.hide(); 4043 4044 // Reload tickets if on ticket page 4045 if (window.location.hash === '#tickets') { 4046 this.loadTickets(); 4047 } 4048 4049 return data.ticket; 4050 } else { 4051 this.showError(data.error || 'Fehler beim Erstellen des Tickets'); 4052 return null; 4053 } 4054 } catch (error) { 4055 console.error('Error creating ticket from call:', error); 4056 this.showError('Ticket konnte nicht erstellt werden'); 4057 return null; 4058 } finally { 4059 this.hideLoading(); 4060 } 4061 } 4062 4063 // Widget Configuration Functions 4064 async ladeWidgetSchluessel() { 4065 // tick-ui-v2: Hier stand bis 13.08.2026 ein FREMDER Schlüssel fest im 4066 // Quelltext. Jeder Kunde ohne eigenen Eintrag bekam ihn angezeigt - seine 4067 // Website-Anfragen wären im Postfach eines anderen Mandanten gelandet. 4068 try { 4069 const headers = await this.getAuthHeaders(); 4070 const r = await fetch(`${this.baseUrl}/api/tickets/widget-key`, { headers, credentials: 'include' }); 4071 if (r.ok) { 4072 const d = await r.json(); 4073 if (d && d.apiKey) { 4074 localStorage.setItem(`widget_api_key_${this.tenantId}`, d.apiKey); 4075 return d.apiKey; 4076 } 4077 } 4078 } catch (e) { 4079 console.warn('Widget-Schlüssel nicht abrufbar:', e); 4080 } 4081 return localStorage.getItem(`widget_api_key_${this.tenantId}`) || ''; 4082 } 4083 4084 async showWidgetConfig() { 4085 const apiKey = await this.ladeWidgetSchluessel(); 4086 if (!apiKey) { 4087 this.showError('Der Widget-Schlüssel konnte nicht geladen werden. Bitte später erneut versuchen.'); 4088 return; 4089 } 4090 const apiUrl = window.location.origin; 4091 4092 // Create modal HTML 4093 const modalHtml = ` 4094 <div class="modal fade" id="widgetConfigModal" tabindex="-1" role="dialog"> 4095 <div class="modal-dialog modal-lg" role="document"> 4096 <div class="modal-content"> 4097 <div class="modal-header bg-primary text-white"> 4098 <h5 class="modal-title"> 4099 <i class="fas fa-code"></i> ${t('Widget Integration')} 4100 </h5> 4101 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button> 4102 </div> 4103 <div class="modal-body"> 4104 <!-- Configuration Section --> 4105 <div class="card mb-4"> 4106 <div class="card-header bg-light"> 4107 <h6 class="mb-0">âï¸ ${t('Widget Konfiguration')}</h6> 4108 </div> 4109 <div class="card-body"> 4110 <div class="row"> 4111 <div class="col-md-6"> 4112 <div class="form-group"> 4113 <label>${t('API Key')}
4113:</label> 4114 <div class="input-group"> 4115 <input type="text" class="form-control" id="widgetApiKey" value="${apiKey}" readonly> 4116 <div class="input-group-append"> 4117 <button class="btn btn-outline-secondary" onclick="window.ticketSystem.copyToClipboard('widgetApiKey')"> 4118 <i class="fas fa-copy"></i> 4119 </button> 4120 </div> 4121 </div> 4122 </div> 4123 </div> 4124 <div class="col-md-6"> 4125 <div class="form-group"> 4126 <label>${t('Primärfarbe')}:</label> 4127 <div class="input-group"> 4128 <input type="color" class="form-control" id="widgetPrimaryColor" value="#667eea" style="height: 38px;"> 4129 <input type="text" class="form-control" id="widgetPrimaryColorText" value="#667eea"> 4130 </div> 4131 </div> 4132 </div> 4133 </div> 4134 <div class="row"> 4135 <div class="col-md-6"> 4136 <div class="form-group"> 4137 <label>${t('Sekundärfarbe')}:</label> 4138 <div class="input-group"> 4139 <input type="color" class="form-control" id="widgetSecondaryColor" value="#764ba2" style="height: 38px;"> 4140 <input type="text" class="form-control" id="widgetSecondaryColorText" value="#764ba2"> 4141 </div> 4142 </div> 4143 </div> 4144 <div class="col-md-6"> 4145 <div class="form-group"> 4146 <label>${t('Button-Text')}:</label> 4147 <input type="text" class="form-control" id="widgetButtonText" value="ð«" placeholder="${t('z.B. Support, Hilfe')}"> 4148 </div> 4149 </div> 4150 </div> 4151 <button class="btn btn-primary btn-sm" onclick="window.ticketSystem.updateWidgetCode()"> 4152 <i class="fas fa-sync"></i> ${t('Code aktualisieren')} 4153 </button> 4154 </div> 4155 </div> 4156 4157 <!-- Integration Methods --> 4158 <ul class="nav nav-tabs" role="tablist"> 4159 <li class="nav-item"> 4160 <a class="nav-link active widget-config-tab" data-tab="inlineTab" href="javascript:void(0)"> 4161 1ï¸â£ ${t('Inline Iframe')} 4162 </a> 4163 </li> 4164 <li class="nav-item"> 4165 <a class="nav-link widget-config-tab" data-tab="modalTab" href="javascript:void(0)"> 4166 2ï¸â£ ${t('Modal Popup')} 4167 </a> 4168 </li> 4169 <li class="nav-item"> 4170 <a class="nav-link widget-config-tab" data-tab="floatingTab" href="javascript:void(0)"> 4171 3ï¸â£ ${t('Floating Button')} 4172 </a> 4173 </li> 4174 </ul> 4175 4176 <div class="tab-content mt-3"> 4177 <!-- Inline Tab --> 4178 <div class="tab-pane fade show active" id="inlineTab"> 4179 <h6>${t('Direkt in die Seite eingebettet')}:</h6> 4180 <div class="bg-light text-dark p-3 rounded border"> 4181 <code id="inlineCode" style="white-space: pre-wrap; color: #000;"><iframe 4182 src="${apiUrl}/static/ticket-widget.html?apiKey=${apiKey}&primaryColor=%23667eea&secondaryColor=%23764ba2" 4183 width="100%" 4184 height="700" 4185 frameborder="0" 4186 style="border-radius: 10px;"> 4187</iframe></code> 4188 </div> 4189 <button class="btn btn-sm btn-outline-primary mt-2" onclick="window.ticketSystem.copyCode('inlineCode')"> 4190 <i class="fas fa-copy"></i> ${t('Code kopieren')} 4191 </button> 4192 </div> 4193 4194 <!-- Modal Tab --> 4195 <div class="tab-pane fade" id="modalTab"> 4196 <h6>${t('Als elegantes Overlay-Fenster')}:</h6> 4197 <div class="bg-light text-dark p-3 rounded border"> 4198 <code id="modalCode" style="white-space: pre-wrap; color: #000;">function openTicketModal() { 4199 const modal = document.createElement('div'); 4200 modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;'; 4201 4202 const iframe = document.createElement('iframe'); 4203 iframe.src = '${apiUrl}/static/ticket-widget.html?apiKey=${apiKey}&primaryColor=%23667eea&secondaryColor=%23764ba2'; 4204 iframe.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:600px;height:700px;border:none;border-radius:12px;background:white;'; 4205 4206 modal.appendChild(iframe); 4207 document.body.appendChild(modal); 4208 modal.onclick = () => modal.remove(); 4209}</code> 4210 </div> 4211 <button class="btn btn-sm btn-outline-primary mt-2" onclick="window.ticketSystem.copyCode('modalCode')"> 4212 <i class="fas fa-copy"></i> ${t('Code kopieren')} 4213 </button> 4214 </div> 4215 4216 <!-- Floating Tab --> 4217 <div class="tab-pane fade" id="floatingTab"> 4218 <h6>${t('Schwebender Support-Button')}:</h6> 4219 <div class="bg-light text-dark p-3 rounded border"> 4220 <code id="floatingCode" style="white-space: pre-wrap; color: #000;"><!-- Floating Button --> 4221<div id="supportButton" onclick="openTicketModal()" 4222 style="position:fixed;bottom:20px;right:20px;width:60px;height:60px;border-radius:50%; 4223 background:linear-gradient(135deg,#667eea,#764ba2);color:white; 4224 display:flex;align-items:center;justify-content:center;cursor:pointer; 4225 box-shadow:0 4px 12px rgba(102,126,234,0.4);z-index:1000;"> 4226 ð« 4227</div> 4228 4229<script> 4230function openTicketModal() { 4231 const modal = document.createElement('div'); 4232 modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;'; 4233 4234 const iframe = document.createElement('iframe'); 4235 iframe.src = '${apiUrl}/static/ticket-widget.html?apiKey=${apiKey}&primaryColor=%23667eea&secondaryColor=%23764ba2'; 4236 iframe.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:600px;height:700px;border:none;border-radius:12px;background:white;'; 4237 4238 modal.appendChild(iframe); 4239 document.body.appendChild(modal); 4240 modal.onclick = () => modal.remove(); 4241} 4242</script></code> 4243 </div> 4244 <button class="btn btn-sm btn-outline-primary mt-2" onclick="window.ticketSystem.copyCode('floatingCode')"> 4245 <i class="fas fa-copy"></i> ${t('Code kopieren')} 4246 </button> 4247 </div> 4248 </div> 4249 4250 <!-- Preview Section --> 4251 <div class="card mt-4"> 4252 <div class="card-header bg-light"> 4253 <h6 class="mb-0">ðï¸ ${t('Live-Vorschau')}</h6> 4254 </div> 4255 <div class="card-body"> 4256 <div id="widgetPreview" style="position:relative;height:60px;"> 4257 <div id="previewButton" style="position:absolute;right:20px;top:0;width:60px;height:60px;border-radius:50%; 4258 background:linear-gradient(135deg,#667eea,#764ba2);color:white; 4259 display:flex;align-items:center;justify-content:center;cursor:pointer; 4260 box-shadow:0 4px 12px rgba(102,126,234,0.4);font-size:24px;"> 4261 ð« 4262 </div> 4263 </div> 4264 </div> 4265 </div> 4266 </div> 4267 <div class="modal-footer"> 4268 <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${t('SchlieÃen')}</button> 4269 </div> 4270 </div> 4271 </div> 4272 </div> 4273 `; 4274 4275 // Remove existing modal if any 4276 $('#widgetConfigModal').remove(); 4277 4278 // Add modal to body 4279 $('body').append(modalHtml); 4280 4281 // Setup event handlers 4282 $('#widgetPrimaryColor').on('input', function() { 4283 $('#widgetPrimaryColorText').val($(this).val()); 4284 window.ticketSystem.updateWidgetPreview(); 4285 }); 4286 4287 $('#widgetPrimaryColorText').on('input', function() { 4288 $('#widgetPrimaryColor').val($(this).val()); 4289 window.ticketSystem.updateWidgetPreview(); 4290 }); 4291 4292 $('#widgetSecondaryColor').on('input', function() { 4293 $('#widgetSecondaryColorText').val($(this).val()); 4294 window.ticketSystem.updateWidgetPreview(); 4295 }); 4296 4297 $('#widgetSecondaryColorText').on('input', function() { 4298 $('#widgetSecondaryColor').val($(this).val()); 4299 window.ticketSystem.updateWidgetPreview(); 4300 }); 4301 4302 $('#widgetButtonText').on('input', function() { 4303 window.ticketSystem.updateWidgetPreview(); 4304 }); 4305 4306 // Setup tab switching (prevent default navigation) 4307 $('.widget-config-tab').on('click', function(e) { 4308 e.preventDefault(); 4309 e.stopPropagation(); 4310 4311 // Remove active class from all tabs and panes 4312 $('.widget-config-tab').removeClass('active'); 4313 $('.tab-pane').removeClass('show active'); 4314 4315 // Add active to clicked tab 4316 $(this).addClass('active'); 4317 4318 // Show corresponding pane 4319 const tabId = $(this).data('tab'); 4320 $('#' + tabId).addClass('show active'); 4321 4322 return false;
4323 }); 4324 4325 // Show modal using Bootstrap 5 4326 const modal = new bootstrap.Modal(document.getElementById('widgetConfigModal')); 4327 modal.show(); 4328 } 4329 4330 updateWidgetPreview() { 4331 const primaryColor = $('#widgetPrimaryColor').val(); 4332 const secondaryColor = $('#widgetSecondaryColor').val(); 4333 const buttonText = $('#widgetButtonText').val() || 'ð«'; 4334 4335 $('#previewButton').css({ 4336 'background': `linear-gradient(135deg, ${primaryColor}, ${secondaryColor})` 4337 }).text(buttonText); 4338 } 4339 4340 updateWidgetCode() { 4341 const apiKey = $('#widgetApiKey').val(); 4342 const primaryColor = $('#widgetPrimaryColor').val(); 4343 const secondaryColor = $('#widgetSecondaryColor').val(); 4344 const buttonText = $('#widgetButtonText').val() || 'ð«'; 4345 const apiUrl = window.location.origin; 4346 4347 // URL encode colors 4348 const primaryEncoded = encodeURIComponent(primaryColor); 4349 const secondaryEncoded = encodeURIComponent(secondaryColor); 4350 4351 // Update inline code 4352 $('#inlineCode').text(`<iframe 4353 src="${apiUrl}/static/ticket-widget.html?apiKey=${apiKey}&primaryColor=${primaryEncoded}&secondaryColor=${secondaryEncoded}" 4354 width="100%" 4355 height="700" 4356 frameborder="0" 4357 style="border-radius: 10px;"> 4358</iframe>`); 4359 4360 // Update modal code 4361 $('#modalCode').text(`function openTicketModal() { 4362 const modal = document.createElement('div'); 4363 modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;'; 4364 4365 const iframe = document.createElement('iframe'); 4366 iframe.src = '${apiUrl}/static/ticket-widget.html?apiKey=${apiKey}&primaryColor=${primaryEncoded}&secondaryColor=${secondaryEncoded}'; 4367 iframe.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:600px;height:700px;border:none;border-radius:12px;background:white;'; 4368 4369 modal.appendChild(iframe); 4370 document.body.appendChild(modal); 4371 modal.onclick = () => modal.remove(); 4372}`); 4373 4374 // Update floating code 4375 $('#floatingCode').text(`<!-- Floating Button --> 4376<div id="supportButton" onclick="openTicketModal()" 4377 style="position:fixed;bottom:20px;right:20px;width:60px;height:60px;border-radius:50%; 4378 background:linear-gradient(135deg,${primaryColor},${secondaryColor});color:white; 4379 display:flex;align-items:center;justify-content:center;cursor:pointer; 4380 box-shadow:0 4px 12px rgba(102,126,234,0.4);z-index:1000;"> 4381 ${buttonText} 4382</div> 4383 4384<script> 4385function openTicketModal() { 4386 const modal = document.createElement('div'); 4387 modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;'; 4388 4389 const iframe = document.createElement('iframe'); 4390 iframe.src = '${apiUrl}/static/ticket-widget.html?apiKey=${apiKey}&primaryColor=${primaryEncoded}&secondaryColor=${secondaryEncoded}'; 4391 iframe.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:600px;height:700px;border:none;border-radius:12px;background:white;'; 4392 4393 modal.appendChild(iframe); 4394 document.body.appendChild(modal); 4395 modal.onclick = () => modal.remove(); 4396} 4397</script>`); 4398 4399 this.showSuccess('Code wurde aktualisiert!'); 4400 } 4401 4402 copyCode(elementId) { 4403 const codeElement = document.getElementById(elementId); 4404 if (!codeElement) { 4405 console.error('Code element not found:', elementId); 4406 return; 4407 } 4408 4409 const code = codeElement.textContent; 4410 4411 // Check if clipboard API is available 4412 if (navigator.clipboard && navigator.clipboard.writeText) { 4413 navigator.clipboard.writeText(code).then(() => { 4414 this.showSuccess('Code wurde in die Zwischenablage kopiert!'); 4415 }).catch((err) => { 4416 console.error('Clipboard write failed:', err); 4417 this.fallbackCopy(code); 4418 }); 4419 } else { 4420 // Use fallback for older browsers or insecure contexts 4421 this.fallbackCopy(code); 4422 } 4423 } 4424 4425 fallbackCopy(text) { 4426 const textarea = document.createElement('textarea'); 4427 textarea.value = text; 4428 textarea.style.position = 'fixed'; 4429 textarea.style.opacity = '0'; 4430 document.body.appendChild(textarea); 4431 textarea.select(); 4432 4433 try { 4434 document.execCommand('copy'); 4435 this.showSuccess('Code wurde kopiert!'); 4436 } catch (err) { 4437 console.error('Fallback copy failed:', err); 4438 this.showError('Kopieren fehlgeschlagen'); 4439 } finally { 4440 document.body.removeChild(textarea); 4441 } 4442 } 4443 4444 copyToClipboard(elementId) { 4445 const element = document.getElementById(elementId); 4446 element.select(); 4447 document.execCommand('copy'); 4448 this.showSuccess('In Zwischenablage kopiert!'); 4449 } 4450 4451} 4452 4453// Initialize ticket system when DOM is ready 4454// ticket-single-instance-v1: Dieses Modul wird erst nachgeladen, NACHDEM die 4455// Ticket-Seite "#ticket-content" in die Seite gesetzt wurde. $(document).ready 4456// feuert deshalb sofort - und legte hier eine ZWEITE, eigene Instanz an, parallel 4457// zu der aus pages.js. Beide schrieben in denselben Container: die eine oeffnete 4458// den Vorgang, die andere (Ansicht steht auf Dashboard, ihr Render-Schutz greift 4459// also nicht) malte Sekundenbruchteile spaeter die Uebersicht darueber. Genau das 4460// war der Sprung aus der digitalen Rezeption zurueck auf die Uebersicht. 4461// Deshalb: es gibt genau EINE Instanz, und alle teilen sie sich. 4462let ticketSystem; 4463$(document).ready(() => { 4464 if (window.ticketSystem) { ticketSystem = window.ticketSystem; return; } 4465 if ($('#ticket-content').length > 0) { 4466 ticketSystem = window.ticketSystem = new TicketSystem(); 4467 } 4468}); 4469 4470// Export for global access 4471window.TicketSystem = TicketSystem; 4472 4473// Add debug function for testing 4474window.debugTicketNav = function() { 4475 console.log('Debugging ticket navigation...'); 4476 console.log('ticketSystem exists?', !!window.ticketSystem);
4477 console.log('Analytics links found:', $('.ticket-nav-item[data-view="analytics"]').length); 4478 $('.ticket-nav-item[data-view="analytics"]').each(function() { 4479 console.log('Analytics element:', this, 'data-view:', $(this).data('view')); 4480 }); 4481 4482 // Try to manually trigger analytics 4483 if (window.ticketSystem) { 4484 console.log('Manually calling loadAnalytics...'); 4485 window.ticketSystem.switchView('analytics'); 4486 } 4487}; 4488 4489// calendar-sync-v1: Aendert jemand einen Termin im Kalender, zeigt die geoeffnete 4490// Vorgangsansicht sofort den neuen Stand. Bewusst EIN Empfaenger auf Modulebene, 4491// der die jeweils aktuelle Instanz anspricht - die Seite legt beim Wechsel eine 4492// neue an, mehrere Empfaenger wuerden einander ins Gehege kommen. 4493onCalendarChanged(() => { 4494 try { 4495 const inst = window.ticketSystem; 4496 const box = document.getElementById('ticket-next-appointment'); 4497 if (!inst || !box || !inst.currentTicket) return; 4498 if (isAppointmentFormOpen(box)) return; // laufende Eingabe nicht zerstoeren 4499 inst.loadTicketAppointment(inst.currentTicket); 4500 } catch (e) { 4501 console.warn('Ticketsystem: Termine nicht neu ladbar', e && e.message || e); 4502 } 4503}); 4504 4505// Export as ES6 module 4506export { TicketSystem };
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.