diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py
index 3d6e8af4cf..45e262213f 100644
--- a/backend/geonature/utils/config_schema.py
+++ b/backend/geonature/utils/config_schema.py
@@ -204,6 +204,32 @@ def post_load(self, data, **kwargs):
return data
+class ListLastObsColumnConfig(Schema):
+ prop = fields.String(required=True)
+ name = fields.String(required=True)
+
+
+class ListLastObsFiltersConfig(Schema):
+ TAXONOMY_GROUP2_INPN = fields.Boolean(load_default=True)
+ TAXONOMY_GROUP3_INPN = fields.Boolean(load_default=True)
+
+
+class ListLastObsConfig(Schema):
+
+ COLUMNS = fields.List(
+ fields.Nested(ListLastObsColumnConfig),
+ load_default=[
+ {"prop": "nom_vern_or_lb_nom", "name": "Taxon"},
+ {"prop": "date_min", "name": "Date"},
+ {"prop": "observers", "name": "Observateur"},
+ ],
+ )
+ FILTERS = fields.Nested(
+ ListLastObsFiltersConfig,
+ load_default=ListLastObsFiltersConfig().load({}),
+ )
+
+
class GnPySchemaConf(Schema):
SQLALCHEMY_DATABASE_URI = fields.String(
required=True,
@@ -281,6 +307,11 @@ class GnFrontEndConf(Schema):
DISPLAY_STAT_BLOC = fields.Boolean(load_default=True)
STAT_BLOC_TTL = fields.Integer(load_default=3600)
DISPLAY_MAP_LAST_OBS = fields.Boolean(load_default=True)
+ DISPLAY_LIST_LAST_OBS = fields.Boolean(load_default=False)
+ LIST_LAST_OBS_CONFIG = fields.Nested(
+ ListLastObsConfig,
+ load_default=ListLastObsConfig().load({}),
+ )
MULTILINGUAL = fields.Boolean(load_default=False)
ENABLE_PROFILES = fields.Boolean(load_default=True)
diff --git a/config/default_config.toml.example b/config/default_config.toml.example
index a198e1d4e2..f5425832be 100644
--- a/config/default_config.toml.example
+++ b/config/default_config.toml.example
@@ -131,6 +131,9 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *"
# Affiche la carte des 100 dernieres observations sur la page d'accueil
DISPLAY_MAP_LAST_OBS = true
+ # Affiche la liste des 100 dernieres observations sur la page d'accueil
+ # Si DISPLAY_MAP_LAST_OBS = true, la carte reste prioritaire sur la liste
+ DISPLAY_LIST_LAST_OBS = false
# Affiche le selecteur de langue dans l'interface
MULTILINGUAL = false
@@ -146,6 +149,19 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *"
# Activer l'affichage des informations liées aux profils de taxons (dans les modules Validation, Synthèse et Occtax)
ENABLE_PROFILES = true
+[FRONTEND.LIST_LAST_OBS_CONFIG]
+ # Colonnes affichées dans la liste des dernières observations
+ # Les colonnes doivent exister dans SYNTHESE.LIST_COLUMNS_FRONTEND
+ # ou SYNTHESE.ADDITIONAL_COLUMNS_FRONTEND, sinon elles ne pourront pas être prises en compte.
+ COLUMNS = [
+ { prop = "nom_vern_or_lb_nom", name = "Taxon" },
+ { prop = "date_min", name = "Date" },
+ { prop = "observers", name = "Observateur" },
+ ]
+
+[FRONTEND.LIST_LAST_OBS_CONFIG.FILTERS]
+ TAXONOMY_GROUP2_INPN = true
+ TAXONOMY_GROUP3_INPN = true
# Afficher l'organisme de l'utilisateur dans la barre de navigation (true / false)
DISPLAY_USER_ORGANISM = true
diff --git a/frontend/cypress/fixtures/config.json b/frontend/cypress/fixtures/config.json
index 1a639828d3..080aff0f45 100644
--- a/frontend/cypress/fixtures/config.json
+++ b/frontend/cypress/fixtures/config.json
@@ -197,7 +197,8 @@
"DISPLAY_EMAIL_INFO_OBS": true,
"DISPLAY_STAT_BLOC": true,
"PROD_MOD": true,
- "DISPLAY_MAP_LAST_OBS": true
+ "DISPLAY_MAP_LAST_OBS": true,
+ "DISPLAY_LIST_LAST_OBS": false
},
"DEFAULT_LANGUAGE": "fr",
"ACCOUNT_MANAGEMENT": {
diff --git a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.html b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.html
index 31b396e15c..206e3e6230 100644
--- a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.html
+++ b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.html
@@ -1,10 +1,28 @@
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.scss b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.scss
index 1241f28ed1..6d3838c90a 100644
--- a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.scss
+++ b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.scss
@@ -1,10 +1,29 @@
+.LoadableLayout {
+ position: relative;
+ display: flex;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+
+ &__content {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ }
+}
+
.SpinnerContainer {
- // max-height: 100%;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
- z-index: 10; /* Ensure the spinner is above other content */
flex-direction: column;
+
+ &--overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 10;
+ background: rgba(255, 255, 255, 0.65);
+ }
}
diff --git a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.ts b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.ts
index 736c1d90aa..caf0ee100e 100644
--- a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.ts
+++ b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.ts
@@ -1,13 +1,22 @@
import { Component, Input } from '@angular/core';
+export enum LoadableLayoutMode {
+ Overlay = 'overlay',
+ Replace = 'replace',
+}
+
@Component({
selector: 'gn-loadable-layout',
templateUrl: 'loadable-layout.component.html',
styleUrls: ['loadable-layout.component.scss'],
})
export class LoadableLayoutComponent {
+ readonly LoadableLayoutMode = LoadableLayoutMode;
+
@Input()
isLoading: boolean = false;
@Input()
message: string = null;
+ @Input()
+ mode: LoadableLayoutMode = LoadableLayoutMode.Overlay;
}
diff --git a/frontend/src/app/GN2CommonModule/map/geojson/geojson.component.ts b/frontend/src/app/GN2CommonModule/map/geojson/geojson.component.ts
index fbf78854fe..7baed376d6 100644
--- a/frontend/src/app/GN2CommonModule/map/geojson/geojson.component.ts
+++ b/frontend/src/app/GN2CommonModule/map/geojson/geojson.component.ts
@@ -89,12 +89,22 @@ export class GeojsonComponent implements OnInit, OnChanges {
}
}
if (changes.style && changes.style.currentValue !== undefined) {
- if (this.currentGeojson) {
- for (const key of Object.keys(this.currentGeojson['_layers'])) {
- const layer = this.currentGeojson['_layers'][key];
- layer.setStyle(changes.style.currentValue);
- }
+ this.applyStyle(changes.style.currentValue);
+ }
+ }
+
+ private applyStyle(style) {
+ if (!this.currentGeojson || !this.currentGeojson['_layers']) {
+ return;
+ }
+
+ for (const key of Object.keys(this.currentGeojson['_layers'])) {
+ const layer = this.currentGeojson['_layers'][key];
+ if (!layer.setStyle) {
+ continue;
}
+
+ layer.setStyle(style);
}
}
}
diff --git a/frontend/src/app/GN2CommonModule/map/map.service.ts b/frontend/src/app/GN2CommonModule/map/map.service.ts
index 563c1f4d8b..e681eff90a 100644
--- a/frontend/src/app/GN2CommonModule/map/map.service.ts
+++ b/frontend/src/app/GN2CommonModule/map/map.service.ts
@@ -175,7 +175,6 @@ export class MapService {
const geojsonLayer = L.geoJSON(geojson?.features || geojson, {
style: (feature) => {
switch (feature.geometry.type) {
- // No color nor opacity for linestrings
case 'LineString':
return style || this.lineStyle();
default:
@@ -183,7 +182,7 @@ export class MapService {
}
},
pointToLayer: (feature, latlng) => {
- return L.circleMarker(latlng);
+ return L.circleMarker(latlng, style || this.defaultStyle());
},
onEachFeature: onEachFeature,
});
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 933ea3c369..5a5751c2bf 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -17,6 +17,7 @@ import { GN2CommonModule } from '@geonature_common/GN2Common.module';
import { AppComponent } from './app.component';
import { routing } from './routing/app-routing.module'; // RoutingModule
import { HomeContentComponent } from './components/home-content/home-content.component';
+import { HomeContentListObsComponent } from './components/home-content/home-content-list-obs/home-content-list-obs.component';
import { HomeDiscussionsComponent } from './components/home-content/home-discussions/home-discussions.component';
import { HomeValidationsComponent } from './components/home-content/home-validations/home-validations.component';
@@ -107,6 +108,7 @@ export function initApp(injector) {
extend: true,
}),
LoginModule,
+ HomeContentListObsComponent,
HomeDiscussionsComponent,
HomeValidationsComponent,
],
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.html b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.html
new file mode 100644
index 0000000000..dc871b0d0d
--- /dev/null
+++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.html
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.scss b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.scss
new file mode 100644
index 0000000000..9a229df333
--- /dev/null
+++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.scss
@@ -0,0 +1,48 @@
+.home-content-list-obs-filters {
+ display: flex;
+ flex-flow: row nowrap;
+ gap: 0.5rem;
+ align-items: center;
+
+ ng-select {
+ width: 200px !important;
+ font-size: 0.875rem;
+ }
+
+ ::ng-deep ng-select .ng-select-container {
+ min-height: 34px;
+ padding: 0 0.35rem;
+ }
+
+ ::ng-deep ng-select .ng-value-container {
+ padding-left: 0.1rem;
+ }
+
+ ::ng-deep ng-select .ng-input {
+ top: 6px;
+ padding-left: 0.1rem;
+ }
+
+ ::ng-deep ng-select .ng-placeholder,
+ ::ng-deep ng-select .ng-value-label,
+ ::ng-deep ng-select .ng-input input {
+ font-size: 0.875rem;
+ line-height: 1.2;
+ }
+
+ ::ng-deep ng-select .ng-clear-wrapper,
+ ::ng-deep ng-select .ng-arrow-wrapper {
+ padding-left: 0.3rem;
+ padding-right: 0.1rem;
+ }
+
+ ::ng-deep ng-dropdown-panel .ng-dropdown-panel-items .ng-option {
+ white-space: nowrap;
+ }
+
+ ::ng-deep ng-dropdown-panel .ng-dropdown-panel-items .ng-option span {
+ white-space: nowrap;
+ overflow: visible;
+ text-overflow: clip;
+ }
+}
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.ts b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.ts
new file mode 100644
index 0000000000..3ba98f7f97
--- /dev/null
+++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.ts
@@ -0,0 +1,129 @@
+import { CommonModule } from '@angular/common';
+import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
+import { UntypedFormControl, ReactiveFormsModule } from '@angular/forms';
+import { DataFormService } from '@geonature_common/form/data-form.service';
+import { ConfigService } from '@geonature/services/config.service';
+import { NgSelectModule } from '@ng-select/ng-select';
+import { Subject } from 'rxjs';
+import { map, takeUntil } from 'rxjs/operators';
+
+interface HomeContentListObsFilters {
+ taxonomy_group2_inpn?: string[];
+ taxonomy_group3_inpn?: string[];
+ [key: string]: string[] | undefined;
+}
+
+interface HomeContentListObsFilterOption {
+ value: string;
+}
+
+@Component({
+ standalone: true,
+ selector: 'pnx-home-content-list-obs-filters',
+ templateUrl: './home-content-list-obs-filters.component.html',
+ styleUrls: ['./home-content-list-obs-filters.component.scss'],
+ imports: [CommonModule, ReactiveFormsModule, NgSelectModule],
+})
+export class HomeContentListObsFiltersComponent implements OnInit, OnDestroy {
+ @Output() filtersChange = new EventEmitter();
+
+ readonly group2InpnControl = new UntypedFormControl(null);
+ readonly group3InpnControl = new UntypedFormControl(null);
+ group2InpnOptions: HomeContentListObsFilterOption[] = [];
+ group3InpnOptions: HomeContentListObsFilterOption[] = [];
+
+ private destroy$ = new Subject();
+
+ constructor(
+ private readonly dataFormService: DataFormService,
+ private readonly config: ConfigService
+ ) {}
+
+ get showGroup2Filter(): boolean {
+ return this.config.FRONTEND?.LIST_LAST_OBS_CONFIG?.FILTERS?.TAXONOMY_GROUP2_INPN ?? true;
+ }
+
+ get showGroup3Filter(): boolean {
+ return this.config.FRONTEND?.LIST_LAST_OBS_CONFIG?.FILTERS?.TAXONOMY_GROUP3_INPN ?? true;
+ }
+
+ ngOnInit() {
+ if (this.showGroup2Filter) {
+ this.dataFormService
+ .getRegneAndGroup2Inpn()
+ .pipe(
+ map((data) => {
+ const allGroups = new Set();
+
+ Object.values(data ?? {}).forEach((groups: string[]) => {
+ groups.forEach((group) => {
+ if (group) {
+ allGroups.add(group);
+ }
+ });
+ });
+
+ return Array.from(allGroups)
+ .sort((a, b) => a.localeCompare(b))
+ .map((value) => ({ value }));
+ }),
+ takeUntil(this.destroy$)
+ )
+ .subscribe((options) => {
+ this.group2InpnOptions = options;
+ });
+ }
+
+ if (this.showGroup3Filter) {
+ this.dataFormService
+ .getGroup3Inpn()
+ .pipe(
+ map((data) =>
+ (data ?? [])
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
+ .sort((a, b) => a.localeCompare(b))
+ .map((value) => ({ value }))
+ ),
+ takeUntil(this.destroy$)
+ )
+ .subscribe((options) => {
+ this.group3InpnOptions = options;
+ });
+ }
+
+ this.group2InpnControl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
+ this._emitFilters();
+ });
+
+ this.group3InpnControl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
+ this._emitFilters();
+ });
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private _emitFilters() {
+ const filters: HomeContentListObsFilters = {};
+
+ if (
+ this.showGroup2Filter &&
+ typeof this.group2InpnControl.value === 'string' &&
+ this.group2InpnControl.value.length > 0
+ ) {
+ filters.taxonomy_group2_inpn = [this.group2InpnControl.value];
+ }
+
+ if (
+ this.showGroup3Filter &&
+ typeof this.group3InpnControl.value === 'string' &&
+ this.group3InpnControl.value.length > 0
+ ) {
+ filters.taxonomy_group3_inpn = [this.group3InpnControl.value];
+ }
+
+ this.filtersChange.emit(filters);
+ }
+}
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.html b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.html
new file mode 100644
index 0000000000..30f2239b3e
--- /dev/null
+++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+ {{ getCellValue(row, column.prop) }}
+
+
+
+
+
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.scss b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.scss
new file mode 100644
index 0000000000..23140ce09b
--- /dev/null
+++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.scss
@@ -0,0 +1,128 @@
+:host {
+ display: flex;
+ min-height: 0;
+}
+
+:host ::ng-deep gn-loadable-layout {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+:host ::ng-deep .ngx-datatable.material {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+ font-size: 0.9rem;
+}
+
+:host ::ng-deep .ngx-datatable.material .datatable-header {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+}
+
+:host ::ng-deep .ngx-datatable.material .datatable-header .datatable-header-inner {
+ display: flex;
+ align-items: center;
+ min-height: 100%;
+}
+
+:host ::ng-deep .ngx-datatable.material .datatable-header .datatable-header-cell {
+ padding: 0.35rem 0.5rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+}
+
+:host
+ ::ng-deep
+ .ngx-datatable.material
+ .datatable-header
+ .datatable-header-cell
+ .datatable-header-cell-template-wrap {
+ display: flex;
+ align-items: center;
+ min-height: 100%;
+}
+
+:host ::ng-deep .ngx-datatable.material .datatable-body .datatable-body-row {
+ cursor: pointer;
+}
+
+:host
+ ::ng-deep
+ .ngx-datatable.material
+ .datatable-body
+ .datatable-body-row.is-selected
+ .datatable-row-group {
+ background-color: rgb(117, 227, 118) !important;
+}
+
+:host ::ng-deep .ngx-datatable.material .datatable-body .datatable-body-row .datatable-body-cell {
+ padding: 0.25rem 0.5rem;
+ line-height: 1.2;
+ display: flex;
+ align-items: center;
+}
+
+:host ::ng-deep .ngx-datatable.material .datatable-footer {
+ padding-top: 0;
+ flex: 0 0 auto;
+}
+
+::ng-deep .ngx-datatable {
+ height: 100%;
+ box-shadow: none !important;
+ ::ng-deep .visible {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ .datatable-body {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+ }
+}
+
+.TaxonCell {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+
+ &__thumbnail {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ flex: 0 0 2rem;
+
+ &Image {
+ width: 2rem;
+ height: 2rem;
+ object-fit: cover;
+ border-radius: 0.25rem;
+ }
+ }
+
+ &__label {
+ min-width: 0;
+ }
+
+ &__link {
+ min-width: 0;
+ color: var(--purple);
+ filter: brightness(70%);
+ text-decoration: none;
+
+ &:hover .TaxonCell__label {
+ text-decoration: underline;
+ }
+ }
+}
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.ts b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.ts
new file mode 100644
index 0000000000..bc221cb917
--- /dev/null
+++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.ts
@@ -0,0 +1,157 @@
+import { CommonModule } from '@angular/common';
+import {
+ Component,
+ EventEmitter,
+ Input,
+ OnChanges,
+ OnDestroy,
+ Output,
+ SimpleChanges,
+} from '@angular/core';
+import { RouterModule } from '@angular/router';
+import { GN2CommonModule } from '@geonature_common/GN2Common.module';
+import { ConfigService } from '@geonature/services/config.service';
+import { DataFormService } from '@geonature_common/form/data-form.service';
+import { getTaxonSheetRoute } from '@geonature/syntheseModule/taxon-sheet/taxon-sheet.route.service';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+interface HomeContentListObservationItem {
+ id_synthese: number;
+ cd_nom?: number | null;
+ nom_vern_or_lb_nom: string;
+ date_min: string | null;
+ observers: string | null;
+ [key: string]: unknown;
+}
+
+interface HomeContentListObservationColumn {
+ name: string;
+ prop: string;
+}
+
+@Component({
+ standalone: true,
+ selector: 'pnx-home-content-list-obs-list',
+ templateUrl: './home-content-list-obs-list.component.html',
+ styleUrls: ['./home-content-list-obs-list.component.scss'],
+ imports: [GN2CommonModule, CommonModule, RouterModule],
+})
+export class HomeContentListObsListComponent implements OnChanges, OnDestroy {
+ readonly pageSize = 9;
+
+ tableRows: HomeContentListObservationItem[] = [];
+ private readonly _destroy$ = new Subject();
+ private readonly _taxonThumbnailUrls = new Map();
+ private readonly _loadingTaxonThumbnailIds = new Set();
+ @Input() observations: HomeContentListObservationItem[] = [];
+ @Input() isLoading = false;
+ @Input() currentPage = 0;
+ @Input() selectedObservationId: number | null = null;
+ @Output() pageChange = new EventEmitter();
+ @Output() observationSelect = new EventEmitter();
+
+ constructor(
+ private readonly config: ConfigService,
+ private readonly _dataFormService: DataFormService
+ ) {}
+
+ ngOnChanges(changes: SimpleChanges) {
+ if (changes.observations || changes.selectedObservationId) {
+ this.tableRows = [...this.observations];
+ this._prefetchTaxonThumbnails();
+ }
+ }
+
+ ngOnDestroy() {
+ this._destroy$.next();
+ this._destroy$.complete();
+ }
+
+ get columns(): HomeContentListObservationColumn[] {
+ const configuredColumns = this.config.FRONTEND?.LIST_LAST_OBS_CONFIG?.COLUMNS;
+ return Array.isArray(configuredColumns) ? configuredColumns : [];
+ }
+
+ onPage(event: any) {
+ this.pageChange.emit(event.offset);
+ }
+
+ onActivate(event: any) {
+ if (event.type !== 'click' || !event.row?.id_synthese) {
+ return;
+ }
+
+ this.observationSelect.emit(event.row.id_synthese);
+ }
+
+ getRowClass = (row: HomeContentListObservationItem) => {
+ return {
+ 'is-selected': row.id_synthese === this.selectedObservationId,
+ };
+ };
+
+ hasTaxonThumbnail(row: HomeContentListObservationItem): boolean {
+ return typeof row.cd_nom === 'number' && !!this._taxonThumbnailUrls.get(row.cd_nom);
+ }
+
+ getTaxonThumbnailUrl(row: HomeContentListObservationItem): string {
+ return typeof row.cd_nom === 'number' ? (this._taxonThumbnailUrls.get(row.cd_nom) ?? '') : '';
+ }
+
+ private _prefetchTaxonThumbnails() {
+ const taxonIds = new Set(
+ this.tableRows
+ .map((row) => row.cd_nom)
+ .filter((cdNom): cdNom is number => typeof cdNom === 'number')
+ );
+
+ taxonIds.forEach((cdNom) => {
+ if (this._taxonThumbnailUrls.has(cdNom) || this._loadingTaxonThumbnailIds.has(cdNom)) {
+ return;
+ }
+
+ this._loadingTaxonThumbnailIds.add(cdNom);
+ this._dataFormService
+ .getTaxonInfo(cdNom, ['medias', 'cd_nom'])
+ .pipe(takeUntil(this._destroy$))
+ .subscribe({
+ next: (taxonAttrAndMedias) => {
+ const media = taxonAttrAndMedias['medias']?.find(
+ (m) => m.id_type == this.config.TAXHUB.ID_TYPE_MAIN_PHOTO
+ );
+ const mediaUrl = media
+ ? `${this._dataFormService.getTaxhubAPI()}/tmedias/thumbnail/${media.id_media}?h=96&w=96`
+ : null;
+ this._taxonThumbnailUrls.set(cdNom, mediaUrl);
+ this._loadingTaxonThumbnailIds.delete(cdNom);
+ },
+ error: () => {
+ this._taxonThumbnailUrls.set(cdNom, null);
+ this._loadingTaxonThumbnailIds.delete(cdNom);
+ },
+ });
+ });
+ }
+
+ getCellValue(row: HomeContentListObservationItem, prop: string): string | number {
+ if (prop === 'date_min') {
+ return this.renderDate(row.date_min);
+ }
+
+ const value = row[prop];
+ return typeof value === 'string' || typeof value === 'number' ? value : '';
+ }
+
+ renderDate(date: string | null): string {
+ return date ? new Date(date).toLocaleDateString() : '';
+ }
+
+ hasTaxonSheetLink(row: HomeContentListObservationItem): boolean {
+ return this.config.SYNTHESE?.ENABLE_TAXON_SHEETS && typeof row.cd_nom === 'number';
+ }
+
+ getTaxonSheetUrl(cdNom: number): [string] {
+ return getTaxonSheetRoute(cdNom);
+ }
+}
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.html b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.html
new file mode 100644
index 0000000000..1cf937ef18
--- /dev/null
+++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ 0"
+ [zoomOnFirstTime]="true"
+ >
+
+
+
+
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.scss b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.scss
new file mode 100644
index 0000000000..bbdd3567bc
--- /dev/null
+++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.scss
@@ -0,0 +1,48 @@
+.home-content-list-obs {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ height: 100%;
+ min-height: 0;
+ padding: 0.5rem;
+
+ &-content {
+ display: flex;
+ flex-flow: row wrap;
+ gap: 0.1rem;
+ justify-content: space-between;
+ flex: 1 1 auto;
+ min-height: 0;
+ &-list {
+ flex-grow: 1;
+ flex-shrink: 1;
+ min-width: 250px;
+ width: 250px;
+ min-height: 0;
+ }
+
+ &-map {
+ margin-top: 0.3rem;
+ flex-grow: 1;
+ flex-shrink: 1;
+ height: 100%;
+ min-width: 250px;
+ width: 250px;
+ border: 1px solid #dfe3e8;
+ border-radius: 4px;
+ overflow: hidden;
+ }
+ }
+}
+
+// @media (max-width: 991.98px) {
+// .home-content-list-obs {
+// &-content {
+// flex-direction: column;
+// }
+
+// &-map {
+// min-height: 320px;
+// }
+// }
+// }
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.ts b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.ts
new file mode 100644
index 0000000000..cea5fe598b
--- /dev/null
+++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.ts
@@ -0,0 +1,365 @@
+import { CommonModule } from '@angular/common';
+import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
+import { GN2CommonModule } from '@geonature_common/GN2Common.module';
+import { DataFormService } from '@geonature_common/form/data-form.service';
+import { SyntheseDataService } from '@geonature_common/form/synthese-form/synthese-data.service';
+import { GeojsonComponent } from '@geonature_common/map/geojson/geojson.component';
+import { MapService } from '@geonature_common/map/map.service';
+import { Feature, FeatureCollection, Geometry } from 'geojson';
+import { Layer } from 'leaflet';
+import { Subject } from 'rxjs';
+import { map, takeUntil } from 'rxjs/operators';
+import { HomeContentListObsFiltersComponent } from './home-content-list-obs-filters/home-content-list-obs-filters.component';
+import { HomeContentListObsListComponent } from './home-content-list-obs-list/home-content-list-obs-list.component';
+
+interface HomeContentListObservationItem {
+ id_synthese: number;
+ cd_nom?: number | null;
+ nom_vern_or_lb_nom: string;
+ date_min: string | null;
+ observers: string | null;
+ geometry?: Geometry;
+}
+
+type HomeContentListObservationFeature = Feature;
+type HomeContentListObservationFeatureCollection = FeatureCollection<
+ Geometry,
+ HomeContentListObservationItem
+>;
+
+interface HomeContentListObsFilters {
+ taxonomy_group2_inpn?: string[];
+ taxonomy_group3_inpn?: string[];
+}
+
+@Component({
+ standalone: true,
+ selector: 'pnx-home-content-list-obs',
+ templateUrl: './home-content-list-obs.component.html',
+ styleUrls: ['./home-content-list-obs.component.scss'],
+ imports: [
+ CommonModule,
+ GN2CommonModule,
+ HomeContentListObsFiltersComponent,
+ HomeContentListObsListComponent,
+ ],
+})
+export class HomeContentListObsComponent implements OnInit, OnDestroy {
+ @ViewChild(GeojsonComponent) private _geojsonComponent?: GeojsonComponent;
+
+ readonly pageSize = 9;
+ readonly defaultMapFeatureStyle = {
+ color: '#3388FF',
+ weight: 3,
+ fill: false,
+ radius: 6,
+ };
+ readonly selectedMapFeatureStyle = {
+ color: '#FF0000',
+ weight: 3,
+ fill: false,
+ radius: 6,
+ };
+
+ observations: HomeContentListObservationItem[] = [];
+ observationsGeoJson: HomeContentListObservationFeatureCollection = {
+ type: 'FeatureCollection',
+ features: [],
+ };
+ isLoading = false;
+ filters: HomeContentListObsFilters = {};
+ currentPage = 0;
+ selectedObservationId: number | null = null;
+
+ private featureLayers = new Map();
+ private selectedLayer: Layer | null = null;
+ private taxonThumbnailUrls = new Map();
+ private loadingTaxonThumbnailIds = new Set();
+ private destroy$ = new Subject();
+
+ constructor(
+ private _syntheseDataService: SyntheseDataService,
+ private _dataFormService: DataFormService,
+ private _mapService: MapService,
+ private _router: Router
+ ) {}
+
+ ngOnInit() {
+ this._fetchObservations();
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ onFiltersChange(filters: HomeContentListObsFilters) {
+ this.filters = filters;
+ this.currentPage = 0;
+ this._clearSelection(false);
+ this._fetchObservations();
+ }
+
+ onPageChange(offset: number) {
+ this.currentPage = offset;
+ }
+
+ onObservationSelect(idSynthese: number) {
+ if (this.selectedObservationId === idSynthese) {
+ this._clearSelection(true);
+ return;
+ }
+
+ this._showObservationPage(idSynthese);
+ this._selectObservation(idSynthese, true, true);
+ }
+
+ readonly onEachFeature = (feature: HomeContentListObservationFeature, layer: Layer) => {
+ const idSynthese = feature.properties.id_synthese;
+ this.featureLayers.set(idSynthese, layer);
+ this._applyStyleToLayer(layer, this.defaultMapFeatureStyle);
+
+ if ('bindPopup' in layer && typeof layer.bindPopup === 'function') {
+ layer.bindPopup(this._buildPopupContent(feature.properties));
+ }
+
+ if (idSynthese === this.selectedObservationId) {
+ this.selectedLayer = layer;
+ this._applyStyleToLayer(layer, this.selectedMapFeatureStyle);
+ }
+
+ if ('on' in layer && typeof layer.on === 'function') {
+ layer.on('click', () => {
+ if (this.selectedObservationId === idSynthese) {
+ this._clearSelection(true);
+ return;
+ }
+
+ this._showObservationPage(idSynthese);
+ this._selectObservation(idSynthese, false, false);
+ });
+ }
+ };
+
+ private _fetchObservations() {
+ this.isLoading = true;
+ this.featureLayers.clear();
+ this.selectedLayer = null;
+ this._syntheseDataService
+ .getSyntheseData(this.filters, { limit: 100, format: 'ungrouped_geom' })
+ .pipe(
+ map((data) =>
+ ((data?.features ?? []) as HomeContentListObservationFeature[]).sort((a, b) => {
+ const aTime = a.properties?.date_min ? new Date(a.properties.date_min).getTime() : 0;
+ const bTime = b.properties?.date_min ? new Date(b.properties.date_min).getTime() : 0;
+ return bTime - aTime;
+ })
+ ),
+ takeUntil(this.destroy$)
+ )
+ .subscribe({
+ next: (features: HomeContentListObservationFeature[]) => {
+ this.observations = features.map((feature) => ({
+ ...feature.properties,
+ geometry: feature.geometry ?? undefined,
+ }));
+ this.observationsGeoJson = {
+ type: 'FeatureCollection',
+ features: features.filter((feature) => !!feature.geometry),
+ };
+ this._prefetchTaxonThumbnails();
+ this.isLoading = false;
+ },
+ error: () => {
+ this.observations = [];
+ this.observationsGeoJson = {
+ type: 'FeatureCollection',
+ features: [],
+ };
+ this.isLoading = false;
+ },
+ });
+ }
+
+ private _prefetchTaxonThumbnails() {
+ const taxonIds = new Set(
+ this.observations
+ .map((observation) => observation.cd_nom)
+ .filter((cdNom): cdNom is number => typeof cdNom === 'number')
+ );
+
+ taxonIds.forEach((cdNom) => {
+ if (this.taxonThumbnailUrls.has(cdNom) || this.loadingTaxonThumbnailIds.has(cdNom)) {
+ return;
+ }
+
+ this.loadingTaxonThumbnailIds.add(cdNom);
+ this._dataFormService
+ .getTaxonInfo(cdNom, ['medias', 'cd_nom'])
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: (taxonAttrAndMedias) => {
+ const media = taxonAttrAndMedias['medias']?.find(
+ (m) => m.id_type == this._syntheseDataService.config.TAXHUB.ID_TYPE_MAIN_PHOTO
+ );
+ const mediaUrl = media
+ ? `${this._dataFormService.getTaxhubAPI()}/tmedias/thumbnail/${media.id_media}?h=96&w=96`
+ : null;
+ this.taxonThumbnailUrls.set(cdNom, mediaUrl);
+ this.loadingTaxonThumbnailIds.delete(cdNom);
+ this._refreshPopupContentsForTaxon(cdNom);
+ },
+ error: () => {
+ this.taxonThumbnailUrls.set(cdNom, null);
+ this.loadingTaxonThumbnailIds.delete(cdNom);
+ },
+ });
+ });
+ }
+
+ private _refreshPopupContentsForTaxon(cdNom: number) {
+ this.observations
+ .filter((observation) => observation.cd_nom === cdNom)
+ .forEach((observation) => {
+ const layer = this.featureLayers.get(observation.id_synthese);
+ if (
+ !layer ||
+ !('setPopupContent' in layer) ||
+ typeof layer.setPopupContent !== 'function'
+ ) {
+ return;
+ }
+
+ layer.setPopupContent(this._buildPopupContent(observation));
+ });
+ }
+
+ private _showObservationPage(idSynthese: number) {
+ const observationIndex = this.observations.findIndex(
+ (observation) => observation.id_synthese === idSynthese
+ );
+ if (observationIndex < 0) {
+ return;
+ }
+
+ this.currentPage = Math.floor(observationIndex / this.pageSize);
+ }
+
+ private _clearSelection(shouldClosePopup: boolean) {
+ this.selectedObservationId = null;
+
+ if (!this.selectedLayer) {
+ return;
+ }
+
+ this._applyStyleToLayer(this.selectedLayer, this.defaultMapFeatureStyle);
+
+ if (
+ shouldClosePopup &&
+ 'closePopup' in this.selectedLayer &&
+ typeof this.selectedLayer.closePopup === 'function'
+ ) {
+ this.selectedLayer.closePopup();
+ }
+
+ this.selectedLayer = null;
+ }
+
+ private _selectObservation(
+ idSynthese: number,
+ shouldOpenPopup: boolean,
+ shouldCenterMap: boolean
+ ) {
+ this.selectedObservationId = idSynthese;
+
+ if (this.selectedLayer) {
+ this._applyStyleToLayer(this.selectedLayer, this.defaultMapFeatureStyle);
+ }
+
+ const layer = this.featureLayers.get(idSynthese);
+ if (!layer) {
+ this.selectedLayer = null;
+ return;
+ }
+
+ this.selectedLayer = layer;
+ this._applyStyleToLayer(layer, this.selectedMapFeatureStyle);
+ this._focusLayer(layer, shouldOpenPopup, shouldCenterMap);
+ }
+
+ private _applyStyleToLayer(layer: Layer, style) {
+ if ('setStyle' in layer && typeof layer.setStyle === 'function') {
+ layer.setStyle(style);
+ }
+ }
+
+ private _focusLayer(layer: Layer, shouldOpenPopup: boolean, shouldCenterMap: boolean) {
+ const currentGeojson = this._geojsonComponent?.currentGeojson as any;
+
+ const finalizeFocus = () => {
+ if (shouldCenterMap) {
+ this._centerMapOnLayer(layer);
+ }
+
+ if ('openPopup' in layer && typeof layer.openPopup === 'function' && shouldOpenPopup) {
+ layer.openPopup();
+ }
+ };
+
+ if (
+ currentGeojson &&
+ typeof currentGeojson.zoomToShowLayer === 'function' &&
+ typeof currentGeojson.hasLayer === 'function' &&
+ currentGeojson.hasLayer(layer)
+ ) {
+ currentGeojson.zoomToShowLayer(layer, finalizeFocus);
+ return;
+ }
+
+ finalizeFocus();
+ }
+
+ private _centerMapOnLayer(layer: Layer) {
+ const map = this._mapService.map;
+ if (!map) {
+ return;
+ }
+
+ if ('getLatLng' in layer && typeof layer.getLatLng === 'function') {
+ map.panTo(layer.getLatLng());
+ return;
+ }
+
+ if ('getBounds' in layer && typeof layer.getBounds === 'function') {
+ const bounds = layer.getBounds();
+ if (bounds && bounds.isValid && bounds.isValid()) {
+ map.fitBounds(bounds, { maxZoom: 14 });
+ }
+ }
+ }
+
+ private _buildPopupContent(observation: HomeContentListObservationItem): string {
+ const url = new URL(window.location.href);
+ url.hash = this._router.serializeUrl(
+ this._router.createUrlTree(['synthese', 'occurrence', observation.id_synthese, 'details'])
+ );
+
+ const thumbnailUrl =
+ typeof observation.cd_nom === 'number'
+ ? (this.taxonThumbnailUrls.get(observation.cd_nom) ?? '')
+ : '';
+
+ return `
+
+ ${thumbnailUrl ? `

` : ''}
+
+ ${observation.nom_vern_or_lb_nom ?? ''}
+ Observé le: ${observation.date_min ?? ''}
+ Par: ${observation.observers ?? ''}
+ Voir l'observation
+
+
+ `;
+ }
+}
diff --git a/frontend/src/app/components/home-content/home-content.component.html b/frontend/src/app/components/home-content/home-content.component.html
index 9b1f250e87..e920ea261c 100644
--- a/frontend/src/app/components/home-content/home-content.component.html
+++ b/frontend/src/app/components/home-content/home-content.component.html
@@ -3,14 +3,18 @@
data-qa="pnx-home-content"
>
-
+
diff --git a/frontend/src/app/components/home-content/home-content.component.scss b/frontend/src/app/components/home-content/home-content.component.scss
index cfdab54fdc..a89c2deddc 100644
--- a/frontend/src/app/components/home-content/home-content.component.scss
+++ b/frontend/src/app/components/home-content/home-content.component.scss
@@ -58,3 +58,36 @@ ngx-datatable-column {
margin-left: 1rem;
margin-right: 1rem;
}
+
+.home-content-list-obs {
+ height: 100%;
+}
+
+.home-content-side-panel {
+ display: flex;
+ min-height: 0;
+}
+
+.home-content-last-obs-panel {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+}
+
+.home-content-last-obs-body {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+@media (max-width: 991.98px) {
+ .home-content-last-obs-panel {
+ height: auto;
+ min-height: 0;
+ }
+
+ .home-content-last-obs-panel .panel-body-intro,
+ .home-content-last-obs-body {
+ overflow: visible;
+ height: auto;
+ }
+}
diff --git a/frontend/src/app/components/home-content/home-content.component.ts b/frontend/src/app/components/home-content/home-content.component.ts
index d29c3ad8a1..da0e3cf546 100644
--- a/frontend/src/app/components/home-content/home-content.component.ts
+++ b/frontend/src/app/components/home-content/home-content.component.ts
@@ -1,4 +1,4 @@
-import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core';
+import { AfterViewInit, Component, OnInit } from '@angular/core';
import { LangChangeEvent, TranslateService } from '@ngx-translate/core';
@@ -21,7 +21,6 @@ import { HomeValidationsService } from './home-validations/home-validations.serv
providers: [MapService, SyntheseDataService, HomeDiscussionsService, HomeValidationsService],
})
export class HomeContentComponent implements OnInit, AfterViewInit {
- public showLastObsMap: boolean = false;
public showGeneralStat: boolean = false;
public generalStat: any;
public locale: string;
@@ -43,9 +42,6 @@ export class HomeContentComponent implements OnInit, AfterViewInit {
let synthese_module = this._moduleService.getModule('SYNTHESE');
let synthese_read_scope = synthese_module ? synthese_module.cruved['R'] : 0;
- if (this.config.FRONTEND.DISPLAY_MAP_LAST_OBS && synthese_read_scope > 0) {
- this.showLastObsMap = true;
- }
if (this.config.FRONTEND.DISPLAY_STAT_BLOC && synthese_read_scope > 0) {
this.showGeneralStat = true;
}
@@ -83,6 +79,23 @@ export class HomeContentComponent implements OnInit, AfterViewInit {
return this.displayDiscussions || this.displayValidations;
}
+ get showLastObsMap(): boolean {
+ return this.hasSyntheseReadScope && this.config.FRONTEND.DISPLAY_MAP_LAST_OBS;
+ }
+
+ get showLastObsList(): boolean {
+ return (
+ this.hasSyntheseReadScope &&
+ !this.config.FRONTEND.DISPLAY_MAP_LAST_OBS &&
+ this.config.FRONTEND.DISPLAY_LIST_LAST_OBS
+ );
+ }
+
+ get hasSyntheseReadScope(): boolean {
+ const syntheseModule = this._moduleService.getModule('SYNTHESE');
+ return (syntheseModule ? syntheseModule.cruved['R'] : 0) > 0;
+ }
+
get displayDiscussions(): boolean {
return this._discussionsService.isAvailable;
}
diff --git a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs-container.component.ts b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs-container.component.ts
index f97018eb4f..d798453b86 100644
--- a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs-container.component.ts
+++ b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs-container.component.ts
@@ -4,7 +4,6 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { SyntheseInfoObsComponent } from './synthese-info-obs/synthese-info-obs.component';
-import { Location } from '@angular/common';
@Component({
selector: 'pnx-synthese-info-obs-modal-container',
@@ -16,7 +15,7 @@ export class SyntheseObsModalWrapperComponent implements OnDestroy {
dialogResult: any;
constructor(
private modalService: NgbModal,
- private location: Location,
+ private router: Router,
route: ActivatedRoute
) {
route.params.pipe(takeUntil(this.destroy)).subscribe((params) => {
@@ -34,10 +33,10 @@ export class SyntheseObsModalWrapperComponent implements OnDestroy {
// Go back to home page after the modal is closed
this.dialogResult = this.currentDialog.result.then(
(result) => {
- this.location.back();
+ this.router.navigate(['/synthese'], { replaceUrl: true });
},
(reason) => {
- this.location.back();
+ this.router.navigate(['/synthese'], { replaceUrl: true });
}
);
});
diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss
index 5535e6e40a..f8692fd4b0 100644
--- a/frontend/src/styles.scss
+++ b/frontend/src/styles.scss
@@ -377,6 +377,7 @@ ng-select.ng-invalid .ng-select-container,
display: flex;
flex-direction: column;
height: 55vh;
+ min-height: 550px;
}
.panel-body-intro {