import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges, computed, effect, signal } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import oedb from '../../../oedb-types'; import { OedbApi } from '../../services/oedb-api'; import { JsonPipe } from '@angular/common'; @Component({ selector: 'app-edit-form', standalone: true, imports: [ReactiveFormsModule, JsonPipe], templateUrl: './edit-form.html', styleUrl: './edit-form.scss' }) export class EditForm implements OnChanges { @Input() selected: any | null = null; @Output() saved = new EventEmitter(); @Output() created = new EventEmitter(); @Output() deleted = new EventEmitter(); @Output() canceled = new EventEmitter(); form: FormGroup; allPresets: Array<{ key: string, label: string, emoji: string, category: string, description?: string, durationHours?: number, properties?: Record }>; filteredGroups = computed(() => this.groupPresets(this.form.get('what')?.value || '')); currentPreset = computed(() => { const key = this.form.get('what')?.value || ''; return (oedb.presets.what as any)[key] || null; }); presetEntries = computed(() => { const p = this.currentPreset(); const props = (p && p.properties) ? p.properties as Record : {}; return Object.keys(props).map(k => ({ key: k, spec: props[k] })); }); presetValues = signal>({}); extraPropertyKeys = signal([]); private readonly defaultFormKeys = new Set([ 'label','description','what','where','lat','lon','noLocation','wikidata','featureType','type','start','stop' ]); status = signal<{ state: 'idle' | 'saving' | 'saved' | 'error', message?: string, what?: string }>({ state: 'idle' }); featureId = signal(null); durationHuman = signal(''); constructor(private fb: FormBuilder, private api: OedbApi) { this.form = this.fb.group({ label: ['', Validators.required], description: [''], what: ['', Validators.required], where: [''], lat: ['', Validators.required], lon: ['', Validators.required], noLocation: [false], wikidata: [''], featureType: ['point'], type: ['unscheduled'], start: [''], stop: [''] }); const what = oedb.presets.what as Record; this.allPresets = Object.keys(what).map(k => ({ key: k, label: what[k].label || k, emoji: what[k].emoji || '', category: what[k].category || 'Autres', description: what[k].description || '' })); // initialize default 24h window const now = new Date(); const in24h = new Date(now.getTime() + 24 * 3600 * 1000); this.form.patchValue({ start: this.toLocalInputValue(now), stop: this.toLocalInputValue(in24h) }, { emitEvent: false }); // initial fill if provided on first render this.fillFormFromSelected(); // watch start/stop changes to update human duration this.form.valueChanges.subscribe(v => { const startIso = this.toIsoFromLocalInput(v.start); const stopIso = this.toIsoFromLocalInput(v.stop); if (startIso && stopIso) this.durationHuman.set(this.humanDuration(startIso, stopIso)); }); } ngOnChanges(changes: SimpleChanges): void { if (changes['selected']) { this.fillFormFromSelected(); } } private fillFormFromSelected() { const sel = this.selected; if (sel && sel.properties) { const propId = sel?.properties?.id ?? sel?.properties?.uuid; this.featureId.set((propId ?? sel.id) ?? null); const p = sel.properties || {}; const coords = sel?.geometry?.coordinates || []; this.form.patchValue({ label: p.label || p.name || '', id: p.id || '', description: p.description || '', what: p.what || '', "what:series": p['what:series'] || '', where: p.where || '', lat: coords[1] ?? '', lon: coords[0] ?? '', wikidata: p.wikidata || '', featureType: 'point', type: p.type || this.form.value.type || 'unscheduled', start: this.toLocalInputValue(p.start || p.when || new Date()), stop: this.toLocalInputValue(p.stop || new Date(new Date().getTime() + 24 * 3600 * 1000)) }, { emitEvent: false }); // Ajouter des contrôles pour les propriétés du preset courant const current = this.currentPreset(); const presetKeys = new Set( current && current.properties ? Object.keys(current.properties) : [] ); presetKeys.forEach(key => this.ensureControl(key, p[key] ?? current?.properties?.[key]?.default ?? '')); // Ajouter des contrôles pour les autres propriétés présentes dans l'événement sélectionné const extra: string[] = []; Object.keys(p).forEach((key) => { if (this.defaultFormKeys.has(key)) return; if (presetKeys.has(key)) return; this.ensureControl(key, p[key]); extra.push(key); }); this.extraPropertyKeys.set(extra); } } applyPreset(key: string) { const what = oedb.presets.what as Record; const preset = what[key]; if (!preset) return; this.form.patchValue({ what: key, label: preset.label || this.form.value.label, description: preset.description || this.form.value.description }); // Créer/mettre à jour les contrôles dynamiques pour les propriétés du preset const props = preset.properties || {}; Object.keys(props).forEach(k => { const initial = Object.prototype.hasOwnProperty.call(props[k], 'default') ? props[k].default : ''; this.ensureControl(k, (this.selected?.properties?.[k] ?? initial)); }); // adjust stop based on preset duration const startIso = this.toIsoFromLocalInput(this.form.value.start); if (typeof preset.durationHours === 'number' && startIso) { const start = new Date(startIso); const stop = new Date(start.getTime() + preset.durationHours * 3600 * 1000); this.form.patchValue({ stop: this.toLocalInputValue(stop) }, { emitEvent: true }); } // Recalculer les extra properties (non définies par le preset) const presetKeys = new Set(Object.keys(props)); const currentProps = this.selected?.properties || {}; const extra: string[] = []; Object.keys(currentProps).forEach((k) => { if (this.defaultFormKeys.has(k)) return; if (presetKeys.has(k)) return; this.ensureControl(k, currentProps[k]); extra.push(k); }); this.extraPropertyKeys.set(extra); } private groupPresets(query: string): Array<{ category: string, items: Array<{ key: string, label: string, emoji: string }> }> { const q = String(query || '').trim().toLowerCase(); const matches = (p: typeof this.allPresets[number]) => { if (!q) return true; return ( p.key.toLowerCase().includes(q) || (p.label || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q) || (p.category || '').toLowerCase().includes(q) ); }; const grouped: Record> = {}; for (const p of this.allPresets) { if (!matches(p)) continue; const cat = p.category || 'Autres'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push({ key: p.key, label: p.label, emoji: p.emoji }); } return Object.keys(grouped) .sort((a, b) => a.localeCompare(b)) .map(cat => ({ category: cat, items: grouped[cat].sort((a, b) => a.label.localeCompare(b.label)) })); } onSubmit() { const val = this.form.value as any; // Validation minimale: what obligatoire; coordonnées lat/lon obligatoires si non en ligne/sans lieu if (!val.what || String(val.what).trim() === '') { this.status.set({ state: 'error', what: val.what, message: 'Le champ "what" est requis' }); setTimeout(() => this.status.set({ state: 'idle' }), 3000); return; } const isNoLocation = !!val.noLocation; const numLat = Number(val.lat); const numLon = Number(val.lon); const haveCoords = Number.isFinite(numLat) && Number.isFinite(numLon); if (!isNoLocation && !haveCoords) { this.status.set({ state: 'error', what: val.what, message: 'Latitude et longitude sont requises' }); setTimeout(() => this.status.set({ state: 'idle' }), 3000); return; } // Construire la géométrie selon le mode let geometry: any; if (isNoLocation) { // Polygone englobant le monde (bbox globale) geometry = { type: 'Polygon', coordinates: [[ [-180, -90], [-180, 90], [180, 90], [180, -90], [-180, -90] ]] }; } else { geometry = { type: 'Point', coordinates: [Number(val.lon), Number(val.lat)] }; } const feature: any = { type: 'Feature', properties: { label: val.label, description: val.description, what: val.what, where: val.where, wikidata: val.wikidata, type: val.type, start: this.toIsoFromLocalInput(val.start), stop: this.toIsoFromLocalInput(val.stop), ...(isNoLocation ? { no_location: true } : {}) }, geometry }; // Apply default duration from preset when creating a new event const durationPreset = (oedb.presets.what as any)[val.what]; if ((!this.featureId()) && durationPreset && typeof durationPreset.durationHours === 'number') { // already set from form; ensure consistency if empty if (!feature.properties.start || !feature.properties.stop) { const start = new Date(); const stop = new Date(start.getTime() + durationPreset.durationHours * 3600 * 1000); feature.properties.start = start.toISOString(); feature.properties.stop = stop.toISOString(); } } const id = this.featureId(); // Ajouter les propriétés issues du preset (contrôles dynamiques) const submitPreset = (oedb.presets.what as any)[val.what]; if (submitPreset && submitPreset.properties) { Object.keys(submitPreset.properties).forEach((k: string) => { if (this.form.contains(k)) { feature.properties[k] = this.form.get(k)?.value; } }); } // Ajouter les propriétés extra (non définies par le preset) for (const k of this.extraPropertyKeys()) { if (this.form.contains(k)) { feature.properties[k] = this.form.get(k)?.value; } } this.status.set({ state: 'saving', what: val.what, message: 'Envoi en cours…' }); if (id !== null && id !== undefined && id !== '') { this.api.updateEvent(id, feature).subscribe({ next: (res) => { this.status.set({ state: 'saved', what: val.what, message: 'Évènement mis à jour' }); this.saved.emit(res); // Quitter l'édition après succès this.canceled.emit(); setTimeout(() => this.status.set({ state: 'idle' }), 3000); }, error: (err) => { this.status.set({ state: 'error', what: val.what, message: 'Erreur lors de la mise à jour' }); console.error(err); setTimeout(() => this.status.set({ state: 'idle' }), 3000); } }); } else { this.api.createEvent(feature).subscribe({ next: (res) => { this.status.set({ state: 'saved', what: val.what, message: 'Évènement créé' }); this.created.emit(res); // Quitter l'édition après succès this.canceled.emit(); setTimeout(() => this.status.set({ state: 'idle' }), 3000); }, error: (err) => { this.status.set({ state: 'error', what: val.what, message: 'Erreur lors de la création' }); console.error(err); setTimeout(() => this.status.set({ state: 'idle' }), 3000); } }); } } onPresetValueChange(key: string, value: any) { // Conservé pour compat, plus utilisé avec formControlName if (this.form.contains(key)) this.form.get(key)?.setValue(value); } onDelete() { const id = this.featureId(); if (id === null || id === undefined || id === '') return; this.status.set({ state: 'saving', what: this.form.value.what, message: 'Suppression…' }); this.api.deleteEvent(id).subscribe({ next: (res) => { this.status.set({ state: 'saved', what: this.form.value.what, message: 'Évènement supprimé' }); this.deleted.emit(res); setTimeout(() => this.status.set({ state: 'idle' }), 3000); }, error: (err) => { this.status.set({ state: 'error', what: this.form.value.what, message: 'Erreur lors de la suppression' }); console.error(err); setTimeout(() => this.status.set({ state: 'idle' }), 3000); } }); } onWhatKeyup() { // Le filtrage se base déjà sur filteredGroups() qui lit form.what, // donc un keyup déclenche le recalcul via Angular forms. // On garde cette méthode pour des actions annexes si besoin. } filteredPresetCount() { try { const groups = this.filteredGroups(); return groups.reduce((acc, g) => acc + (g.items?.length || 0), 0); } catch { return 0; } } resetPresetFilter() { // Réinitialise le champ what pour afficher tous les presets this.form.patchValue({ what: '' }); } onCancelEdit() { this.selected = null; this.featureId.set(null); this.form.reset({ label: '', description: '', what: '', where: '', lat: '', lon: '', wikidata: '', featureType: 'point', type: 'unscheduled', start: this.toLocalInputValue(new Date()), stop: this.toLocalInputValue(new Date(new Date().getTime() + 24 * 3600 * 1000)) }); this.presetValues.set({}); this.status.set({ state: 'idle' }); this.canceled.emit(); } private toLocalInputValue(d: string | Date): string { const date = (typeof d === 'string') ? new Date(d) : d; if (Number.isNaN(date.getTime())) return ''; const pad = (n: number) => n.toString().padStart(2, '0'); const y = date.getFullYear(); const m = pad(date.getMonth() + 1); const da = pad(date.getDate()); const h = pad(date.getHours()); const mi = pad(date.getMinutes()); return `${y}-${m}-${da}T${h}:${mi}`; } private toIsoFromLocalInput(s?: string): string | null { if (!s) return null; // Treat input as local time and convert to ISO const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})$/.exec(s); if (!m) return null; const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), 0, 0); return date.toISOString(); } private ensureControl(key: string, initial: any) { if (!this.form.contains(key)) { this.form.addControl(key, new FormControl(initial)); } else { // Mettre à jour sans émettre pour éviter des boucles this.form.get(key)?.setValue(initial, { emitEvent: false }); } } private humanDuration(startIso: string, stopIso: string): string { const a = new Date(startIso).getTime(); const b = new Date(stopIso).getTime(); if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) return ''; const ms = b - a; const hours = Math.floor(ms / 3600000); const days = Math.floor(hours / 24); const h = hours % 24; if (days > 0 && h > 0) return `${days} j ${h} h`; if (days > 0) return `${days} j`; return `${h} h`; } }