PageSourceSearch

https://app.medi-one.ai/modules/ticketSystem.js?v=20260728-singlei…ick2-fwtick1-tickprio1-tickui1-kf3-tsuche1b

js medi-one.ai collected 2026-10-05 14:15:08 UTC 215,526 bytes, 4,506 lines download raw bytes

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 '&gt;' 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            '&': '&amp;',
2980            '<': '&lt;',
2981            '>': '&gt;',
2982            '"': '&quot;',
2983            "'": '&#039;'
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, '&quot;')}">` : ''}
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;">&lt;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;"&gt;
4187&lt;/iframe&gt;</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;">&lt;!-- Floating Button --&gt;
4221&lt;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;"&gt;
4226    🎫
4227&lt;/div&gt;
4228
4229&lt;script&gt;
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&lt;/script&gt;</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.