427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
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<any>();
|
|
@Output() created = new EventEmitter<any>();
|
|
@Output() deleted = new EventEmitter<any>();
|
|
@Output() canceled = new EventEmitter<void>();
|
|
|
|
form: FormGroup;
|
|
allPresets: Array<{ key: string, label: string, emoji: string, category: string, description?: string, durationHours?: number, properties?: Record<string, { label?: string, writable?: boolean, values?: any[], default?: any, allow_custom?: boolean, allow_empty?: boolean }> }>;
|
|
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<string, any> : {};
|
|
return Object.keys(props).map(k => ({ key: k, spec: props[k] }));
|
|
});
|
|
|
|
presetValues = signal<Record<string, any>>({});
|
|
extraPropertyKeys = signal<string[]>([]);
|
|
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<string | number | null>(null);
|
|
durationHuman = signal<string>('');
|
|
|
|
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<string, any>;
|
|
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<string>(
|
|
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<string, any>;
|
|
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<string>(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<string, Array<{ key: string, label: string, emoji: string }>> = {};
|
|
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`;
|
|
}
|
|
}
|