PageSourceSearch

https://albatros-fluggruppe.web.app/js/modules/flight-data/flight-data-module.js?v=1.0

js albatros-fluggruppe.web.app collected 2026-10-03 12:16:22 UTC 45,849 bytes, 1,251 lines download raw bytes

1/**
2 * FLIGHT DATA MODULE (v2) - Adapted to FlightFormImproved structure
3 * - Storage key: "flightData"
4 * - Simplified validation (per user request)
5 * - Keeps techLogs, pilots, aircraft arrays
6 * - Exposes global helpers for UI and form integration
7 *
8 * Usage:
9 *   const m = new FlightDataModule();
10 *   await m.init();
11 *
12 * Exports on init:
13 *   window.flightDataModule
14 *   window.recordFlight
15 *   window.getFlights
16 *   window.getTechLogs
17 *   window.calculateFlightTotals
18 */
19
20class FlightDataModule {
21  constructor() {
22    this.storageKey = 'flightData';
23    this.initialized = false;
24
25    // Data structure aligned with flight-form-improved
26    this.data = {
27      flights: [],   // array of flight objects (see form structure)
28      techLogs: [],  // technical logs
29      pilots: [],    // loaded from members module if present
30      aircraft: []   // aircraft list (id, registration, model, base, active...)
31    };
32
33    // basic caches if needed
34    this.cache = new Map();
35
36  }
37
38  // -------------------------
39  // SYSTÈME DE PERMISSIONS
40  // -------------------------
41  canEditFlight(flight) {
42    // Récupérer l'utilisateur actuel depuis localStorage
43    const currentUserStr = localStorage.getItem('currentUser');
44    if (!currentUserStr) return false;
45    
46    const currentUser = JSON.parse(currentUserStr);
47    const userRole = currentUser.role;
48    const userId = currentUser.id;
49    
50    // Rôles avec accès complet
51    const adminRoles = ['praesident', 'vorstand', 'kassier'];
52    if (adminRoles.includes(userRole)) {
53      return true;
54    }
55    
56    // Membre standard : seulement ses propres vols
57    // Comparer le pilote du vol avec l'utilisateur actuel
58    const flightPilotId = this.normalizePilotId(flight.crew);
59    return String(flightPilotId) === String(userId);
60  }
61
62  canDeleteFlight(flight) {
63    // Même logique que canEditFlight
64    const currentUserStr = localStorage.getItem('currentUser');
65    if (!currentUserStr) return false;
66    
67    const currentUser = JSON.parse(currentUserStr);
68    const userRole = currentUser.role;
69    const userId = currentUser.id;
70    
71    const adminRoles = ['praesident', 'vorstand', 'kassier'];
72    if (adminRoles.includes(userRole)) {
73      return true;
74    }
75    
76    const flightPilotId = this.normalizePilotId(flight.crew);
77    return String(flightPilotId) === String(userId);
78  }
79
80  // -------------------------
81  // INIT / LOAD / SAVE
82  // -------------------------
83  async init() {
84    if (this.initialized) return this;
85    await this.loadData();
86    await this.migrateFlightsData(); // migrate existing flights to new schema
87    await this.loadAircraftInfo(); // keep seeded aircraft if empty
88    await this.loadPilotsInfo();   // attempt to load pilots from members module
89    this.setupFleetListeners();    // listen for fleet changes
90    this.exportGlobalFunctions();
91    this.initialized = true;
92    // Dispatch event pour notifier les autres modules
93    document.dispatchEvent(new CustomEvent('flightDataModuleReady', {
94      detail: { module: this }
95    }));
96
97    return this;
98  }
99
100  async loadData() {
101    try {
102      const db = window.firestoreDb;
103      if (db) {
104        // Borner l'attente — sur mobile (réseau instable, app en arrière-plan),
105        // .get() peut hanger silencieusement et nous laisser avec un état partiel.
106        const fetchPromise = Promise.all([
107          db.collection('flights').get(),
108          db.collection('techLogs').get()
109        ]);
110        const [flightsSnap, techLogsSnap] = window.withTimeout
111          ? await window.withTimeout(fetchPromise, 12000, 'FlightData loadData')
112          : await fetchPromise;
113
114        // Succès : on remplace les données. Empty est un état valide (vraie liste vide).
115        this.data.flights  = flightsSnap.docs.map(d => ({ ...d.data(), id: d.id }));
116        this.data.techLogs = techLogsSnap.docs.map(d => ({ ...d.data(), id: d.id }));
117        if (this.loadError && window.clearFirebaseErrorBanner) {
118            window.clearFirebaseErrorBanner();
119        }
120        this.loadError = false;
121        return;
122      }
123    } catch (err) {
124      // permission-denied = pas authentifié → reset (comportement attendu avant login)
125      if (err.code === 'permission-denied') {
126        this.data.flights = [];
127        this.data.techLogs = [];
128        this.loadError = false;
129        return;
130      }
131      // Autres erreurs (timeout, unavailable, network) : NE PAS écraser les données
132      // déjà en mémoire — on préserve l'état précédent et on signale l'erreur.
133      console.error('[FlightData] Erreur loadData Firestore (données préservées):', err);
134      this.loadError = true;
135      if (window.showFirebaseErrorBanner) {
136        window.showFirebaseErrorBanner('Flugdaten konnten nicht aktualisiert werden — nächster automatischer Versuch in 30 Sekunden.');
137      }
138      this._scheduleLoadRetry();
139      return;
140    }
141
142    // Fallback localStorage si Firestore non disponible
143    try {
144      const raw = localStorage.getItem(this.storageKey);
145      if (!raw) {
146        await this.saveData();
147        return;
148      }
149      const parsed = JSON.parse(raw);
150      this.data = {
151        flights:  Array.isArray(parsed.flights)  ? parsed.flights  : [],
152        techLogs: Array.isArray(parsed.techLogs) ? parsed.techLogs : [],
153        pilots:   Array.isArray(parsed.pilots)   ? parsed.pilots   : [],
154        aircraft: Array.isArray(parsed.aircraft) ? parsed.aircraft : []
155      };
156    } catch (err) {
157      this.data = { flights: [], techLogs: [], pilots: [], aircraft: [] };
158    }
159  }
160
161  /**
162   * Re-tente automatiquement le chargement après un échec (30s, puis tant que
163   * loadError persiste) et à la reconnexion — plus besoin de recharger la page.
164   */
165  _scheduleLoadRetry() {
166    // Retry à la reconnexion (une seule souscription)
167    if (!this._reconnectHooked && window.connectionStatus) {
168      this._reconnectHooked = true;
169      window.connectionStatus.onChange(online => {
170        if (online && this.loadError) this._retryLoadNow();
171      });
172    }
173    // Retry périodique de secours (couvre le cas "en ligne mais requête expirée")
174    if (this._retryTimer) return;
175    this._retryTimer = setTimeout(() => {
176      this._retryTimer = null;
177      if (this.loadError) this._retryLoadNow();
178    }, 30000);
179  }
180
181  async _retryLoadNow() {
182    await this.loadData();
183    if (!this.loadError && window.flightDataUI && typeof window.flightDataUI.refresh === 'function') {
184      window.flightDataUI.refresh();
185    }
186  }
187
188  async saveData() {
189    // Les écritures Firestore sont faites directement dans recordFlight/updateFlight/etc.
190    // On conserve localStorage comme cache.
191    try {
192      localStorage.setItem(this.storageKey, JSON.stringify(this.data));
193      this.cache.clear();
194      return true;
195    } catch (err) {
196      return false;
197    }
198  }
199
200  /**
201   * Migrates existing flight records to include new instructor/checkout fields.
202   * Called once during init to ensure all flights have the required properties.
203   */
204  async migrateFlightsData() {
205    let migrated = 0;
206    this.data.flights.forEach(flight => {
207      let needsMigration = false;
208
209      // Add instructorName if missing (already sent from form but not persisted in older flights)
210      if (!flight.hasOwnProperty('instructorName')) {
211        flight.instructorName = null;
212        needsMigration = true;
213      }
214
215      // Add new checkout tracking fields
216      if (!flight.hasOwnProperty('instructorRemarks')) {
217        flight.instructorRemarks = null;
218        needsMigration = true;
219      }
220      if (!flight.hasOwnProperty('isCheckoutFlight')) {
221        flight.isCheckoutFlight = false;
222        needsMigration = true;
223      }
224      if (!flight.hasOwnProperty('checkoutConfirmedAt')) {
225        flight.checkoutConfirmedAt = null;
226        needsMigration = true;
227      }
228      if (!flight.hasOwnProperty('checkoutConfirmedBy')) {
229        flight.checkoutConfirmedBy = null;
230        needsMigration = true;
231      }
232      if (!flight.hasOwnProperty('checkoutYear')) {
233        flight.checkoutYear = null;
234        needsMigration = true;
235      }
236      if (!flight.hasOwnProperty('checkoutCreditApplied')) {
237        flight.checkoutCreditApplied = false;
238        needsMigration = true;
239      }
240      if (!flight.hasOwnProperty('checkoutCreditMinutes')) {
241        flight.checkoutCreditMinutes = null;
242        needsMigration = true;
243      }
244      if (!flight.hasOwnProperty('checkoutClubChargeAmount')) {
245        flight.checkoutClubChargeAmount = null;
246        needsMigration = true;
247      }
248      if (!flight.hasOwnProperty('checkoutClubTransactionId')) {
249        flight.checkoutClubTransactionId = null;
250        needsMigration = true;
251      }
252
253      if (needsMigration) migrated++;
254    });
255
256    if (migrated > 0) {
257      await this.saveData();
258    }
259  }
260
261  // -------------------------
262  // SEED / INTEGRATIONS
263  // -------------------------
264  async loadAircraftInfo() {
265    // Charger depuis le module Fleet si disponible
266    if (window.fleetModule && window.fleetModule.initialized) {
267      const fleetAircraft = window.fleetModule.getAllAircraft();
268
269      // Convertir au format flight-data
270      this.data.aircraft = fleetAircraft.map(aircraft => ({
271        id: aircraft.id,
272        registration: aircraft.registration,
273        type: aircraft.type,
274        model: aircraft.model,
275        base: 'LSZF', // Homebase par défaut
276        category: 'SEP', // Category par défaut
277        active: aircraft.active
278      }));
279
280      await this.saveData();
281      return;
282    }
283
284    // Fallback: si aircraft list déjà présente, la garder
285    if (this.data.aircraft.length > 0) {
286      return;
287    }
288
289    // Fallback final: seed par défaut (ne devrait jamais arriver si Fleet module est chargé)
290    this.data.aircraft = [
291      { id: 1, registration: 'HB-OCI', type: 'J3C', model: 'Piper Cub', base: 'LSZF', category: 'SEP', active: true },
292      { id: 2, registration: 'HB-KEV', type: 'JB15', model: 'JOB 15-180/2', base: 'LSZF', category: 'SEP', active: true },
293      { id: 3, registration: 'HB-OIA', type: 'J3C', model: 'Piper Cub', base: 'LSZF', category: 'SEP', active: true },
294      { id: 4, registration: 'HB-RBB', type: 'ST75', model: 'Stearman', base: 'LSZF', category: 'SEP', active: true },
295      { id: 5, registration: 'HB-UPR', type: 'SV4A', model: 'Stampe', base: 'LSZF', category: 'SEP', active: false },
296      { id: 6, registration: 'HB-YAV', type: 'MB2', model: 'Kolibri', base: 'LSZF', category: 'SEP', active: true }
297    ];
298
299    await this.saveData();
300  }
301
302  async loadPilotsInfo() {
303    // optional integration with members module
304    if (window.membersModuleInstance && typeof window.membersModuleInstance.getMembers === 'function') {
305      try {
306        const members = window.membersModuleInstance.getMembers();
307        // Load ALL members as potential pilots (not just those with licenses)
308        this.data.pilots = members
309          .filter(m => {
310            const status = m.status ? m.status.toLowerCase() : '';
311            return status === 'aktiv' || status === 'provisorisch aktiv';
312          })
313          .map(m => ({
314            id: m.id,
315            name: `${m.firstName || ''} ${m.lastName || ''}`.trim(),
316            firstName: m.firstName || '',
317            lastName: m.lastName || '',
318            email: m.email || '',
319            licenseNumber: m.pilotLicense || '',
320            qualifications: m.qualifications || [],
321            role: m.role || 'member'
322          }))
323          // Tri par nom de famille (puis prénom en départage), locale 'de' pour gérer les umlauts
324          .sort((a, b) => {
325            const ln = (a.lastName || '').localeCompare(b.lastName || '', 'de', { sensitivity: 'base' });
326            if (ln !== 0) return ln;
327            return (a.firstName || '').localeCompare(b.firstName || '', 'de', { sensitivity: 'base' });
328          });
329        await this.saveData();
330      } catch (err) {
331      }
332    } else {
333    }
334  }
335
336  // -------------------------
337  // VALIDATION (SIMPLIFIÉE)
338  // -------------------------
339  validateFlightSimple(f) {
340    const errors = [];
341    if (!f) {
342      errors.push('Flight object missing');
343      return { valid: false, errors };
344    }
345
346    // essential fields from flight-form-improved
347    if (!f.date) errors.push('date missing');
348    if (!f.aircraft) errors.push('aircraft missing');
349    if (!f.placeOfDeparture) errors.push('placeOfDeparture missing');
350    if (!f.placeOfArrival) errors.push('placeOfArrival missing');
351    if (typeof f.flightTimeMinutes !== 'number' || isNaN(f.flightTimeMinutes) || f.flightTimeMinutes <= 0) {
352      errors.push('flightTimeMinutes must be a positive number');
353    }
354    if (!f.crew) errors.push('crew missing');
355
356    // Plausibilité Block-/Flugzeit (garde-fou contre les fautes de frappe, cf. formulaire)
357    if (typeof f.blockTimeMinutes === 'number' && !isNaN(f.blockTimeMinutes) && f.blockTimeMinutes > 0 &&
358        typeof f.flightTimeMinutes === 'number' && !isNaN(f.flightTimeMinutes) && f.flightTimeMinutes > 0) {
359      if (f.flightTimeMinutes > f.blockTimeMinutes) {
360        errors.push('flightTimeMinutes cannot exceed blockTimeMinutes');
361      } else {
362        const taxiMinutes = f.blockTimeMinutes - f.flightTimeMinutes;
363        const maxTaxiMinutes = Math.max(f.flightTimeMinutes * 0.30, 15);
364        if (taxiMinutes > maxTaxiMinutes) {
365          errors.push(`blockTimeMinutes (${f.blockTimeMinutes}) deviates too much from flightTimeMinutes (${f.flightTimeMinutes}
365): taxi time ${taxiMinutes} min exceeds max ${Math.round(maxTaxiMinutes)} min (30% of flight time, min. 15)`);
366        }
367      }
368    }
369
370    return { valid: errors.length === 0, errors };
371  }
372
373  // -------------------------
374  // RECORD FLIGHT (NEW STRUCTURE)
375  // -------------------------
376  /**
377   * Accepts flight object coming from flight-form-improved format.
378   * Adds metadata, computes block times when possible, stores in this.data.flights
379   */
380  async recordFlight(formFlight) {
381    try {
382      // validate simplified
383      const validation = this.validateFlightSimple(formFlight);
384      if (!validation.valid) {
385        return { success: false, error: `Validation failed: ${validation.errors.join(', ')}` };
386      }
387
388      // Create canonical flight object (merge minimal metadata)
389      const id = `FLT-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
390
391      // Compute block time minutes if blockOff/blockOn present (HH:MM)
392      const blockOff = formFlight.blockOff || null;
393      const blockOn = formFlight.blockOn || null;
394      let totalBlockTimeMinutes = null;
395      if (blockOff && blockOn) {
396        totalBlockTimeMinutes = this._minutesBetween(blockOff, blockOn);
397      }
398
399      // canonical flight object: keep original form fields but add metadata for queries
400      const flight = {
401        // metadata
402        id,
403        createdBy: formFlight.createdBy || window.currentUser?.id || null,
404        createdAt: new Date().toISOString(),
405        updatedAt: null,
406        verified: false,
407        exported: false,
408
409        // core (form-improved fields)
410        date: formFlight.date,
411        aircraft: formFlight.aircraft,                      // registration
412        crew: formFlight.crew,
413        natureOfFlight: formFlight.natureOfFlight || null,
414        instructorName: formFlight.instructorName || null,  // for VS/IS training flights
415        placeOfDeparture: formFlight.placeOfDeparture,
416        placeOfArrival: formFlight.placeOfArrival,
417
418        blockOff,
419        takeoff: formFlight.takeoff || null,
420        landing: formFlight.landing || null,
421        blockOn,
422
423        // time counters (minutes)
424        flightTimeMinutes: Number(formFlight.flightTimeMinutes || 0),
425        blockTimeMinutes: totalBlockTimeMinutes,
426
427        // counters
428        landings: Number(formFlight.landings || 0),
429
430        // totals for aircraft (computed lazily by UI / totals functions)
431        totalLandings: null,
432        totalFlightHours: null,
433
434        // fuel / oil
435        fuelUplift: Number(formFlight.fuelUplift || 0),
436        fuelUpliftExternal: !!formFlight.fuelUpliftExternal,
437        oilUplift: Number(formFlight.oilUplift || 0),
438
439        // checks / maintenance
440        pfc: !!formFlight.pfc,
441        nil: typeof formFlight.nil === 'boolean' ? formFlight.nil : true,
442        defects: Array.isArray(formFlight.defects) ? formFlight.defects : [],
443
444        // link to techlog when NIL false (created by module)
445        linkedTechlogId: null,
446
447        // signature placeholders
448        signatureType: formFlight.signatureType || null,
449        signature: formFlight.signature || null,
450        signatureDate: formFlight.signatureDate || null,
451
452        // free-form remarks
453        remarks: formFlight.remarks || '',
454
455        // instructor training & checkout fields
456        instructorRemarks: null,                             // instructor-only debriefing notes
457        isCheckoutFlight: false,                             // confirmed as mandatory checkout flight
458        checkoutConfirmedAt: null,                           // timestamp of checkout confirmation
459        checkoutConfirmedBy: null,                           // instructor user ID who confirmed
460        checkoutYear: null,                                  // year of checkout (for seasonal blocking)
461        checkoutCreditApplied: false,                        // prevents duplicate credit application
462        checkoutCreditMinutes: null,                         // minutes créditées (min(30, durée vol))
463        checkoutClubChargeAmount: null,                      // montant CHF imputé au compte CLUB
464        checkoutClubTransactionId: null                      // ID transaction CLUB correspondante
465      };
466
467      // Append to dataset
468      this.data.flights.push(flight);
469
470      // If NIL was not checked → create a techLog and link it
471      if (!flight.nil) {
472        const tech = await this.recordTechLog({
473          aircraft: flight.aircraft,
474          date: flight.date,
475          reportedBy: flight.crew,
476          flightId: flight.id,
477          category: 'defect',
478          status: 'open',
479          defects: flight.defects || []
480        });
481        if (tech.success) {
482          flight.linkedTechlogId = tech.techLog.id;
483        }
484      }
485
486      const db = window.firestoreDb;
487      if (db) {
488        await db.collection('flights').doc(flight.id).set(flight);
489      }
490
491      await this.saveData();
492
493      // Trigger billing if finance module is available
494      if (window.currentAccountModule && typeof window.currentAccountModule.process
494FlightBilling === 'function') {
495        try {
496          await window.currentAccountModule.processFlightBilling(flight);
497        } catch (billingErr) {
498        }
499      }
500
501      // Dispatch custom event for other modules
502      document.dispatchEvent(new CustomEvent('flightRecorded', { detail: { flight } }));
503
504      return { success: true, flight, message: 'Flug erfasst' };
505    } catch (err) {
506      return { success: false, error: err.message || String(err) };
507    }
508  }
509
510  // -------------------------
511  // RECORD TECHLOG (simple)
512  // -------------------------
513  async recordTechLog(tl) {
514    try {
515      if (!tl) throw new Error('techlog data missing');
516
517      const id = `TL-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
518      const techLog = {
519        id,
520        createdAt: new Date().toISOString(),
521        aircraft: tl.aircraft,
522        date: tl.date || new Date().toISOString().split('T')[0],
523        reportedBy: tl.reportedBy || null,
524        flightId: tl.flightId || null,
525        category: tl.category || 'defect',
526        status: tl.status || 'open',
527        defects: Array.isArray(tl.defects) ? tl.defects : (tl.defect ? [tl.defect] : []),
528        notes: tl.notes || '',
529        createdBy: tl.createdBy || window.currentUser?.id || null,
530        verified: false,
531        exported: false
532      };
533
534      this.data.techLogs.push(techLog);
535
536      const db = window.firestoreDb;
537      if (db) {
538        await db.collection('techLogs').doc(techLog.id).set(techLog);
539      }
540
541      await this.saveData();
542
543      return { success: true, techLog, message: 'TechLog created' };
544    } catch (err) {
545      return { success: false, error: err.message || String(err) };
546    }
547  }
548
549  // -------------------------
550  // READ / QUERY
551  // -------------------------
552    getFlights(filters = {}) {
553      // filters: pilotId, aircraft, startDate, endDate, natureOfFlight
554      let flights = Array.isArray(this.data.flights) ? [...this.data.flights] : [];
555
556      // Filtre pilote: se base exclusivement sur `crew` (pilote réel du vol),
557      // jamais sur `createdBy` (qui a saisi le vol dans le système).
558      if (filters.pilotId) {
559        const pilotName = this._getPilotNameById(filters.pilotId);
560        const targetId = String(filters.pilotId);
561        flights = flights.filter(f => {
562          if (!f.crew) return false;
563          // Anciens vols: crew est un ID numérique (string ou number)
564          if (String(f.crew) === targetId) return true;
565          // Nouveaux vols: crew est un nom complet
566          if (pilotName && typeof f.crew === 'string') {
567            return f.crew.trim().toLowerCase() === pilotName.trim().toLowerCase();
568          }
569          return false;
570        });
571      }
572
573      if (filters.aircraft) {
574        flights = flights.filter(f => f.aircraft === filters.aircraft);
575      }
576
577      if (filters.startDate) {
578        flights = flights.filter(f => new Date(f.date) >= new Date(filters.startDate));
579      }
580
581      if (filters.endDate) {
582        flights = flights.filter(f => new Date(f.date) <= new Date(filters.endDate));
583      }
584
585      if (filters.natureOfFlight) {
586        flights = flights.filter(f => f.natureOfFlight === filters.natureOfFlight);
587      }
588
589      // CORRECTION 2: Tri amélioré avec support de multiples formats de temps
590      return flights.sort((a, b) => {
591        // 1. Comparer les dates (plus récent d'abord)
592        const dateCompare = new Date(b.date) - new Date(a.date);
593        if (dateCompare !== 0) return dateCompare;
594        
595        // 2. Même date: comparer les heures (plus récent d'abord)
596        // Essayer plusieurs champs dans l'ordre de préférence
597        const timeA = a.timeTakeoff || a.takeoff || a.timeOffBlocks || a.blockOff || '00:00';
598        const timeB = b.timeTakeoff || b.takeoff || b.timeOffBlocks || b.blockOff || '00:00';
599        
600        // Convertir HH:MM en minutes pour comparaison numérique précise
601        const minutesA = this._timeToMinutes(timeA);
602        const minutesB = this._timeToMinutes(timeB);
603        
604        return minutesB - minutesA;  // Descendant (plus récent d'abord)
605      });
606    }
607
608    /**
609     * Helper: Obtenir le nom d'un pilote à partir de son ID
610     * Utilisé pour le filtrage des vols
611     */
612    _getPilotNameById(pilotId) {
613      if (!pilotId) return null;
614      
615      // Chercher dans this.data.pilots
616      const pilot = (this.data.pilots || []).find(p => 
617        p.id === pilotId || 
618        String(p.id) === String(pilotId)
619      );
620      
621      return pilot ? pilot.name : null;
622    }
623
624    /**
625     * Helper: Convertir HH:MM en minutes
626     * Utilisé pour le tri chronologique précis
627     */
628    _timeToMinutes(timeStr) {
629      if (!timeStr || typeof timeStr !== 'string') return 0;
630      const parts = timeStr.split(':');
631      if (parts.length !== 2) return 0;
632      const hours = parseInt(parts[0], 10);
633      const minutes = parseInt(parts[1], 10);
634      if (isNaN(hours) || isNaN(minutes)) return 0;
635      return hours * 60 + minutes;
636    }
637
638
639  getTechLogs(filters = {}) {
640    let techLogs = Array.isArray(this.data.techLogs) ? [...this.data.techLogs] : [];
641    
642    if (filters.pilotId) {
643      techLogs = techLogs.filter(t => t.reportedBy === filters.pilotId || String(t.reportedBy) === String(filters.pilotId));
644    }
645    
646    if (filters.aircraft) techLogs = techLogs.filter(t => t.aircraft === filters.aircraft);
647    if (filters.status) techLogs = techLogs.filter(t => t.status === filters.status);
648    if (filters.startDate) techLogs = techLogs.filter(t => new Date(t.date) >= new Date(filters.startDate));
649    if (filters.endDate) techLogs = techLogs.filter(t => new Date(t.date) <= new Date(filters.endDate));
650    
651    return techLogs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
652  }
653
654  getFlightById(id) {
655    return this.data.flights.find(f => f.id === id) || null;
656  }
657
658  // -------------------------
659  // UPDATE / DELETE
660  // -------------------------
661  async updateFlight(id, patch = {}) {
662    const idx = this.data.flights.findIndex(f => f.id === id);
663    if (idx === -1) return { success: false, error: 'flight not found' };
664
665    const oldFlight = { ...this.data.flights[idx] };
666
667    // Check if the flight's period is closed
668    if (window.currentAccountModule) {
669      const flightDate = patch.date || oldFlight.date;
670      const period = window.currentAccountModule.getCurrentPeriod(flightDate);
671      const isClosed = window.currentAccountModule.isPeriodClosed(period);
672
673      if (isClosed) {
674        return {
675          success: false,
676          error: `Periode ${period} ist bereits abgeschlossen. Flüge können nicht mehr bearbeitet werden.`
677        };
678      }
679    }
680
681    // Recalculer blockTimeMinutes si blockOff ou blockOn change
682    if (patch.blockOff || patch.blockOn) {
683      const currentFlight = this.data.flights[idx];
684      const newBlockOff = patch.blockOff || currentFlight.blockOff;
685      const newBlockOn = patch.blockOn || currentFlight.blockOn;
686
687      if (newBlockOff && newBlockOn) {
688        patch.blockTimeMinutes = this._minutesBetween(newBlockOff, newBlockOn);
689      }
690    }
691
692    // Recalculer flightTimeMinutes si takeoff ou landing change
693    if (patch.takeoff || patch.landing) {
694      const currentFlight = this.data.flights[idx];
695      const newTakeoff = patch.takeoff || currentFlight.takeoff;
696      const newLanding = patch.landing || currentFlight.landing;
697
698      if (newTakeoff && newLanding) {
699        patch.flightTimeMinutes = this._minutesBetween(newTakeoff, newLanding);
700      }
701    }
702
703    this.data.flights[idx] = { ...this.data.flights[idx], ...patch, updatedAt: new Date().toISOString() };
704
705    const updatedFlight = this.data.flights[idx];
706    const db = window.firestoreDb;
707    if (db) {
708      // Sanitize : supprimer les champs undefined et les flags UI internes (préfixe _)
709      const firestoreData = Object.fromEntries(
710        Object.entries(updatedFlight).filter(([k, v]) => v !== undefined && !k.startsWith('_'))
711      );
712      await db.collection('flights').doc(id).set(firestoreData);
713    }
714
715    await this.saveData();
716
717    // Update billing if finance module is available
718    if (window.currentAccountModule && typeof window.currentAccountModule.updateFlightBilling === 'function') {
719      try {
720        const billingResult = await window.currentAccountModule.updateFlightBilling(updatedFlight, oldFlight);
721      } catch (billingErr) {
722      }
723    }
724
725    // Dispatch event for other modules
726    document.dispatchEvent(new CustomEvent('flightUpdated', { detail: { flight: updatedFlight, oldFlight } }));
727
728    return { success: true, flight: updatedFlight };
729  }
730
731  async deleteFlight(id) {
732    const idx = this.data.flights.findIndex(f => f.id === id);
733    if (idx === -1) return { success: false, error: 'flight not found' };
734
735    const flight = this.data.flights[idx];
736
737    // Check if the flight's period is closed
738    if (window.currentAccountModule) {
739      const period = window.currentAccountModule.getCurrentPeriod(flight.date);
740      const isClosed = window.currentAccountModule.isPeriodClosed(period);
741
742      if (isClosed) {
743        return {
744          success: false,
745          error: `Periode ${period} ist bereits abgeschlossen. Flüge können nicht mehr gelöscht werden.`
746        };
747      }
748    }
749
750    const removed = this.data.flights.splice(idx, 1)[0];
751
752    const db = window.firestoreDb;
753    if (db) {
754      await db.collection('flights').doc(id).delete();
755    }
756
757    await this.saveData();
758
759    // Cancel billing if finance module is available
760    if (window.currentAccountModule && typeof window.currentAccountModule.cancelFlightBilling === 'function') {
761      try {
762        const billingResult = await window.currentAccountModule.cancelFlightBilling(id);
763      } catch (billingErr) {
764      }
765    }
766
767    // Dispatch event for other modules
768    document.dispatchEvent(new CustomEvent('flightDeleted', { detail: { flight: removed } }));
769
770    return { success: true, flight: removed };
771  }
772
773  // ============================================
774  // MÉTHODE calculateTotals - VERSION ULTRA-SIMPLE
775  // ============================================
776
777  calculateTotals(filter = {}) {
778    // ✅ Récupérer les vols via getFlights
779    const flights = this.getFlights(filter);
780    
781    // Si pas de vols, retourner des totaux vides
782    if (flights.length === 0) {
783      return {
784        totalFlights: 0,
785        totalFlightMinutes: 0,
786        totalLandings: 0,
787        byAircraft: {}
788      };
789    }
790    
791    // Initialiser les totaux
792    const totals = {
793      totalFlights: flights.length,
794      totalFlightMinutes: 0,
795      totalLandings: 0,
796      byAircraft: {}
797    };
798    
799    // Parcourir chaque vol et additionner
800    flights.forEach((flight, index) => {
801      // ✅ CORRECTION: Convertir en nombre et gérer null/undefined
802      const minutes = parseInt(flight.blockTimeMinutes) || 0;
803      const landings = parseInt(flight.landings) || 0;
804      
805      // Log du premier vol pour debug
806      if (index === 0) {
807      }
808      
809      // Ajouter aux totaux
810      totals.totalFlightMinutes += minutes;
811      totals.totalLandings += landings;
812      
813      // Statistiques par avion
814      if (flight.aircraft) {
815        if (!totals.byAircraft[flight.aircraft]) {
816          totals.byAircraft[flight.aircraft] = {
817            flights: 0,
818            minutes: 0,
819            landings: 0
820          };
821        }
822        totals.byAircraft[flight.aircraft].flights++;
823        totals.byAircraft[flight.aircraft].minutes += minutes;
824        totals.byAircraft[flight.aircraft].landings += landings;
825      }
826    });
827    
828    return totals;
829  }
830
831  // -------------------------
832  // EXPORT (CSV) - simple
833  // -------------------------
834  exportFlightsToCSV(flights = []) {
835    // header aligned with new structure
836    const headers = [
837      'id','date','aircraft','crew','natureOfFlight','placeOfDeparture','placeOfArrival',
838      'blockOff','takeoff','landing','blockOn','flightTimeMinutes','totalBlockTimeMinutes',
839      'landings','fuelUplift','fuelUpliftExternal','oilUplift','pfc','nil','linkedTechlogId','remarks','createdBy','createdAt'
840    ];
841    const rows = flights.map(f => {
842      return headers.map(h => {
843        const v = f[h];
844        if (v === null || v === undefined) return '';
845        if (typeof v === 'boolean') return v ? '1' : '0';
846        if (Array.isArray(v)) return v.join('; ');
847        return String(v).replace(/\r?\n/g, ' ');
848      }).join(',');
849    });
850    return [headers.join(','), ...rows].join('\n');
851  }
852
853  exportAllFlightsCSV(filters = {}) {
854    const flights = this.getFlights(filters);
855    return this.exportFlightsToCSV(flights);
856  }
857
858  // -------------------------
859  // UTILITIES
860  // -------------------------
861  _minutesBetween(timeA, timeB) {
862    // time format "HH:MM" (24h). Returns minutes from A to B (assumes B may be after midnight)
863    try {
864      const [hA, mA] = timeA.split(':').map(Number);
865      const [hB, mB] = timeB.split(':').map(Number);
866      let a = hA * 60 + mA;
867      let b = hB * 60 + mB;
868      if (b < a) b += 24 * 60; // next day
869      return b - a;
870    } catch (err) {
871      return null;
872    }
873  }
874
875  getAircraftInfo(registration) {
876    return this.data.aircraft.find(a => a.registration === registration) || null;
877  }
878
879  getPilotInfo(pilotId) {
880    return this.data.pilots.find(p => p.id === pilotId) || null;
881  }
882
883  // -------------------------
884  // GLOBAL EXPORTS
885  // -------------------------
886  exportGlobalFunctions() {
887    window.flightDataModule = this;
888    window.recordFlight = this.recordFlight.bind(this);
889    window.recordTechLog = this.recordTechLog.bind(this);
890    window.getFlights = this.getFlights.bind(this);
891    window.getTechLogs = this.getTechLogs.bind(this);
892    window.calculateFlightTotals = this.calculateTotals.bind(this);
893
894    // Export instructor/checkout methods
895    window.getInstructorFlights = this.getInstructorFlights.bind(this);
896    window.getCheckoutStatus = this.getCheckoutStatus.bind(this);
897    window.addInstructorRemarks = this.addInstructorRemarks.bind(this);
898    window.confirmCheckoutFlight = this.confirmCheckoutFlight.bind(this);
899
900  }
901
902  // -------------------------
903  // INSTRUCTOR & CHECKOUT METHODS
904  // -------------------------
905
906  /**
907   * Get all instructor flights (VS/IS) for a specific instructor.
908   * @param {string} instructorFullName - Full name of instructor (e.g., "Peter Schmidt")
909   * @param {object} filters - Optional filters (date range, aircraft, etc.)
910   * @returns {Array} Array of flights where the member was instructor
911   */
912  getInstructorFlights(instructorFullName, filters = {}) {
913    if (!instructorFullName) return [];
914
915    let flights = this.data.flights.filter(f => {
916      // Must be training flight (VS or IS)
917      const isTraining = f.natureOfFlight === 'VS' || f.natureOfFlight === 'IS';
918      // Must match instructor name
919      const matchesInstructor = f.instructorName === instructorFullName;
920
921      return isTraining && matchesInstructor;
922    });
923
924    // Apply optional filters
925    if (filters.startDate) {
926      flights = flights.filter(f => f.date >= filters.startDate);
927    }
928    if (filters.endDate) {
929      flights = flights.filter(f => f.date <= filters.endDate);
930    }
931    if (filters.aircraft) {
932      flights = flights.filter(f => f.aircraft === filters.aircraft);
933    }
934    if (filters.student) {
935      flights = flights.filter(f => f.crew.includes(filters.student));
936    }
937
938    // Sort by date descending (most recent first)
939    flights.sort((a, b) => new Date(b.date) - new Date(a.date));
940
941    return flights;
942  }
943
944  /**
945   * Check if a member has completed their mandatory checkout flight for a given year.
946   * @param {number} memberId - Member ID
947   * @param {number} year - Year to check (e.g., 2025)
948   * @returns {object} {hasCheckout: boolean, checkoutFlight: object|null, checkoutDate: string|null}
949   */
950  getCheckoutStatus(memberId, year) {
951    // Saisons ≤ 2025 : tous les membres sont considérés comme confirmés (pas d'historique requis).
952    // Le système de Kontrollflug obligatoire ne s'applique qu'à partir de la saison 2026.
953    if (year <= 2025) {
954      return { hasCheckout: true, checkoutFlight: null, checkoutDate: null, reason: 'grandfathered' };
955    }
956
957    // Get member name to match against flight crew
958    const member = window.membersModuleInstance?.getMember(memberId);
959    if (!member) {
960      return { hasCheckout: false, checkoutFlight: null, checkoutDate: null, reason: 'member_not_found' };
961    }
962
963    const memberFullName = `${member.firstName} ${member.lastName}`;
964
965    // Find confirmed checkout flight for this member and year.
966    // On filtre uniquement sur `crew` (pilote réel), jamais sur `createdBy`
967    // (qui indique seulement qui a saisi le vol dans le système).
968    const checkoutFlight = this.data.flights.find(f => {
969      const matchesMember = f.crew === memberFullName;
970      const isConfirmedCheckout = f.isCheckoutFlight === true;
971
972      // Primary check: use stored checkoutYear (correct for recently confirmed flights)
973      // Fallback: recalculate season year from flight date (handles flights confirmed before
974      // the season-year logic was introduced, e.g. a Feb 2026 flight stored with checkoutYear=2026
975      // instead of the correct season year 2025)
976      let matchesYear = f.checkoutYear === year;
977      if (!matchesYear && f.date) {
978        const fd = new Date(f.date);
979        const calculatedSeasonYear = fd.getFullYear(); // season = calendar year (Jan 1 – Jun 30)
980        matchesYear = calculatedSeasonYear === year;
981      }
982
983      return matchesMember && isConfirmedCheckout && matchesYear;
984    });
985
986    return {
987      hasCheckout: !!checkoutFlight,
988      checkoutFlight: checkoutFlight || null,
989      checkoutDate: checkoutFlight?.date || null
990    };
991  }
992
993  /**
994   * Add or update instructor remarks for a flight.
995   * @param {string} flightId - Flight ID
996   * @param {string} remarks - Instructor's debriefing remarks
997   * @param {object} instructorUser - Current instructor user object
998   * @returns {object} {success: boolean, message: string}
999   */
1000  async addInstructorRemarks(flightId, remarks, instructorUser) {
1001    const flight = this.data.flights.find(f => f.id === flightId);
1002    if (!flight) {
1003      return { success: false, message: 'Flug nicht gefunden' };
1004    }
1005
1006    // Verify user is the instructor of this flight
1007    const instructorFullName = `${instructorUser.firstName} ${instructorUser.lastName}`;
1008    if (flight.instructorName !== instructorFullName) {
1009      return { success: false, message: 'Nur der Fluglehrer kann Bemerkungen hinzufügen' };
1010    }
1011
1012    // Update remarks
1013    flight.instructorRemarks = remarks || null;
1014    flight.updatedAt = new Date().toISOString();
1015
1016    await this.saveData();
1017
1018    return {
1019      success: true,
1020      message: 'Bemerkungen gespeichert',
1021      flight
1022    };
1023  }
1024
1025  /**
1026   * Confirm a flight as a mandatory checkout flight and apply 30-minute credit.
1027   * @param {string} flightId - Flight ID
1028   * @param {object} instructorUser - Current instructor user object
1029   * @returns {object} {success: boolean, message: string, creditAmount?: number}
1030   */
1031  async confirmCheckoutFlight(flightId, instructorUser) {
1032    const flight = this.data.flights.find(f => f.id === flightId);
1033    if (!flight) {
1034      return { success: false, message: 'Flug nicht gefunden' };
1035    }
1036
1037    // Verify user is the instructor of this flight
1038    const instructorFullName = `${instructorUser.firstName} ${instructorUser.lastName}`;
1039    if (flight.instructorName !== instructorFullName) {
1040      return { success: false, message: 'Nur der Fluglehrer kann den Kontrollflug bestätigen' };
1041    }
1042
1043    // Check if already confirmed
1044    if (flight.checkoutCreditApplied) {
1045      return { success: false, message: 'Kontrollflug bereits bestätigt und Gutschrift angewendet' };
1046    }
1047
1048    // Verify it's a training flight
1049    if (flight.natureOfFlight !== 'VS' && flight.natureOfFlight !== 'IS') {
1050      return { success: false, message: 'Nur Schulungsflüge (VS/IS) können als Kontrollflüge bestätigt werden' };
1051    }
1052
1053    // Check if finance module is available
1054    if (!window.currentAccountModule) {
1055      return { success: false, message: 'Finanzmodul nicht verfügbar — Gutschrift nicht möglich' };
1056    }
1057
1058    if (!window.aircraftManagementModule) {
1059      return { success: false, message: 'Flugzeugverwaltung nicht verfügbar — Kostenberechnung nicht möglich' };
1060    }
1061
1062    try {
1063      // Get student member ID by searching through members
1064      let student = null;
1065      if (window.membersModuleInstance && window.membersModuleInstance.data) {
1066        // Search by full name (flight.crew is "FirstName LastName")
1067        student = window.membersModuleInstance.data.find(m => {
1068          const fullName = `${m.firstName} ${m.lastName}`;
1069          return fullName === flight.crew;
1070        });
1071      }
1072
1073      if (!student) {
1074        return { success: false, message: `Schüler nicht gefunden: ${flight.crew}` };
1075      }
1076
1077      // Année civile du vol (le Kontrollflug est valable du 1er janvier au 31 décembre)
1078      const checkoutYear = new Date(flight.date).getFullYear();
1079
1080      // Vérifier qu'aucune Gutschrift Kontrollflug n'existe déjà pour ce membre cette année civile
1081      const existingCheckoutCredit = window.currentAccountModule.data.transactions.find(t => {
1082        if (t.type !== 'manual_credit' || !t.metadata?.checkoutCredit) return false;
1083        if (t.memberId !== student.id) return false;
1084        if (t.status === 'cancelled') return false; // Ignorer les transactions storniert
1085        return new Date(t.date).getFullYear() === checkoutYear;
1086      });
1087
1088      if (existingCheckoutCredit) {
1089        return {
1090          success: false,
1091          message: `${flight.crew} hat bereits eine Kontrollflug-Gutschrift für das Jahr ${checkoutYear} erhalten`
1092        };
1093      }
1094
1095      // Crédit = min(30, durée du vol) — un vol plus court que 30 min ne peut créditer plus que sa durée
1096      const flightDurationMinutes = flight.flightTimeMinutes || 0;
1097      const creditMinutes = Math.min(30, flightDurationMinutes > 0 ? flightDurationMinutes : 30);
1098
1099      const costCalc = window.aircraftManagementModule.calculateFlightCost(
1100        flight.aircraft,
1101        creditMinutes,
1102        0,   // pas de carburant externe
1103        flight.date
1104      );
1105
1106      if (!costCalc || !costCalc.baseCost) {
1107        return { success: false, message: 'Fehler bei der Kostenberechnung' };
1108      }
1109
1110      const creditAmount = costCalc.baseCost;
1111
1112      // Gutschrift sur le compte du membre (crédit négatif)
1113      const transaction = {
1114        id: `TRX-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
1115        type: 'manual_credit',
1116        memberId: student.id,
1117        amount: -Math.abs(creditAmount),  // Negative = credit
1118        description: `Kontrollflug-Gutschrift ${flight.aircraft} (${creditMinutes} min, ${flight.date})`,
1119        date: flight.date,
1120        period: flight.date.substring(0, 7), // YYYY-MM format
1121        status: 'confirmed',
1122        createdAt: new Date().toISOString(),
1123        createdBy: instructorUser.id,
1124        metadata: {
1125          flightId: flight.id,
1126          checkoutCredit: true,
1127          aircraft: flight.aircraft,
1128          creditMinutes
1129        }
1130      };
1131
1132      // Add transaction to finance module
1133      if (!window.currentAccountModule.data || !window.currentAccountModule.data.transactions) {
1134        return { success: false, message: 'Finanzdaten nicht verfügbar' };
1135      }
1136
1137      window.currentAccountModule.data.transactions.push(transaction);
1138      await window.currentAccountModule.saveData();
1139
1140      // Écriture Firestore de la transaction (manquait — localStorage seul ne suffit pas)
1141      const db = window.firestoreDb;
1142      if (db) {
1143        try {
1144          await db.collection('transactions').doc(transaction.id).set(transaction);
1145        } catch (e) {
1146          console.error('[confirmCheckoutFlight] Firestore transaction write error:', e);
1147          return { success: false, message: 'Fehler beim Speichern der Gutsc
1147hrift in Firestore: ' + e.message };
1148        }
1149      }
1150
1151      // Verify transaction was saved
1152      const savedTransaction = window.currentAccountModule.data.transactions.find(t => t.id === transaction.id);
1153      if (!savedTransaction) {
1154        return { success: false, message: 'Fehler beim Speichern der Gutschrift' };
1155      }
1156
1157      // Mark flight as confirmed checkout
1158      flight.isCheckoutFlight = true;
1159      flight.checkoutConfirmedAt = new Date().toISOString();
1160      flight.checkoutConfirmedBy = instructorUser.id;
1161      flight.checkoutYear = checkoutYear;
1162      flight.checkoutCreditApplied = true;
1163      flight.checkoutCreditMinutes = creditMinutes;
1164      flight.checkoutClubChargeAmount = creditAmount;
1165      flight.updatedAt = new Date().toISOString();
1166
1167      // Contrepartie sur le compte CLUB (Flüge zu Lasten der Gruppe)
1168      if (window.clubChargeModule?.createCheckoutClubCharge) {
1169        try {
1170          const clubRes = await window.clubChargeModule.createCheckoutClubCharge(
1171            flight, creditMinutes, creditAmount, student.id
1172          );
1173          if (clubRes.success && clubRes.transaction) {
1174            flight.checkoutClubTransactionId = clubRes.transaction.id;
1175          }
1176        } catch (e) {
1177          console.warn('[confirmCheckoutFlight] createCheckoutClubCharge failed:', e);
1178        }
1179      }
1180
1181      if (db) {
1182        await db.collection('flights').doc(flight.id).set(flight);
1183      }
1184
1185      await this.saveData();
1186
1187      return {
1188        success: true,
1189        message: `Checkout flight confirmed. ${creditAmount.toFixed(2)} CHF credited to ${flight.crew} (${creditMinutes} min)`,
1190        creditAmount,
1191        creditMinutes,
1192        flight
1193      };
1194
1195    } catch (error) {
1196      return { success: false, message: `Error: ${error.message}` };
1197    }
1198  }
1199
1200  // -------------------------
1201  // FLEET INTEGRATION
1202  // -------------------------
1203  async refreshAircraftFromFleet() {
1204    // Recharger la liste d'avions depuis le Fleet module
1205    if (window.fleetModule && window.fleetModule.initialized) {
1206      const fleetAircraft = window.fleetModule.getAllAircraft();
1207
1208      this.data.aircraft = fleetAircraft.map(aircraft => ({
1209        id: aircraft.id,
1210        registration: aircraft.registration,
1211        type: aircraft.type,
1212        model: aircraft.model,
1213        base: 'LSZF',
1214        category: 'SEP',
1215        active: aircraft.active
1216      }));
1217
1218      await this.saveData();
1219      // Notifier l'UI si elle existe
1220      if (window.flightDataUI && typeof window.flightDataUI.refreshAircraftOptions === 'function') {
1221        window.flightDataUI.refreshAircraftOptions();
1222      }
1223    } else {
1224    }
1225  }
1226
1227  setupFleetListeners() {
1228    // Écouter les changements de flotte
1229    document.addEventListener('aircraftAdded', () => {
1230      this.refreshAircraftFromFleet();
1231    });
1232
1233    document.addEventListener('aircraftUpdated', () => {
1234      this.refreshAircraftFromFleet();
1235    });
1236
1237    document.addEventListener('aircraftArchived', () => {
1238      this.refreshAircraftFromFleet();
1239    });
1240
1241    document.addEventListener('aircraftReactivated', () => {
1242      this.refreshAircraftFromFleet();
1243    });
1244
1245  }
1246}
1247
1248// Export as module when possible (browser globals are also set on init)
1249if (typeof module !== 'undefined' && module.exports) {
1250  module.exports = FlightDataModule;
1251}

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.