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.