Initial local backup snapshot

This commit is contained in:
Selecta Keke 2026-06-24 00:02:55 -03:00
commit acd9e14ba5
367 changed files with 118038 additions and 0 deletions

View file

@ -0,0 +1,108 @@
# Export annuaires CCDS + associations
Ce dossier contient les fichiers principaux pour réintégrer dans un autre projet :
- l'annuaire public des associations
- la carte publique des associations
- la fiche publique d'une association
- l'annuaire interne CCDS
## Fichiers inclus
### Frontend
- `client/src/components/CCDSInternalDirectory.tsx`
- `client/src/components/AssociationPublicMap.tsx`
- `client/src/pages/AssociationDirectoryPortal.tsx`
- `client/src/pages/AssociationMapPublic.tsx`
- `client/src/pages/AssociationPublicProfile.tsx`
### Shared
- `shared/associationCommunes.ts`
- `shared/associationGeo.ts`
- `shared/associationThematics.ts`
### Backend
- `server/associationDirectory.ts`
- `server/associationDirectoryMatcher.ts`
- `server/associationGeo.ts`
- `server/associationHelloAssoSync.ts`
## Routes utilisées dans le portail actuel
- `/associations`
- `/associations/carte`
- `/associations/:id`
- `/admin?tab=ccds-directory`
## Dépendances à rebrancher dans l'autre projet
### Frontend
Les pages exportées utilisent déjà ces dépendances du portail actuel :
- `wouter`
- `sonner`
- `lucide-react`
- composants UI locaux (`Button`, `Card`, `Badge`, `Input`, `Select`, `Accordion`, etc.)
- `trpc` côté client via `@/lib/trpc`
- `useAuth` via `@/_core/hooks/useAuth`
### Backend / API
L'annuaire des associations dépend des procédures `associationDirectory` dans `server/routers.ts`.
Les pages publiques consomment surtout :
- `associationDirectory.listPortal`
- `associationDirectory.listMap`
- `associationDirectory.getMapEntry`
L'admin / bordereau consomme aussi :
- `associationDirectory.getSummary`
- `associationDirectory.listLatest`
- `associationDirectory.getById`
- `associationDirectory.listReviewQueue`
- `associationDirectory.createManual`
- `associationDirectory.updateManual`
- `associationDirectory.setManualCoordinates`
- `associationDirectory.syncExternalData`
- `associationDirectory.syncExternalDataBatch`
- `associationDirectory.syncReferenceData`
- `associationDirectory.syncHelloAssoById`
- `associationDirectory.syncHelloAssoBatch`
- `associationDirectory.resolveReviewLink`
- `associationDirectory.ignoreReview`
- `associationDirectory.previewImport`
- `associationDirectory.importWorkbook`
## Important
Le fichier `server/routers.ts` n'est pas recopié ici car il est très large dans ce projet.
Dans ton autre projet, il faudra soit :
1. recopier la section `associationDirectory: router({...})` depuis `server/routers.ts`
2. soit reconnecter ces composants/pages à ton propre backend
## Fichier archive prêt à copier
Une archive a aussi été générée ici :
- `/Users/selecta/Documents/portail-associations/exports/annuaires-integration-20260605.tar.gz`
## Conseil pratique
Si tu veux une intégration rapide dans un autre projet React :
1. copie d'abord les fichiers `client/src/...`
2. copie ensuite les fichiers `shared/...`
3. rebranche les appels API
4. termine par les services backend `server/...`
Si tu veux, je peux faire la suite utile :
- te préparer un **pack encore plus propre et autonome**
- ou te faire une **version “drop-in”** pour un autre projet React / Vite / Wouter.

View file

@ -0,0 +1,742 @@
import { useEffect, useMemo, useRef } from "react";
import maplibregl, { type GeoJSONSource, type LngLatBoundsLike, type Map as MapLibreMap, type MapLayerMouseEvent } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { buildMaterialReturnFinalPdfViewerHref } from "@/lib/materialReturnFinalPdf";
import { getAssociationThematicLabel } from "@shared/associationThematics";
type PointFeature = {
type: "Feature";
properties: FeatureProperties;
geometry: {
type: "Point";
coordinates: [number, number];
};
};
type PolygonFeature = {
type: "Feature";
properties: FeatureProperties;
geometry: {
type: "Polygon";
coordinates: [Array<[number, number]>];
};
};
type SimpleFeatureCollection = {
type: "FeatureCollection";
features: Array<PointFeature | PolygonFeature>;
};
type MapAssociationEntry = {
id: number;
nomAssociation: string;
ville: string | null;
thematiques?: string[] | null;
objetAssociation: string | null;
siteWeb?: string | null;
latitude: number | null;
longitude: number | null;
registered: boolean;
publicUrl: string;
};
type LogisticsEvent = {
requestId: number;
title: string;
associationName: string;
commune: string;
manifestationStart: string;
manifestationEnd: string;
pickupDate: string;
restitutionDate: string;
useStartDate: string;
useEndDate: string;
requestedItems: Array<{
key: string;
label: string;
quantity: number;
granted: boolean;
requested: boolean;
extra: string;
}>;
boardState: string;
boardStateLabel: string;
logisticServiceLabel: string;
issueFlag: boolean;
finalPdfUrl: string | null;
uploadLink: string | null;
};
type AssociationPublicMapProps = {
associations: MapAssociationEntry[];
mode: "annuaire" | "densite" | "logistique";
selectedAssociationId?: number | null;
onSelectAssociation?: (id: number) => void;
logisticsEvents?: LogisticsEvent[];
selectedLogisticsRequestId?: number | null;
onSelectLogistics?: (requestId: number) => void;
radiusCenter?: { latitude: number; longitude: number } | null;
radiusKm?: number;
styleUrl?: string;
heightClassName?: string;
};
type FeatureProperties = Record<string, string | number | boolean | null>;
const logisticsStateColors: Record<string, string> = {
a_attribuer: "#64748b",
planifie: "#2563eb",
terrain: "#d97706",
retard: "#dc2626",
conforme: "#16a34a",
litige: "#ea580c",
};
function escapeHtml(value: string | null | undefined) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function getThematicColor(thematique?: string | null) {
switch (thematique) {
case "culture_loisirs":
return "#7c3aed";
case "social_sante":
return "#dc2626";
case "education_formation":
return "#2563eb";
case "economie_territoire":
return "#b45309";
case "environnement_patrimoine":
return "#15803d";
case "institutions_divers":
return "#475569";
default:
return "#0f5c89";
}
}
function getHeatColor(count: number, maxCount: number) {
if (maxCount <= 1) return "#0f5c89";
const ratio = count / maxCount;
if (ratio > 0.75) return "#b91c1c";
if (ratio > 0.5) return "#ea580c";
if (ratio > 0.25) return "#f59e0b";
return "#2563eb";
}
function formatDateFr(value?: string | null) {
if (!value) return "-";
try {
return new Date(value).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return value;
}
}
function isDateWithinTodayRange(start?: string | null, end?: string | null) {
const today = new Date().toISOString().slice(0, 10);
const normalizedStart = String(start || "").slice(0, 10);
const normalizedEnd = String(end || normalizedStart).slice(0, 10);
if (!normalizedStart || !normalizedEnd) return false;
return normalizedStart <= today && today <= normalizedEnd;
}
function toFeature(properties: FeatureProperties, longitude: number, latitude: number) {
return {
type: "Feature" as const,
properties,
geometry: {
type: "Point" as const,
coordinates: [longitude, latitude] as [number, number],
},
};
}
function updateGeoJsonSource(map: MapLibreMap, sourceId: string, data: SimpleFeatureCollection) {
const source = map.getSource(sourceId) as GeoJSONSource | undefined;
if (source) {
source.setData(data);
}
}
function buildBoundsFromFeatures(features: PointFeature[]) {
if (features.length === 0) return null;
const first = features[0].geometry.coordinates;
const bounds = new maplibregl.LngLatBounds(first as [number, number], first as [number, number]);
for (const feature of features.slice(1)) {
bounds.extend(feature.geometry.coordinates as [number, number]);
}
return bounds as LngLatBoundsLike;
}
function buildAssociationPopup(properties: FeatureProperties) {
const thematiques = String(properties.thematiques || "")
.split("||")
.filter(Boolean);
return `<div style="min-width:220px">
<strong>${escapeHtml(String(properties.nomAssociation || ""))}</strong><br/>
<span>${escapeHtml(String(properties.ville || "Commune non renseignée"))}</span><br/>
${thematiques.length ? `<span style="display:block;margin-top:6px;font-weight:600">${escapeHtml(thematiques.join(" • "))}</span>` : ""}
${properties.objetAssociation ? `<span style="display:block;margin-top:6px">${escapeHtml(String(properties.objetAssociation))}</span>` : ""}
${properties.siteWeb ? `<a href="${escapeHtml(String(properties.siteWeb))}" target="_blank" rel="noopener noreferrer" style="display:inline-block;margin-top:8px;margin-right:8px">Site web</a>` : ""}
${properties.publicUrl ? `<a href="${escapeHtml(String(properties.publicUrl))}" style="display:inline-block;margin-top:8px">Voir la fiche</a>` : ""}
</div>`;
}
function buildDensityPopup(properties: FeatureProperties) {
return `<div style="min-width:220px">
<strong>${escapeHtml(String(properties.ville || "Commune non renseignée"))}</strong><br/>
<span>${properties.count} association(s)</span><br/>
${properties.dominantThematique ? `<span style="display:block;margin-top:6px">Thématique dominante: ${escapeHtml(String(properties.dominantThematique))}</span>` : ""}
</div>`;
}
function buildLogisticsPopup(properties: FeatureProperties) {
const finalPdfViewerHref = properties.finalPdfUrl
? buildMaterialReturnFinalPdfViewerHref({
url: String(properties.finalPdfUrl),
returnTo: "/associations/carte",
title: "Consulter le PDF final de restitution",
downloadName: `fiche-finale-restitution-${String(properties.requestId || "materiel")}.pdf`,
})
: "";
return `<div style="min-width:240px">
<strong>${escapeHtml(String(properties.associationName || ""))}</strong><br/>
<span>${escapeHtml(String(properties.commune || "Commune non renseignée"))}</span><br/>
<span style="display:block;margin-top:6px;font-weight:600">${escapeHtml(String(properties.boardStateLabel || ""))}</span>
<span style="display:block;margin-top:6px">Usage: ${escapeHtml(String(properties.manifestationStartLabel || "-"))} au ${escapeHtml(String(properties.manifestationEndLabel || "-"))}</span>
<span style="display:block;margin-top:6px">Matériel: ${escapeHtml(String(properties.requestedItemsLabel || "Non renseigné"))}</span>
${properties.logisticServiceLabel ? `<span style="display:block;margin-top:6px">Service: ${escapeHtml(String(properties.logisticServiceLabel))}</span>` : ""}
${properties.finalPdfUrl ? `<a href="${escapeHtml(finalPdfViewerHref)}" style="display:inline-block;margin-top:8px;margin-right:8px">PDF final</a>` : ""}
${properties.uploadLink ? `<a href="${escapeHtml(String(properties.uploadLink))}" target="_blank" rel="noopener noreferrer" style="display:inline-block;margin-top:8px">Lien terrain</a>` : ""}
</div>`;
}
export function AssociationPublicMap({
associations,
mode,
selectedAssociationId,
onSelectAssociation,
logisticsEvents = [],
selectedLogisticsRequestId,
onSelectLogistics,
radiusCenter = null,
radiusKm = 0,
styleUrl = "https://www.portail-association973.com/map-tiles/styles/OSM%20OpenMapTiles/style.json",
heightClassName = "h-[620px]",
}: AssociationPublicMapProps) {
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<MapLibreMap | null>(null);
const popupRef = useRef<maplibregl.Popup | null>(null);
const interactiveBoundRef = useRef(false);
const modeRef = useRef(mode);
modeRef.current = mode;
const validAssociations = useMemo(
() => associations.filter((item) => item.latitude !== null && item.longitude !== null),
[associations]
);
const communeDensity = useMemo(() => {
const groups = new Map<
string,
{
ville: string;
count: number;
latitudeTotal: number;
longitudeTotal: number;
thematiques: string[];
}
>();
validAssociations.forEach((entry) => {
const key = entry.ville || `association-${entry.id}`;
const group = groups.get(key) || {
ville: entry.ville || "Commune non renseignée",
count: 0,
latitudeTotal: 0,
longitudeTotal: 0,
thematiques: [],
};
group.count += 1;
group.latitudeTotal += entry.latitude as number;
group.longitudeTotal += entry.longitude as number;
group.thematiques.push(...(entry.thematiques || []));
groups.set(key, group);
});
return Array.from(groups.values()).map((group) => {
const counts = group.thematiques.reduce<Record<string, number>>((acc, value) => {
acc[value] = (acc[value] || 0) + 1;
return acc;
}, {});
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
return {
...group,
dominantThematique: dominant ? getAssociationThematicLabel(dominant) : null,
latitude: group.latitudeTotal / group.count,
longitude: group.longitudeTotal / group.count,
};
});
}, [validAssociations]);
const maxDensityCount = useMemo(
() => communeDensity.reduce((max, group) => Math.max(max, group.count), 0),
[communeDensity]
);
const logisticsFeatures = useMemo(() => {
return logisticsEvents.flatMap((event) => {
const associationMatch = validAssociations.find(
(entry) =>
entry.nomAssociation.trim().toLowerCase() === event.associationName.trim().toLowerCase()
&& (!event.commune || !entry.ville || entry.ville.trim().toLowerCase() === event.commune.trim().toLowerCase())
);
if (!associationMatch?.latitude || !associationMatch?.longitude) return [];
const active = isDateWithinTodayRange(event.useStartDate, event.useEndDate);
return [
toFeature(
{
requestId: event.requestId,
associationName: event.associationName,
commune: event.commune || associationMatch.ville,
boardStateLabel: event.boardStateLabel,
logisticServiceLabel: event.logisticServiceLabel,
finalPdfUrl: event.finalPdfUrl,
uploadLink: event.uploadLink,
manifestationStartLabel: formatDateFr(event.manifestationStart),
manifestationEndLabel: formatDateFr(event.manifestationEnd),
requestedItemsLabel: event.requestedItems.map((item) => `${item.label} x${item.quantity}`).join(" • ") || "Non renseigné",
color: logisticsStateColors[event.boardState] || "#0f5c89",
radius: selectedLogisticsRequestId === event.requestId ? 13 : active ? 11 : 9,
},
associationMatch.longitude,
associationMatch.latitude
),
];
});
}, [logisticsEvents, selectedLogisticsRequestId, validAssociations]);
const associationFeatureCollection = useMemo<SimpleFeatureCollection>(() => ({
type: "FeatureCollection",
features: validAssociations.map((entry) =>
toFeature(
{
id: entry.id,
nomAssociation: entry.nomAssociation,
ville: entry.ville,
thematiques: (entry.thematiques || []).map((value) => getAssociationThematicLabel(value)).join("||"),
objetAssociation: entry.objetAssociation,
siteWeb: entry.siteWeb || "",
publicUrl: entry.publicUrl,
color: getThematicColor(entry.thematiques?.[0]),
radius: selectedAssociationId === entry.id ? 11 : 8,
},
entry.longitude as number,
entry.latitude as number
)
),
}), [selectedAssociationId, validAssociations]);
const densityFeatureCollection = useMemo<SimpleFeatureCollection>(() => ({
type: "FeatureCollection",
features: communeDensity.map((group) =>
toFeature(
{
ville: group.ville,
count: group.count,
dominantThematique: group.dominantThematique || "",
color: getHeatColor(group.count, maxDensityCount),
radiusMeters: Math.max(6000, 4000 + group.count * 1800),
label: String(group.count),
},
group.longitude,
group.latitude
)
),
}), [communeDensity, maxDensityCount]);
const logisticsFeatureCollection = useMemo<SimpleFeatureCollection>(() => ({
type: "FeatureCollection",
features: logisticsFeatures,
}), [logisticsFeatures]);
useEffect(() => {
if (!mapContainerRef.current) return;
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: styleUrl,
center: [-52.75, 4.95],
zoom: 8,
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
mapRef.current = map;
popupRef.current = new maplibregl.Popup({ closeButton: false, closeOnClick: false, maxWidth: "320px" });
const bindInteractivity = () => {
if (interactiveBoundRef.current) return;
interactiveBoundRef.current = true;
map.on("click", "association-unclustered", (event: MapLayerMouseEvent) => {
const feature = event.features?.[0];
if (!feature || feature.geometry.type !== "Point") return;
const props = feature.properties || {};
onSelectAssociation?.(Number(props.id));
popupRef.current
?.setLngLat(feature.geometry.coordinates as [number, number])
.setHTML(buildAssociationPopup(props as FeatureProperties))
.addTo(map);
});
map.on("click", "association-clusters", (event: MapLayerMouseEvent) => {
const feature = event.features?.[0];
if (!feature || feature.geometry.type !== "Point") return;
const pointFeature = feature as unknown as PointFeature;
const clusterId = pointFeature.properties?.cluster_id;
const source = map.getSource("associations") as (GeoJSONSource & {
getClusterExpansionZoom?: (clusterId: number, cb: (error: Error | null, zoom: number) => void) => void;
}) | undefined;
if (!source || clusterId === undefined) return;
source.getClusterExpansionZoom?.(Number(clusterId), (error: Error | null, zoom: number) => {
if (error) return;
map.easeTo({
center: pointFeature.geometry.coordinates,
zoom,
});
});
});
map.on("click", "density-circles", (event: MapLayerMouseEvent) => {
const feature = event.features?.[0];
if (!feature || feature.geometry.type !== "Point") return;
popupRef.current
?.setLngLat(feature.geometry.coordinates as [number, number])
.setHTML(buildDensityPopup(feature.properties as FeatureProperties))
.addTo(map);
});
map.on("click", "logistics-circles", (event: MapLayerMouseEvent) => {
const feature = event.features?.[0];
if (!feature || feature.geometry.type !== "Point") return;
onSelectLogistics?.(Number(feature.properties?.requestId));
popupRef.current
?.setLngLat(feature.geometry.coordinates as [number, number])
.setHTML(buildLogisticsPopup(feature.properties as FeatureProperties))
.addTo(map);
});
for (const layerId of ["association-unclustered", "association-clusters", "density-circles", "logistics-circles"]) {
map.on("mouseenter", layerId, () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", layerId, () => {
map.getCanvas().style.cursor = "";
});
}
};
const ensureLayers = () => {
if (!map.getSource("associations")) {
map.addSource("associations", {
type: "geojson",
data: associationFeatureCollection,
cluster: true,
clusterMaxZoom: 10,
clusterRadius: 50,
});
}
if (!map.getLayer("association-clusters")) {
map.addLayer({
id: "association-clusters",
type: "circle",
source: "associations",
filter: ["has", "point_count"],
paint: {
"circle-color": "#0f5c89",
"circle-radius": [
"step",
["get", "point_count"],
18,
10,
24,
30,
30,
],
"circle-stroke-width": 3,
"circle-stroke-color": "#ffffff",
},
});
}
if (!map.getLayer("association-cluster-count")) {
map.addLayer({
id: "association-cluster-count",
type: "symbol",
source: "associations",
filter: ["has", "point_count"],
layout: {
"text-field": ["get", "point_count_abbreviated"],
"text-size": 12,
"text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
},
paint: {
"text-color": "#ffffff",
},
});
}
if (!map.getLayer("association-unclustered")) {
map.addLayer({
id: "association-unclustered",
type: "circle",
source: "associations",
filter: ["!", ["has", "point_count"]],
paint: {
"circle-color": ["coalesce", ["get", "color"], "#0f5c89"],
"circle-radius": ["coalesce", ["get", "radius"], 8],
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
"circle-opacity": 0.95,
},
});
}
if (!map.getSource("density")) {
map.addSource("density", {
type: "geojson",
data: densityFeatureCollection,
});
}
if (!map.getLayer("density-circles")) {
map.addLayer({
id: "density-circles",
type: "circle",
source: "density",
paint: {
"circle-color": ["coalesce", ["get", "color"], "#2563eb"],
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
5,
14,
12,
32,
],
"circle-opacity": 0.28,
"circle-stroke-width": 1.5,
"circle-stroke-color": ["coalesce", ["get", "color"], "#2563eb"],
},
});
}
if (!map.getLayer("density-labels")) {
map.addLayer({
id: "density-labels",
type: "symbol",
source: "density",
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
},
paint: {
"text-color": "#0f172a",
"text-halo-color": "#ffffff",
"text-halo-width": 1.5,
},
});
}
if (!map.getSource("logistics")) {
map.addSource("logistics", {
type: "geojson",
data: logisticsFeatureCollection,
});
}
if (!map.getLayer("logistics-circles")) {
map.addLayer({
id: "logistics-circles",
type: "circle",
source: "logistics",
paint: {
"circle-color": ["coalesce", ["get", "color"], "#0f5c89"],
"circle-radius": ["coalesce", ["get", "radius"], 9],
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
"circle-opacity": 0.95,
},
});
}
if (!map.getSource("radius")) {
map.addSource("radius", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [],
},
});
}
if (!map.getLayer("radius-fill")) {
map.addLayer({
id: "radius-fill",
type: "fill",
source: "radius",
paint: {
"fill-color": "#93c5fd",
"fill-opacity": 0.12,
},
});
}
if (!map.getLayer("radius-outline")) {
map.addLayer({
id: "radius-outline",
type: "line",
source: "radius",
paint: {
"line-color": "#2563eb",
"line-width": 2,
"line-opacity": 0.7,
},
});
}
bindInteractivity();
};
map.on("load", ensureLayers);
map.on("styledata", ensureLayers);
return () => {
popupRef.current?.remove();
popupRef.current = null;
interactiveBoundRef.current = false;
map.remove();
mapRef.current = null;
};
}, [styleUrl, onSelectAssociation, onSelectLogistics]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
updateGeoJsonSource(map, "associations", associationFeatureCollection);
updateGeoJsonSource(map, "density", densityFeatureCollection);
updateGeoJsonSource(map, "logistics", logisticsFeatureCollection);
}, [associationFeatureCollection, densityFeatureCollection, logisticsFeatureCollection]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
const radiusFeatureCollection: SimpleFeatureCollection = radiusCenter && radiusKm > 0
? {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {},
geometry: {
type: "Polygon",
coordinates: [Array.from({ length: 65 }, (_, index) => {
const angle = (index / 64) * Math.PI * 2;
const earthRadius = 6371;
const lat = radiusCenter.latitude + (radiusKm / earthRadius) * (180 / Math.PI) * Math.sin(angle);
const lng = radiusCenter.longitude + (radiusKm / earthRadius) * (180 / Math.PI) * Math.cos(angle) / Math.cos((radiusCenter.latitude * Math.PI) / 180);
return [lng, lat];
})],
},
},
],
}
: { type: "FeatureCollection", features: [] };
updateGeoJsonSource(map, "radius", radiusFeatureCollection);
}, [radiusCenter, radiusKm]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
const visibilityByMode: Record<typeof mode, { associations: "visible" | "none"; density: "visible" | "none"; logistics: "visible" | "none" }> = {
annuaire: { associations: "visible", density: "none", logistics: "none" },
densite: { associations: "none", density: "visible", logistics: "none" },
logistique: { associations: "none", density: "none", logistics: "visible" },
};
const visibility = visibilityByMode[mode];
const setVisibility = (layerId: string, value: "visible" | "none") => {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", value);
};
setVisibility("association-clusters", visibility.associations);
setVisibility("association-cluster-count", visibility.associations);
setVisibility("association-unclustered", visibility.associations);
setVisibility("density-circles", visibility.density);
setVisibility("density-labels", visibility.density);
setVisibility("logistics-circles", visibility.logistics);
}, [mode]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
let bounds: LngLatBoundsLike | null = null;
if (mode === "annuaire") {
bounds = buildBoundsFromFeatures(associationFeatureCollection.features as PointFeature[]);
} else if (mode === "densite") {
bounds = buildBoundsFromFeatures(densityFeatureCollection.features as PointFeature[]);
} else if (mode === "logistique") {
bounds = buildBoundsFromFeatures(logisticsFeatureCollection.features as PointFeature[]);
}
if (bounds) {
map.fitBounds(bounds, { padding: 28, maxZoom: 12 });
}
}, [mode, associationFeatureCollection, densityFeatureCollection, logisticsFeatureCollection]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded() || !selectedAssociationId) return;
const feature = (associationFeatureCollection.features as PointFeature[])
.find((entry) => Number(entry.properties.id) === selectedAssociationId);
if (!feature) return;
map.easeTo({
center: feature.geometry.coordinates as [number, number],
zoom: Math.max(map.getZoom(), 12),
});
popupRef.current
?.setLngLat(feature.geometry.coordinates as [number, number])
.setHTML(buildAssociationPopup(feature.properties))
.addTo(map);
}, [associationFeatureCollection, selectedAssociationId]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded() || !selectedLogisticsRequestId) return;
const feature = (logisticsFeatureCollection.features as PointFeature[])
.find((entry) => Number(entry.properties.requestId) === selectedLogisticsRequestId);
if (!feature) return;
map.easeTo({
center: feature.geometry.coordinates as [number, number],
zoom: Math.max(map.getZoom(), 12),
});
popupRef.current
?.setLngLat(feature.geometry.coordinates as [number, number])
.setHTML(buildLogisticsPopup(feature.properties))
.addTo(map);
}, [logisticsFeatureCollection, selectedLogisticsRequestId]);
return <div ref={mapContainerRef} className={`${heightClassName} w-full overflow-hidden rounded-lg border`} />;
}

View file

@ -0,0 +1,405 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { associationCommuneOptions, getAssociationCommuneLabel, type AssociationCommuneFilter } from "@shared/associationCommunes";
import { associationThematicOptions, getAssociationThematicLabel, getAssociationThematicLabels, type AssociationThematic } from "@shared/associationThematics";
import { ArrowLeft, ArrowUpRight, Building2, Copy, Facebook, Globe, Instagram, Loader2, Mail, MapPinned, Search, Users } from "lucide-react";
import { useState } from "react";
import { Link, useLocation } from "wouter";
import { toast } from "sonner";
function getPublicGeoStatus(entry: {
latitude?: number | null;
longitude?: number | null;
geoSource?: string | null;
geoPrecision?: string | null;
}) {
const hasCoordinates = typeof entry.latitude === "number" && typeof entry.longitude === "number";
if (!hasCoordinates || !entry.geoSource) {
return {
label: "Non localisée",
className: "bg-slate-50 text-slate-700 border-slate-200",
};
}
if (entry.geoSource === "manual") {
return {
label: "Position manuelle",
className: "bg-emerald-50 text-emerald-700 border-emerald-200",
};
}
if (entry.geoPrecision === "commune_center" || entry.geoSource === "commune_center") {
return {
label: "Centre de commune",
className: "bg-amber-50 text-amber-800 border-amber-200",
};
}
return {
label: "Adresse exacte",
className: "bg-blue-50 text-blue-700 border-blue-200",
};
}
export default function AssociationDirectoryPortal() {
const { user } = useAuth();
const [location] = useLocation();
const utils = trpc.useUtils();
const [search, setSearch] = useState("");
const [communeFilter, setCommuneFilter] = useState<AssociationCommuneFilter>("all");
const [statusFilter, setStatusFilter] = useState<"all" | "registered" | "unregistered">("all");
const [thematiqueFilter, setThematiqueFilter] = useState<AssociationThematic | "all">("all");
const { data, isLoading } = trpc.associationDirectory.listPortal.useQuery({
search: search || undefined,
commune: communeFilter,
thematique: thematiqueFilter,
registrationStatus: statusFilter,
limit: 100,
});
const sendInvitationMutation = trpc.associationInvitation.send.useMutation({
onSuccess: (result) => {
toast.success(
result.emailSent
? "Invitation envoyée à l'adresse officielle."
: "Invitation créée, mais l'email n'a pas pu être envoyé. Utilise le lien sécurisé."
);
void utils.associationDirectory.listPortal.invalidate();
},
onError: (error) => {
toast.error(error.message || "Impossible d'envoyer l'invitation.");
},
});
const secureLinkMutation = trpc.associationInvitation.getSecureLink.useMutation({
onError: (error) => {
toast.error(error.message || "Impossible de récupérer le lien sécurisé.");
},
});
const isAdmin = user?.role === "admin" || user?.role === "super_admin";
const backHref = location.startsWith("/dashboard") || user ? "/dashboard" : "/";
const formatDate = (date: Date | string | null | undefined) => {
if (!date) return "-";
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const getInvitationStatusLabel = (entry: NonNullable<typeof data>["data"][number]) => {
if (entry.registered) return "Acceptée";
switch (entry.invitationStatus?.status) {
case "sent":
return "Invitation envoyée";
case "expired":
return "Expirée";
case "accepted":
return "Acceptée";
default:
return "Jamais invitée";
}
};
const copySecureInvitationLink = async (directoryEntryId: number) => {
try {
const result = await secureLinkMutation.mutateAsync({ directoryEntryId });
const link = result.invitationLink;
await navigator.clipboard.writeText(link);
toast.success("Lien sécurisé copié. Tu peux maintenant l'envoyer à l'association.");
void utils.associationDirectory.listPortal.invalidate();
} catch {
toast.error("Impossible de copier le lien automatiquement.");
}
};
return (
<div className="min-h-screen bg-muted/30">
<header className="border-b border-border bg-white sticky top-0 z-10">
<div className="container py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Link href={backHref}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Annuaire des associations</h1>
<p className="text-sm text-muted-foreground">
Consulte le bordereau public des associations des Savanes, même avant leur inscription sur le portail.
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
{data?.total || 0} visible(s)
</Badge>
<Link href="/associations/carte">
<Button variant="outline" size="sm">
<MapPinned className="mr-2 h-4 w-4" />
Carte publique
</Button>
</Link>
</div>
</div>
</div>
</header>
<main className="container py-8 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
Bordereau partagé
</CardTitle>
<CardDescription>
Recherche par nom et filtre par commune pour retrouver rapidement une association.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="relative lg:w-[320px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher une association, un SIRET ou un RNA..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="lg:w-[280px]">
<Select value={thematiqueFilter} onValueChange={(value) => setThematiqueFilter(value as AssociationThematic | "all")}>
<SelectTrigger>
<SelectValue placeholder="Filtrer par catégorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les thématiques</SelectItem>
{associationThematicOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap gap-2">
{[
{ value: "all", label: "Toutes" },
{ value: "registered", label: "Inscrites" },
{ value: "unregistered", label: "Pas encore inscrites" },
].map((option) => (
<Button
key={option.value}
size="sm"
type="button"
variant={statusFilter === option.value ? "default" : "outline"}
className="rounded-full"
onClick={() => setStatusFilter(option.value as "all" | "registered" | "unregistered")}
>
{option.label}
</Button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{associationCommuneOptions.map((option) => (
<Button
key={option.value}
size="sm"
type="button"
variant={communeFilter === option.value ? "default" : "outline"}
className="rounded-full"
onClick={() => setCommuneFilter(option.value)}
>
{option.label}
</Button>
))}
</div>
<p className="text-sm text-muted-foreground">
{data?.total || 0} association(s)
{communeFilter !== "all" ? `${getAssociationCommuneLabel(communeFilter)}` : ""}
{thematiqueFilter !== "all" ? `${getAssociationThematicLabel(thematiqueFilter)}` : ""}
{statusFilter === "registered" ? " déjà activée(s) sur le portail" : ""}
{statusFilter === "unregistered" ? " pas encore inscrite(s) sur le portail" : ""}
</p>
</CardContent>
</Card>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{data?.data?.map((entry) => (
<Card key={entry.id} className="h-full">
{(() => {
const geoStatus = getPublicGeoStatus(entry);
return (
<CardHeader className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Building2 className="h-5 w-5" />
</div>
<div className="flex flex-col items-end gap-2">
<Badge
variant="outline"
className={entry.registered ? "bg-green-50 text-green-700 border-green-200" : "bg-blue-50 text-blue-700 border-blue-200"}
>
{entry.registered ? "Inscrite" : "À activer"}
</Badge>
<Badge variant="outline" className={geoStatus.className}>
{geoStatus.label}
</Badge>
</div>
</div>
<div>
<CardTitle className="text-lg leading-6">{entry.nomAssociation}</CardTitle>
<CardDescription className="mt-1">
{entry.ville || "Commune non renseignée"}
</CardDescription>
</div>
</CardHeader>
);
})()}
<CardContent className="space-y-2 text-sm text-muted-foreground">
{(entry.siret || entry.rna) ? (
<div className="flex flex-wrap gap-2 pb-1">
{entry.siret ? (
<span className="rounded-full bg-muted px-2 py-1 text-xs">SIRET : {entry.siret}</span>
) : null}
{entry.rna ? (
<span className="rounded-full bg-muted px-2 py-1 text-xs">RNA : {entry.rna}</span>
) : null}
</div>
) : null}
{entry.thematiques?.length ? (
<div className="flex flex-wrap gap-2">
{getAssociationThematicLabels(entry.thematiques).map((label) => (
<span key={label} className="rounded-full bg-primary/10 px-2 py-1 text-xs text-primary">{label}</span>
))}
</div>
) : null}
{entry.adresse || entry.codePostal || entry.ville ? (
<p>
{[entry.adresse, [entry.codePostal, entry.ville].filter(Boolean).join(" ")].filter(Boolean).join(", ")}
</p>
) : null}
{entry.emailOfficiel ? <p>{entry.emailOfficiel}</p> : null}
{entry.siteWeb ? (
<a
href={entry.siteWeb}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
<Globe className="h-3.5 w-3.5" />
Site web
</a>
) : null}
<p>Présente dans le bordereau depuis le {formatDate(entry.importedAt)}</p>
<p>
{entry.registered
? `Compte activé le ${formatDate(entry.registeredAt)}`
: "Cette association n'a pas encore créé son espace portail."}
</p>
{(entry.facebookUrl || entry.instagramUrl) ? (
<div className="flex flex-wrap items-center gap-2 pt-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">Réseaux</span>
{entry.facebookUrl ? (
<a
href={entry.facebookUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs text-foreground hover:bg-muted/50"
>
<Facebook className="h-3.5 w-3.5 text-primary" />
Facebook
</a>
) : null}
{entry.instagramUrl ? (
<a
href={entry.instagramUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs text-foreground hover:bg-muted/50"
>
<Instagram className="h-3.5 w-3.5 text-primary" />
Instagram
</a>
) : null}
</div>
) : null}
<div className="pt-2 space-y-1">
<p className="font-medium text-foreground">État dinvitation : {getInvitationStatusLabel(entry)}</p>
{entry.invitationStatus?.sentAt ? (
<p>Envoyée le {formatDate(entry.invitationStatus.sentAt)}</p>
) : null}
{entry.invitationStatus?.expiresAt ? (
<p>Expire le {formatDate(entry.invitationStatus.expiresAt)}</p>
) : null}
{!entry.registered && entry.invitationStatus?.status === "sent" && !entry.invitationStatus.emailSent ? (
<p className="text-amber-700">Email non envoyé automatiquement, lien sécurisé disponible.</p>
) : null}
</div>
{!entry.registered && isAdmin ? (
<div className="grid gap-2 pt-3">
<Button
type="button"
className="w-full"
disabled={!entry.emailOfficiel || sendInvitationMutation.isPending}
onClick={() => sendInvitationMutation.mutate({ directoryEntryId: entry.id })}
>
<Mail className="mr-2 h-4 w-4" />
{entry.emailOfficiel ? "Inviter par email" : "Email officiel requis"}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
disabled={!entry.emailOfficiel || secureLinkMutation.isPending}
onClick={() => copySecureInvitationLink(entry.id)}
>
<Copy className="mr-2 h-4 w-4" />
{entry.emailOfficiel ? "Copier le lien sécurisé" : "Email officiel requis"}
</Button>
</div>
) : null}
<div className="pt-3">
<Link href={`/associations/${entry.id}`}>
<Button type="button" variant="outline" className="w-full">
<ArrowUpRight className="mr-2 h-4 w-4" />
Voir la fiche complète
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
{(!data?.data || data.data.length === 0) && (
<Card className="md:col-span-2 xl:col-span-3">
<CardContent className="py-12 text-center text-muted-foreground">
<Users className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Aucune association ne correspond aux filtres actuels.</p>
</CardContent>
</Card>
)}
</div>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,565 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { buildMaterialReturnFinalPdfViewerHref } from "@/lib/materialReturnFinalPdf";
import { trpc } from "@/lib/trpc";
import { associationCommuneOptions, getAssociationCommuneLabel, type AssociationCommuneFilter } from "@shared/associationCommunes";
import { associationThematicOptions, getAssociationThematicLabel, getAssociationThematicLabels, type AssociationThematic } from "@shared/associationThematics";
import { ArrowLeft, Building2, Loader2, MapPinned, PackageCheck, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "wouter";
import { AssociationPublicMap } from "@/components/AssociationPublicMap";
import { toast } from "sonner";
type LogisticsMapEvent = {
requestId: number;
title: string;
associationName: string;
commune: string;
manifestationStart: string;
manifestationEnd: string;
pickupDate: string;
restitutionDate: string;
useStartDate: string;
useEndDate: string;
requestedItems: Array<{
key: string;
label: string;
quantity: number;
granted: boolean;
requested: boolean;
extra: string;
}>;
boardState: string;
boardStateLabel: string;
logisticServiceLabel: string;
issueFlag: boolean;
finalPdfUrl: string | null;
uploadLink: string | null;
};
function haversineDistanceKm(a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number }) {
const toRad = (value: number) => (value * Math.PI) / 180;
const earthRadiusKm = 6371;
const deltaLat = toRad(b.latitude - a.latitude);
const deltaLng = toRad(b.longitude - a.longitude);
const lat1 = toRad(a.latitude);
const lat2 = toRad(b.latitude);
const hav =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2;
return 2 * earthRadiusKm * Math.asin(Math.sqrt(hav));
}
export default function AssociationMapPublic() {
const { isAuthenticated, user } = useAuth();
const isAdmin = user?.role === "admin" || user?.role === "super_admin";
const [search, setSearch] = useState("");
const [communeFilter, setCommuneFilter] = useState<AssociationCommuneFilter>("all");
const [thematiqueFilter, setThematiqueFilter] = useState<AssociationThematic | "all">("all");
const [mapMode, setMapMode] = useState<"annuaire" | "densite" | "logistique">("annuaire");
const [selectedAssociationId, setSelectedAssociationId] = useState<number | null>(null);
const [selectedLogisticsRequestId, setSelectedLogisticsRequestId] = useState<number | null>(null);
const [radiusEnabled, setRadiusEnabled] = useState(false);
const [radiusKm, setRadiusKm] = useState(5);
const [userPosition, setUserPosition] = useState<{ latitude: number; longitude: number } | null>(null);
const [geolocating, setGeolocating] = useState(false);
const [logisticsFilter, setLogisticsFilter] = useState<"actifs" | "avenir" | "tous">("actifs");
const { data, isLoading } = trpc.associationDirectory.listMap.useQuery({
search: search || undefined,
commune: communeFilter,
thematique: thematiqueFilter,
registrationStatus: "all",
limit: 500,
});
const materialCalendarQuery = trpc.request.getMaterialCalendar.useQuery(undefined, {
enabled: isAdmin,
});
const { data: associationMapSettings } = trpc.settings.getAssociationMap.useQuery();
useEffect(() => {
if (mapMode === "logistique" && !isAdmin) {
setMapMode("annuaire");
}
}, [isAdmin, mapMode]);
const requestUserLocation = () => {
if (!navigator.geolocation) {
toast.error("La géolocalisation n'est pas disponible sur cet appareil.");
return;
}
setGeolocating(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setUserPosition({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
setRadiusEnabled(true);
setGeolocating(false);
},
() => {
toast.error("Impossible de récupérer la position. La carte reste utilisable sans ce filtre.");
setGeolocating(false);
},
{
enableHighAccuracy: true,
timeout: 5000,
}
);
};
const radiusFilteredAssociations = useMemo(() => {
const entries = data?.data || [];
if (!radiusEnabled || !userPosition) return entries;
return entries.filter((entry) => {
if (entry.latitude === null || entry.longitude === null) return false;
return haversineDistanceKm(userPosition, {
latitude: entry.latitude,
longitude: entry.longitude,
}) <= radiusKm;
});
}, [data?.data, radiusEnabled, radiusKm, userPosition]);
const logisticsEvents = useMemo<LogisticsMapEvent[]>(() => {
if (!isAdmin) return [];
const allEvents: LogisticsMapEvent[] = (materialCalendarQuery.data?.events || []).flatMap((event) => {
if (typeof event.requestId !== "number") {
return [];
}
return [{
requestId: event.requestId,
title: event.title,
associationName: event.associationName,
commune: event.commune,
manifestationStart: event.manifestationStart,
manifestationEnd: event.manifestationEnd,
pickupDate: event.pickupDate,
restitutionDate: event.restitutionDate,
useStartDate: event.useStartDate,
useEndDate: event.useEndDate,
requestedItems: event.requestedItems,
boardState: event.boardState,
boardStateLabel: event.boardStateLabel,
logisticServiceLabel: event.logisticServiceLabel,
issueFlag: event.issueFlag,
finalPdfUrl: event.finalPdfUrl,
uploadLink: event.uploadLink,
}];
});
const today = new Date().toISOString().slice(0, 10);
return allEvents.filter((event) => {
if (communeFilter !== "all" && event.commune?.toLowerCase() !== communeFilter.toLowerCase()) {
return false;
}
if (search) {
const haystack = `${event.associationName} ${event.title} ${event.commune} ${event.requestedItems.map((item: any) => item.label).join(" ")}`.toLowerCase();
if (!haystack.includes(search.toLowerCase())) {
return false;
}
}
if (logisticsFilter === "tous") return true;
const start = String(event.useStartDate || "").slice(0, 10);
const end = String(event.useEndDate || start).slice(0, 10);
const active = start && end && start <= today && today <= end;
if (logisticsFilter === "actifs") return active;
if (logisticsFilter === "avenir") return start > today;
return true;
});
}, [communeFilter, isAdmin, logisticsFilter, materialCalendarQuery.data?.events, search]);
const selectedAssociation = useMemo(
() => radiusFilteredAssociations.find((item) => item.id === selectedAssociationId) || null,
[radiusFilteredAssociations, selectedAssociationId]
);
const selectedLogistics = useMemo(
() => logisticsEvents.find((event) => event.requestId === selectedLogisticsRequestId) || null,
[logisticsEvents, selectedLogisticsRequestId]
);
const densitySummary = useMemo(() => {
const grouped = new Map<string, number>();
radiusFilteredAssociations.forEach((entry) => {
const key = entry.ville || "Commune non renseignée";
grouped.set(key, (grouped.get(key) || 0) + 1);
});
return Array.from(grouped.entries())
.map(([ville, count]) => ({ ville, count }))
.sort((a, b) => b.count - a.count);
}, [radiusFilteredAssociations]);
const backHref = isAuthenticated ? "/dashboard/associations" : "/associations";
const visibleCount = mapMode === "logistique" ? logisticsEvents.length : radiusFilteredAssociations.length;
return (
<div className="min-h-screen bg-muted/30">
<header className="border-b border-border bg-white">
<div className="container py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Link href={backHref}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Carte des associations</h1>
<p className="text-sm text-muted-foreground">
Découvre les associations des Savanes, leur densité territoriale et, côté administration, la circulation du matériel CCDS.
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
{visibleCount} {mapMode === "logistique" ? "mission(s)" : "association(s)"}
</Badge>
{isAuthenticated ? (
<Link href="/dashboard/associations">
<Button variant="outline">Annuaire portail</Button>
</Link>
) : null}
</div>
</div>
</div>
</header>
<main className="container py-8 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPinned className="h-5 w-5 text-primary" />
Cartographie interactive & implantation territoriale
</CardTitle>
<CardDescription>
Passe de l'annuaire géographique à la densité d'implantation, puis au pilotage logistique si tu es administrateur.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="relative lg:w-[320px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={mapMode === "logistique" ? "Rechercher une mission ou du matériel..." : "Rechercher une association..."}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="lg:w-[280px]">
<Select value={thematiqueFilter} onValueChange={(value) => setThematiqueFilter(value as AssociationThematic | "all")}>
<SelectTrigger disabled={mapMode === "logistique"}>
<SelectValue placeholder="Filtrer par catégorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les thématiques</SelectItem>
{associationThematicOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap gap-2">
{associationCommuneOptions.map((option) => (
<Button
key={option.value}
size="sm"
type="button"
variant={communeFilter === option.value ? "default" : "outline"}
className="rounded-full"
onClick={() => setCommuneFilter(option.value)}
>
{option.label}
</Button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={mapMode === "annuaire" ? "default" : "outline"}
onClick={() => setMapMode("annuaire")}
>
Annuaire géographique
</Button>
<Button
variant={mapMode === "densite" ? "default" : "outline"}
onClick={() => setMapMode("densite")}
>
Densité d'implantation
</Button>
{isAdmin ? (
<Button
variant={mapMode === "logistique" ? "default" : "outline"}
onClick={() => setMapMode("logistique")}
>
<PackageCheck className="mr-2 h-4 w-4" />
Flux logistique événementiel
</Button>
) : null}
</div>
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
<div className="space-y-3 rounded-lg border bg-muted/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Filtre par rayon d'action</p>
<p className="text-xs text-muted-foreground">
Affiche uniquement les associations autour de ta position pour repérer l'offre locale sur le territoire.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={requestUserLocation} disabled={geolocating}>
{geolocating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{userPosition ? "Actualiser ma position" : "Utiliser ma position"}
</Button>
{radiusEnabled ? (
<Button variant="ghost" onClick={() => setRadiusEnabled(false)}>
Désactiver
</Button>
) : null}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Rayon actif</span>
<span className="font-medium">{radiusKm} km</span>
</div>
<Slider
value={[radiusKm]}
min={1}
max={25}
step={1}
onValueChange={(value) => setRadiusKm(value[0] || 5)}
disabled={!userPosition}
/>
</div>
</div>
{mapMode === "logistique" ? (
<div className="rounded-lg border bg-muted/20 p-4">
<p className="text-sm font-medium">Vue logistique</p>
<p className="text-xs text-muted-foreground mb-3">
Les missions terrain peuvent être affichées en cours, à venir ou dans une vue complète.
</p>
<div className="flex flex-wrap gap-2">
{([
["actifs", "Actifs aujourd'hui"],
["avenir", "À venir"],
["tous", "Tous les flux"],
] as const).map(([value, label]) => (
<Button
key={value}
variant={logisticsFilter === value ? "default" : "outline"}
size="sm"
onClick={() => setLogisticsFilter(value)}
>
{label}
</Button>
))}
</div>
</div>
) : (
<div className="rounded-lg border bg-muted/20 p-4">
<p className="text-sm font-medium">Lecture territoriale</p>
<p className="text-xs text-muted-foreground">
{mapMode === "annuaire"
? "La carte regroupe automatiquement les marqueurs quand le nombre d'associations devient dense, puis les détaille au zoom."
: "La couche densité montre les zones de concentration associatives pour aider à repérer les secteurs isolés ou saturés."}
</p>
</div>
)}
</div>
<p className="text-sm text-muted-foreground">
{visibleCount} élément(s) visible(s)
{communeFilter !== "all" ? `${getAssociationCommuneLabel(communeFilter)}` : ""}
{thematiqueFilter !== "all" && mapMode !== "logistique" ? `${getAssociationThematicLabel(thematiqueFilter)}` : ""}
{radiusEnabled && userPosition ? ` • dans un rayon de ${radiusKm} km autour de toi` : ""}
</p>
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.7fr)_420px]">
<AssociationPublicMap
associations={radiusFilteredAssociations}
mode={mapMode}
selectedAssociationId={selectedAssociationId}
onSelectAssociation={(id) => {
setSelectedAssociationId(id);
setSelectedLogisticsRequestId(null);
}}
logisticsEvents={logisticsEvents}
selectedLogisticsRequestId={selectedLogisticsRequestId}
onSelectLogistics={(requestId) => {
setSelectedLogisticsRequestId(requestId);
setSelectedAssociationId(null);
}}
radiusCenter={radiusEnabled ? userPosition : null}
radiusKm={radiusEnabled ? radiusKm : 0}
styleUrl={associationMapSettings?.styleUrl}
/>
<Card className="min-h-[620px]">
<CardHeader>
<CardTitle>
{mapMode === "annuaire"
? "Associations géolocalisées"
: mapMode === "densite"
? "Lecture par commune"
: "Missions logistiques"}
</CardTitle>
<CardDescription>
{mapMode === "annuaire"
? "Clique sur une fiche ou un point de la carte pour centrer l'association."
: mapMode === "densite"
? "Les zones les plus denses remontent ici pour guider l'analyse territoriale."
: "Les prêts de matériel en cours ou à venir deviennent des objets de pilotage cartographique."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 max-h-[560px] overflow-auto pr-1">
{(isLoading || (mapMode === "logistique" && materialCalendarQuery.isLoading)) ? (
<p className="text-sm text-muted-foreground">Chargement de la carte...</p>
) : mapMode === "annuaire" ? (
radiusFilteredAssociations.length > 0 ? (
radiusFilteredAssociations.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => {
setSelectedAssociationId(entry.id);
setSelectedLogisticsRequestId(null);
}}
className={`w-full rounded-lg border p-3 text-left transition-colors ${selectedAssociation?.id === entry.id ? "border-primary bg-primary/5" : "hover:bg-muted/40"}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">{entry.nomAssociation}</p>
<p className="text-sm text-muted-foreground">{entry.ville || "Commune non renseignée"}</p>
{entry.thematiques?.length ? (
<p className="text-sm text-primary">{getAssociationThematicLabels(entry.thematiques).join(" • ")}</p>
) : null}
</div>
<Badge variant="outline">{entry.registered ? "Inscrite" : "Référencée"}</Badge>
</div>
{entry.objetAssociation ? (
<p className="mt-2 text-sm text-muted-foreground line-clamp-3">{entry.objetAssociation}</p>
) : null}
<div className="mt-3">
<Link href={entry.publicUrl}>
<Button type="button" size="sm" variant="outline">
<Building2 className="mr-2 h-4 w-4" />
Voir la fiche
</Button>
</Link>
</div>
</button>
))
) : (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
Aucune association géolocalisée ne correspond à cette recherche.
</div>
)
) : mapMode === "densite" ? (
densitySummary.length > 0 ? (
densitySummary.map((entry) => (
<div key={entry.ville} className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium">{entry.ville}</p>
<Badge>{entry.count} association(s)</Badge>
</div>
</div>
))
) : (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
Aucune commune ne remonte dans ce filtre.
</div>
)
) : logisticsEvents.length > 0 ? (
logisticsEvents.map((event) => (
<button
key={event.requestId}
type="button"
onClick={() => {
setSelectedLogisticsRequestId(event.requestId ?? null);
setSelectedAssociationId(null);
}}
className={`w-full rounded-lg border p-3 text-left transition-colors ${selectedLogistics?.requestId === event.requestId ? "border-primary bg-primary/5" : "hover:bg-muted/40"}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">{event.associationName}</p>
<p className="text-sm text-muted-foreground">{event.commune || "Commune non renseignée"}</p>
<p className="text-sm text-primary">{event.boardStateLabel}</p>
</div>
<Badge variant="outline">{formatDateLabel(event.useStartDate, event.useEndDate)}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
{event.requestedItems.map((item) => `${item.label} x${item.quantity}`).join(" • ") || "Matériel non renseigné"}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link href={`/dashboard/requests/${event.requestId}`}>
<Button type="button" size="sm" variant="outline">
Ouvrir le dossier
</Button>
</Link>
{event.finalPdfUrl ? (
<a
href={buildMaterialReturnFinalPdfViewerHref({
url: event.finalPdfUrl,
returnTo: "/associations/carte",
title: "Consulter le PDF final de restitution",
downloadName: `fiche-finale-restitution-${event.requestId}.pdf`,
})}
>
<Button type="button" size="sm" variant="outline">
PDF final
</Button>
</a>
) : null}
</div>
</button>
))
) : (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
Aucune mission logistique ne correspond à cette lecture.
</div>
)}
</CardContent>
</Card>
</div>
</main>
</div>
);
}
function formatDateLabel(start?: string | null, end?: string | null) {
if (!start) return "-";
const startLabel = new Date(start).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
});
const endLabel = new Date(end || start).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
});
if (startLabel === endLabel) return startLabel;
return `${startLabel} -> ${endLabel}`;
}

View file

@ -0,0 +1,309 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { AssociationPublicMap } from "@/components/AssociationPublicMap";
import { getAssociationThematicDescription, getAssociationThematicLabel } from "@shared/associationThematics";
import { getFacebookEmbedUrl, getSocialHandle } from "@shared/socialLinks";
import { ArrowLeft, Building2, Facebook, Globe, Instagram, MapPinned } from "lucide-react";
import { Link, useParams } from "wouter";
function getPublicGeoStatus(entry: {
latitude?: number | null;
longitude?: number | null;
geoSource?: string | null;
geoPrecision?: string | null;
}) {
const hasCoordinates = typeof entry.latitude === "number" && typeof entry.longitude === "number";
if (!hasCoordinates || !entry.geoSource) {
return "Non localisée";
}
if (entry.geoSource === "manual") {
return "Position manuelle";
}
if (entry.geoPrecision === "commune_center" || entry.geoSource === "commune_center") {
return "Centre de commune";
}
return "Adresse exacte";
}
export default function AssociationPublicProfile() {
const { isAuthenticated } = useAuth();
const params = useParams();
const associationId = Number(params.id);
const { data, isLoading, error } = trpc.associationDirectory.getMapEntry.useQuery(
{ id: associationId },
{ enabled: Number.isFinite(associationId) }
);
const { data: associationMapSettings } = trpc.settings.getAssociationMap.useQuery();
const facebookEmbedUrl = data?.facebookUrl ? getFacebookEmbedUrl(data.facebookUrl) : null;
const instagramHandle = data?.instagramUrl ? getSocialHandle(data.instagramUrl) : null;
const backToDirectoryHref = isAuthenticated ? "/dashboard/associations" : "/associations";
const backToMapHref = isAuthenticated ? "/dashboard/associations" : "/associations/carte";
const backToDashboardHref = isAuthenticated ? "/dashboard" : "/";
const geoStatus = data ? getPublicGeoStatus(data) : null;
return (
<div className="min-h-screen bg-muted/30">
<header className="border-b border-border bg-white">
<div className="container py-4">
<div className="flex items-center gap-4">
<Link href={backToDirectoryHref}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Fiche association</h1>
<p className="text-sm text-muted-foreground">Présentation légère publique de l'association.</p>
</div>
</div>
</div>
</header>
<main className="container py-8">
{isLoading ? (
<Card>
<CardContent className="py-10 text-sm text-muted-foreground">Chargement de la fiche...</CardContent>
</Card>
) : error || !data ? (
<Card>
<CardContent className="py-10 text-sm text-muted-foreground">
Cette fiche n'est pas disponible publiquement.
</CardContent>
</Card>
) : (
<Card className="max-w-3xl">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-2xl">{data.nomAssociation}</CardTitle>
<CardDescription className="mt-2 flex items-center gap-2">
<MapPinned className="h-4 w-4" />
{data.ville || "Commune non renseignée"}
</CardDescription>
</div>
<Badge variant="outline">{data.registered ? "Inscrite sur le portail" : "Référencée dans l'annuaire"}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-lg border bg-primary/5 p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-medium text-primary">Carte des associations</p>
<p className="mt-1 text-sm text-muted-foreground">
Decouvre les associations des Savanes et leur implantation territoriale.
</p>
</div>
<Link href={backToMapHref}>
<Button variant="outline">
<MapPinned className="mr-2 h-4 w-4" />
Ouvrir la carte
</Button>
</Link>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border bg-muted/20 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Type de localisation</p>
<p className="mt-2 font-medium">{geoStatus}</p>
</div>
<div className="rounded-lg border bg-muted/20 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Source de la position</p>
<p className="mt-2 font-medium">{data.sourceLabel || data.geoSource || "Aucune source active"}</p>
</div>
</div>
{typeof data.latitude === "number" && typeof data.longitude === "number" ? (
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-muted-foreground">Localisation interactive</p>
<p className="mt-1 text-sm text-muted-foreground">
Carte vectorielle MapLibre basée sur les coordonnées enregistrées dans lannuaire.
</p>
</div>
<AssociationPublicMap
associations={[{
id: data.id,
nomAssociation: data.nomAssociation,
ville: data.ville ?? null,
thematiques: data.thematiques,
objetAssociation: data.objetAssociation ?? null,
siteWeb: data.siteWeb ?? null,
latitude: data.latitude,
longitude: data.longitude,
registered: data.registered,
publicUrl: `/associations/${data.id}`,
}]}
mode="annuaire"
selectedAssociationId={data.id}
styleUrl={associationMapSettings?.styleUrl}
heightClassName="h-[360px]"
/>
</div>
) : null}
{data.thematiques?.length ? (
<div className="rounded-lg border bg-muted/20 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Thématique</p>
<div className="mt-2 flex flex-wrap gap-2">
{data.thematiques.map((value: string) => (
<span key={value} className="rounded-full bg-primary/10 px-3 py-1 text-sm text-primary">
{getAssociationThematicLabel(value)}
</span>
))}
</div>
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
{data.thematiques.map((value: string) => (
<p key={`${value}-description`}>{getAssociationThematicDescription(value)}</p>
))}
</div>
</div>
) : null}
{(data.siret || data.rna) ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border bg-muted/20 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Numéro SIRET</p>
<p className="mt-2 font-medium">{data.siret || "-"}</p>
</div>
<div className="rounded-lg border bg-muted/20 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Numéro RNA</p>
<p className="mt-2 font-medium">{data.rna || "-"}</p>
</div>
</div>
) : null}
<div>
<p className="text-sm font-medium text-muted-foreground">Objet / activité</p>
<p className="mt-2 leading-7">{data.objetAssociation || "Cette association n'a pas encore publié de description."}</p>
</div>
{data.siteWeb ? (
<div>
<p className="text-sm font-medium text-muted-foreground">Site web</p>
<a href={data.siteWeb} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center text-primary hover:underline">
<Globe className="mr-2 h-4 w-4" />
Visiter le site
</a>
</div>
) : null}
{(data.facebookUrl || data.instagramUrl) ? (
<div>
<p className="text-sm font-medium text-muted-foreground">Réseaux sociaux</p>
<div className="mt-2 flex flex-wrap gap-3">
{data.facebookUrl ? (
<a
href={data.facebookUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm hover:bg-muted/50"
>
<Facebook className="h-4 w-4 text-primary" />
Facebook
</a>
) : null}
{data.instagramUrl ? (
<a
href={data.instagramUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm hover:bg-muted/50"
>
<Instagram className="h-4 w-4 text-primary" />
Instagram
</a>
) : null}
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
{data.facebookUrl ? (
<div className="overflow-hidden rounded-lg border bg-white">
{facebookEmbedUrl ? (
<iframe
title={`Flux Facebook de ${data.nomAssociation}`}
src={facebookEmbedUrl}
width="100%"
height="380"
style={{ border: "none", overflow: "hidden" }}
scrolling="no"
allow="autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
/>
) : (
<div className="flex h-[380px] flex-col justify-between bg-[#1877f2] p-6 text-white">
<div>
<Facebook className="h-8 w-8" />
<p className="mt-4 text-lg font-semibold">Suivre lassociation sur Facebook</p>
<p className="mt-2 text-sm text-white/85">
Ouvre la page officielle pour consulter les actualités et publications.
</p>
</div>
<a
href={data.facebookUrl}
target="_blank"
rel="noreferrer"
className="inline-flex w-fit items-center rounded-full bg-white px-4 py-2 text-sm font-medium text-[#1877f2]"
>
Ouvrir Facebook
</a>
</div>
)}
</div>
) : null}
{data.instagramUrl ? (
<div className="rounded-lg border bg-[linear-gradient(135deg,#833ab4_0%,#fd1d1d_55%,#fcb045_100%)] p-[1px]">
<div className="flex h-full min-h-[220px] flex-col justify-between rounded-[calc(0.5rem-1px)] bg-background p-6">
<div>
<div className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-[linear-gradient(135deg,#833ab4_0%,#fd1d1d_55%,#fcb045_100%)] text-white">
<Instagram className="h-5 w-5" />
</div>
<p className="mt-4 text-lg font-semibold">Instagram</p>
<p className="mt-2 text-sm text-muted-foreground">
{instagramHandle
? `Retrouve lassociation sur ${instagramHandle} pour suivre ses publications et moments forts.`
: "Retrouve lassociation sur Instagram pour suivre ses publications et moments forts."}
</p>
</div>
<a
href={data.instagramUrl}
target="_blank"
rel="noreferrer"
className="inline-flex w-fit items-center rounded-full border px-4 py-2 text-sm font-medium hover:bg-muted/50"
>
Ouvrir Instagram
</a>
</div>
</div>
) : null}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-3">
<Link href={backToMapHref}>
<Button variant="outline">
<MapPinned className="mr-2 h-4 w-4" />
{isAuthenticated ? "Retour à l'annuaire" : "Retour à la carte"}
</Button>
</Link>
<Link href={backToDashboardHref}>
<Button>
<Building2 className="mr-2 h-4 w-4" />
{isAuthenticated ? "Retour au tableau de bord" : "Revenir au portail"}
</Button>
</Link>
</div>
</CardContent>
</Card>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,419 @@
import { createHash } from "node:crypto";
import * as XLSX from "xlsx";
import type { AssociationDirectoryEntry, InsertAssociation } from "../drizzle/schema";
import { TRPCError } from "@trpc/server";
import { normalizeLegalRepresentativeRole, serializeAssociationGovernance } from "@shared/associationGovernance";
type ParsedDirectoryRow = {
sheetName: string;
rowNumber: number;
nomAssociation: string;
emailOfficiel: string | null;
emailOfficielNormalise: string | null;
siret: string | null;
rna: string | null;
adresse: string | null;
codePostal: string | null;
ville: string | null;
telephone: string | null;
siteWeb: string | null;
facebookUrl: string | null;
instagramUrl: string | null;
dateCreation: Date | null;
objetAssociation: string | null;
statutJuridique: "association_loi_1901" | "association_reconnue_utilite_publique" | "fondation" | "autre";
nomRepresentant: string | null;
fonctionRepresentant: string | null;
sourceFingerprint: string;
};
type PreviewRow = {
sheetName: string;
rowNumber: number;
nomAssociation: string;
emailOfficiel: string | null;
ville: string | null;
status: "valid" | "missing_email" | "duplicate_email";
message: string;
};
export type DuplicateEmailGroup = {
emailOfficielNormalise: string;
emailOfficiel: string;
rowNumbers: number[];
options: Array<{
sheetName: string;
rowNumber: number;
nomAssociation: string;
ville: string | null;
}>;
};
export type DirectoryPreviewResult = {
fileName: string;
totalRows: number;
validRows: number;
missingEmailRows: number;
duplicateEmailRows: number;
previewRows: PreviewRow[];
duplicateGroups: DuplicateEmailGroup[];
allRows: ParsedDirectoryRow[];
importableRows: ParsedDirectoryRow[];
};
const headerAliases: Record<string, string[]> = {
nomAssociation: ["nom association", "association", "nom", "raison sociale", "nom de la structure"],
emailOfficiel: ["email officiel", "email", "mail", "courriel", "adresse email"],
siret: ["siret", "numéro siret", "numero siret"],
rna: ["rna", "numéro rna", "numero rna"],
adresse: ["adresse", "adresse siège", "adresse siege", "adresse du siège"],
codePostal: ["code postal", "cp"],
ville: ["ville", "commune"],
telephone: ["telephone", "téléphone", "tel", "tél", "tel.", "port.", "port"],
siteWeb: ["site web", "site", "website", "url site"],
facebookUrl: ["facebook", "facebook url", "facebook link", "lien facebook", "url facebook"],
instagramUrl: ["instagram", "instagram url", "instagram link", "lien instagram", "url instagram"],
dateCreation: ["date création", "date creation", "creation", "date de création"],
objetAssociation: ["objet", "objet association", "activité", "activités", "activite"],
statutJuridique: ["statut", "statut juridique"],
nomRepresentant: ["nom représentant", "nom representant", "président", "president", "responsable", "president"],
fonctionRepresentant: ["fonction représentant", "fonction representant", "fonction", "qualité", "qualite", "secretaire", "secrétaire"],
};
function normalizeHeader(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase();
}
function cleanString(value: unknown) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text ? text : null;
}
function extractFirstEmail(value: unknown) {
const text = cleanString(value);
if (!text) return null;
const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
return match?.[0] ?? null;
}
function normalizeEmail(value: string | null) {
return value ? value.trim().toLowerCase() : null;
}
function normalizeSiret(value: string | null) {
return value ? value.replace(/\D/g, "") || null : null;
}
function normalizeRna(value: string | null) {
return value ? value.replace(/\s+/g, "").toUpperCase() : null;
}
function normalizeSiteWeb(value: string | null) {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
return `https://${value}`;
}
function normalizeTelephone(value: string | null) {
if (!value) return null;
const first = value
.split(/[\/;,]/)
.map((part) => part.trim())
.find(Boolean);
return first ? first.slice(0, 20) : null;
}
function normalizeDate(value: unknown) {
if (value === null || value === undefined || value === "") return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value;
}
if (typeof value === "number") {
const parsed = XLSX.SSF.parse_date_code(value);
if (parsed) {
return new Date(Date.UTC(parsed.y, parsed.m - 1, parsed.d));
}
}
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function normalizeStatut(value: string | null): ParsedDirectoryRow["statutJuridique"] {
if (!value) return "association_loi_1901";
const normalized = normalizeHeader(value);
if (normalized.includes("utilite publique")) return "association_reconnue_utilite_publique";
if (normalized.includes("fondation")) return "fondation";
if (normalized.includes("1901") || normalized.includes("association")) return "association_loi_1901";
return "autre";
}
function computeFingerprint(row: Omit<ParsedDirectoryRow, "sourceFingerprint">) {
return createHash("sha256").update(JSON.stringify(row)).digest("hex");
}
function getCommuneFromSheetName(sheetName: string) {
const normalized = normalizeHeader(sheetName).replace(/[-_]/g, " ");
if (normalized.includes("kourou")) return "Kourou";
if (normalized.includes("sinnamary")) return "Sinnamary";
if (normalized.includes("iracoubo")) return "Iracoubo";
if (normalized.includes("st elie") || normalized.includes("saint elie")) return "Saint-Élie";
return null;
}
function resolveColumnIndex(headers: string[], field: keyof typeof headerAliases) {
const aliases = headerAliases[field];
return headers.findIndex(header => aliases.includes(normalizeHeader(header)));
}
function readCell(row: unknown[], headers: string[], field: keyof typeof headerAliases) {
const index = resolveColumnIndex(headers, field);
if (index === -1) return null;
return row[index];
}
function resolveEmailValue(row: unknown[], headers: string[]) {
const direct = extractFirstEmail(readCell(row, headers, "emailOfficiel"));
if (direct) return direct;
for (const cell of row) {
const extracted = extractFirstEmail(cell);
if (extracted) return extracted;
}
return null;
}
export function parseAssociationDirectoryWorkbook(fileBuffer: Buffer, fileName: string): DirectoryPreviewResult {
const workbook = XLSX.read(fileBuffer, { type: "buffer", cellDates: true });
if (workbook.SheetNames.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier Excel ne contient aucune feuille exploitable" });
}
const importableRows: ParsedDirectoryRow[] = [];
const previewRows: PreviewRow[] = [];
const emailRowMap = new Map<string, number[]>();
let hasAnyUsableSheet = false;
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return;
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: null });
if (rows.length < 2) return;
const headers = (rows[0] || []).map(value => String(value ?? ""));
if (resolveColumnIndex(headers, "nomAssociation") === -1) return;
hasAnyUsableSheet = true;
const communeFromSheet = getCommuneFromSheetName(sheetName);
rows.slice(1).forEach((rawRow, index) => {
const rowNumber = index + 2;
const nomAssociation = cleanString(readCell(rawRow, headers, "nomAssociation"));
if (!nomAssociation) {
return;
}
const emailOfficiel = resolveEmailValue(rawRow, headers);
const emailOfficielNormalise = normalizeEmail(emailOfficiel);
const rawAdresse = cleanString(readCell(rawRow, headers, "adresse"));
const parsedRowBase = {
sheetName,
rowNumber,
nomAssociation,
emailOfficiel,
emailOfficielNormalise,
siret: normalizeSiret(cleanString(readCell(rawRow, headers, "siret"))),
rna: normalizeRna(cleanString(readCell(rawRow, headers, "rna"))),
adresse: rawAdresse && extractFirstEmail(rawAdresse) ? null : rawAdresse,
codePostal: cleanString(readCell(rawRow, headers, "codePostal")),
ville: communeFromSheet || cleanString(readCell(rawRow, headers, "ville")),
telephone: normalizeTelephone(cleanString(readCell(rawRow, headers, "telephone"))),
siteWeb: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "siteWeb"))),
facebookUrl: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "facebookUrl"))),
instagramUrl: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "instagramUrl"))),
dateCreation: normalizeDate(readCell(rawRow, headers, "dateCreation")),
objetAssociation: cleanString(readCell(rawRow, headers, "objetAssociation")),
statutJuridique: normalizeStatut(cleanString(readCell(rawRow, headers, "statutJuridique"))),
nomRepresentant: cleanString(readCell(rawRow, headers, "nomRepresentant")),
fonctionRepresentant: cleanString(readCell(rawRow, headers, "fonctionRepresentant")),
};
const parsedRow: ParsedDirectoryRow = {
...parsedRowBase,
sourceFingerprint: computeFingerprint(parsedRowBase),
};
importableRows.push(parsedRow);
if (emailOfficielNormalise) {
const refs = emailRowMap.get(emailOfficielNormalise) || [];
refs.push(rowNumber);
emailRowMap.set(emailOfficielNormalise, refs);
}
});
});
if (!hasAnyUsableSheet || importableRows.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le fichier Excel ne contient pas de feuille exploitable avec une colonne de nom d'association",
});
}
importableRows.forEach(row => {
const duplicateRows = row.emailOfficielNormalise ? emailRowMap.get(row.emailOfficielNormalise) || [] : [];
if (!row.emailOfficielNormalise) {
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "missing_email",
message: `Feuille ${row.sheetName} : email officiel manquant, la ligne ne pourra pas être rattachée automatiquement`,
});
return;
}
if (duplicateRows.length > 1) {
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "duplicate_email",
message: `Feuille ${row.sheetName} : email dupliqué dans le fichier (lignes ${duplicateRows.join(", ")})`,
});
return;
}
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "valid",
message: `Feuille ${row.sheetName} : ligne prête à être importée`,
});
});
const importableRowKeys = new Set(
previewRows
.filter(row => row.status !== "duplicate_email")
.map(row => `${row.rowNumber}::${row.nomAssociation}`)
);
const duplicateGroups: DuplicateEmailGroup[] = Array.from(emailRowMap.entries())
.filter(([, rowNumbers]) => rowNumbers.length > 1)
.map(([emailOfficielNormalise, rowNumbers]) => {
const options = importableRows
.filter(row => row.emailOfficielNormalise === emailOfficielNormalise)
.map(row => ({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
ville: row.ville,
}));
return {
emailOfficielNormalise,
emailOfficiel: options.length > 0 ? importableRows.find(row => row.emailOfficielNormalise === emailOfficielNormalise)?.emailOfficiel || emailOfficielNormalise : emailOfficielNormalise,
rowNumbers,
options,
};
});
return {
fileName,
totalRows: importableRows.length,
validRows: previewRows.filter(row => row.status === "valid").length,
missingEmailRows: previewRows.filter(row => row.status === "missing_email").length,
duplicateEmailRows: previewRows.filter(row => row.status === "duplicate_email").length,
previewRows,
duplicateGroups,
allRows: importableRows,
importableRows: importableRows.filter(row => importableRowKeys.has(`${row.rowNumber}::${row.nomAssociation}`)),
};
}
export function resolveAssociationDirectoryImportRows(
preview: DirectoryPreviewResult,
duplicateSelections?: Record<string, number>,
) {
const selectedDuplicateKeys = new Set<string>();
Object.entries(duplicateSelections || {}).forEach(([email, rowNumber]) => {
const numericRow = Number(rowNumber);
if (Number.isFinite(numericRow) && numericRow > 0) {
selectedDuplicateKeys.add(`${email}::${numericRow}`);
}
});
return preview.allRows.filter((row) => {
if (!row.emailOfficielNormalise) {
return true;
}
const isDuplicate = preview.duplicateGroups.some(group => group.emailOfficielNormalise === row.emailOfficielNormalise);
if (!isDuplicate) {
return true;
}
return selectedDuplicateKeys.has(`${row.emailOfficielNormalise}::${row.rowNumber}`);
});
}
export function createAssociationProfileFromDirectoryEntry(userId: number, entry: AssociationDirectoryEntry): InsertAssociation {
const governance = entry.nomRepresentant || entry.fonctionRepresentant
? serializeAssociationGovernance({
representantLegal: {
nom: entry.nomRepresentant || "",
prenom: "",
email: "",
telephone: "",
fonction: normalizeLegalRepresentativeRole(entry.fonctionRepresentant),
},
membres: [],
})
: null;
return {
userId,
sourceDirectoryEntryId: entry.id,
nomAssociation: entry.nomAssociation,
siret: entry.siret ?? null,
rna: entry.rna ?? null,
thematique: entry.thematique ?? null,
adresse: entry.adresse ?? null,
codePostal: entry.codePostal ?? null,
ville: entry.ville ?? null,
telephone: entry.telephone ?? null,
emailContact: entry.emailOfficiel ?? null,
siteWeb: entry.siteWeb ?? null,
facebookUrl: entry.facebookUrl ?? null,
instagramUrl: entry.instagramUrl ?? null,
dateCreation: entry.dateCreation ?? null,
objetAssociation: entry.objetAssociation ?? null,
statutJuridique: entry.statutJuridique ?? "association_loi_1901",
nomRepresentant: entry.nomRepresentant ?? null,
fonctionRepresentant: entry.fonctionRepresentant ?? null,
gouvernance: governance,
profileComplete: Boolean(entry.nomAssociation && entry.adresse && entry.ville),
isActive: true,
};
}

View file

@ -0,0 +1,183 @@
import type { AssociationDirectoryEntry } from "../drizzle/schema";
export type AssociationDirectoryMatchInput = {
nomAssociation?: string | null;
email?: string | null;
siret?: string | null;
rna?: string | null;
ville?: string | null;
};
export type AssociationDirectoryMatchCandidate = Pick<
AssociationDirectoryEntry,
"id" | "nomAssociation" | "emailOfficiel" | "siret" | "rna" | "ville"
>;
export type AssociationDirectoryMatchResult = {
status: "matched" | "ambiguous" | "none";
matchedEntry?: AssociationDirectoryMatchCandidate;
candidates: AssociationDirectoryMatchCandidate[];
reason: string;
};
function normalizeText(value?: string | null) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase();
}
export function normalizeDirectoryEmail(value?: string | null) {
const trimmed = String(value || "").trim().toLowerCase();
return trimmed || null;
}
export function normalizeDirectorySiret(value?: string | null) {
const digits = String(value || "").replace(/\D/g, "");
return digits || null;
}
export function normalizeDirectoryRna(value?: string | null) {
const normalized = String(value || "").replace(/\s+/g, "").trim().toUpperCase();
return normalized || null;
}
function candidateScore(candidate: AssociationDirectoryMatchCandidate, input: AssociationDirectoryMatchInput) {
const localName = normalizeText(input.nomAssociation);
const localCity = normalizeText(input.ville);
const localEmail = normalizeDirectoryEmail(input.email);
const localSiret = normalizeDirectorySiret(input.siret);
const localRna = normalizeDirectoryRna(input.rna);
const candidateName = normalizeText(candidate.nomAssociation);
const candidateCity = normalizeText(candidate.ville);
const candidateEmail = normalizeDirectoryEmail(candidate.emailOfficiel);
const candidateSiret = normalizeDirectorySiret(candidate.siret);
const candidateRna = normalizeDirectoryRna(candidate.rna);
let score = 0;
const exactSiret = Boolean(localSiret && candidateSiret && localSiret === candidateSiret);
const exactRna = Boolean(localRna && candidateRna && localRna === candidateRna);
const exactEmail = Boolean(localEmail && candidateEmail && localEmail === candidateEmail);
const exactName = Boolean(localName && candidateName && localName === candidateName);
const exactCity = Boolean(localCity && candidateCity && localCity === candidateCity);
if (exactSiret) score += 300;
if (exactRna) score += 260;
if (exactEmail) score += 220;
if (exactName) score += 120;
if (exactCity) score += 25;
if (!exactName && localName && candidateName) {
if (candidateName.includes(localName) || localName.includes(candidateName)) {
score += 40;
}
}
return { score, exactSiret, exactRna, exactEmail, exactName, exactCity };
}
export function matchAssociationDirectoryEntry(
entries: AssociationDirectoryMatchCandidate[],
input: AssociationDirectoryMatchInput
): AssociationDirectoryMatchResult {
const normalizedName = normalizeText(input.nomAssociation);
const normalizedEmail = normalizeDirectoryEmail(input.email);
const normalizedSiret = normalizeDirectorySiret(input.siret);
const normalizedRna = normalizeDirectoryRna(input.rna);
const normalizedCity = normalizeText(input.ville);
if (!normalizedName && !normalizedEmail && !normalizedSiret && !normalizedRna) {
return {
status: "none",
candidates: [],
reason: "Aucun identifiant exploitable n'a été fourni pour rechercher une fiche du bordereau.",
};
}
const exactSiret = entries.filter((entry) => normalizeDirectorySiret(entry.siret) === normalizedSiret && normalizedSiret);
if (exactSiret.length === 1) {
return { status: "matched", matchedEntry: exactSiret[0], candidates: exactSiret, reason: "Correspondance validée par le SIRET." };
}
if (exactSiret.length > 1) {
return { status: "ambiguous", candidates: exactSiret, reason: "Plusieurs fiches du bordereau portent le même SIRET." };
}
const exactRna = entries.filter((entry) => normalizeDirectoryRna(entry.rna) === normalizedRna && normalizedRna);
if (exactRna.length === 1) {
return { status: "matched", matchedEntry: exactRna[0], candidates: exactRna, reason: "Correspondance validée par le RNA." };
}
if (exactRna.length > 1) {
return { status: "ambiguous", candidates: exactRna, reason: "Plusieurs fiches du bordereau portent le même RNA." };
}
const exactEmail = entries.filter((entry) => normalizeDirectoryEmail(entry.emailOfficiel) === normalizedEmail && normalizedEmail);
if (exactEmail.length === 1) {
return { status: "matched", matchedEntry: exactEmail[0], candidates: exactEmail, reason: "Correspondance validée par l'email officiel." };
}
if (exactEmail.length > 1) {
return { status: "ambiguous", candidates: exactEmail, reason: "Plusieurs fiches du bordereau utilisent le même email officiel." };
}
const scored = entries
.map((entry) => ({ entry, ...candidateScore(entry, input) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score);
const exactNameAndCity = scored.filter((entry) => entry.exactName && entry.exactCity);
if (exactNameAndCity.length === 1) {
return {
status: "matched",
matchedEntry: exactNameAndCity[0].entry,
candidates: exactNameAndCity.map((entry) => entry.entry),
reason: "Correspondance validée par le nom et la commune.",
};
}
if (exactNameAndCity.length > 1) {
return {
status: "ambiguous",
candidates: exactNameAndCity.map((entry) => entry.entry),
reason: "Plusieurs fiches du bordereau correspondent au même nom dans cette commune.",
};
}
const exactNameOnly = scored.filter((entry) => entry.exactName);
if (exactNameOnly.length === 1) {
return {
status: "matched",
matchedEntry: exactNameOnly[0].entry,
candidates: exactNameOnly.map((entry) => entry.entry),
reason: "Correspondance validée par le nom de l'association.",
};
}
if (exactNameOnly.length > 1) {
return {
status: "ambiguous",
candidates: exactNameOnly.map((entry) => entry.entry),
reason: "Plusieurs fiches du bordereau portent le même nom.",
};
}
const closeCandidates = scored
.filter((entry) => entry.score >= 40)
.map((entry) => entry.entry)
.slice(0, 5);
if (closeCandidates.length > 0) {
return {
status: "ambiguous",
candidates: closeCandidates,
reason: normalizedCity
? "Des rapprochements partiels ont été trouvés, mais aucun n'est assez fiable pour lier automatiquement cette association."
: "Des rapprochements potentiels ont été trouvés, mais une validation humaine reste nécessaire.",
};
}
return {
status: "none",
candidates: [],
reason: "Aucune fiche du bordereau ne correspond de façon fiable à cette association.",
};
}

View file

@ -0,0 +1,180 @@
import type { Association, AssociationDirectoryEntry, InsertAssociationDirectoryEntry } from "../drizzle/schema";
import type { AssociationGeoPrecision, AssociationGeoSource } from "@shared/associationGeo";
type CoordinateSet = {
latitude: string | null;
longitude: string | null;
geoSource: AssociationGeoSource | null;
externalSourceStatus: string;
externalSourceLabel: string | null;
};
function formatCoordinate(value: number | null) {
if (value === null || Number.isNaN(value)) return null;
return value.toFixed(6);
}
function buildAddressQuery(entry: AssociationDirectoryEntry, association?: Association | null) {
const adresse = association?.adresse || entry.adresse;
const codePostal = association?.codePostal || entry.codePostal;
const ville = association?.ville || entry.ville;
return [adresse, codePostal, ville].filter(Boolean).join(" ").trim();
}
async function geocodeAddress(query: string) {
const url = new URL("https://api-adresse.data.gouv.fr/search/");
url.searchParams.set("q", query);
url.searchParams.set("limit", "1");
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"User-Agent": "portail-associations/1.0",
},
});
if (!response.ok) {
throw new Error(`Adresse API returned ${response.status}`);
}
const payload = await response.json() as {
features?: Array<{
geometry?: { coordinates?: [number, number] };
properties?: { label?: string };
}>;
};
const first = payload.features?.[0];
const coordinates = first?.geometry?.coordinates;
if (!coordinates || coordinates.length < 2) {
return null;
}
return {
latitude: coordinates[1],
longitude: coordinates[0],
label: first?.properties?.label || null,
};
}
async function fetchCommuneCenter(ville: string, codePostal?: string | null) {
const url = new URL("https://geo.api.gouv.fr/communes");
url.searchParams.set("nom", ville);
url.searchParams.set("fields", "nom,centre,code,codesPostaux");
url.searchParams.set("format", "json");
url.searchParams.set("geometry", "centre");
if (codePostal) {
url.searchParams.set("codePostal", codePostal);
}
const response = await fetch(url, {
headers: {
"Accept": "application/json",
"User-Agent": "portail-associations/1.0",
},
});
if (!response.ok) {
throw new Error(`Geo API returned ${response.status}`);
}
const payload = await response.json() as Array<{
nom?: string;
centre?: { coordinates?: [number, number] };
}>;
const first = payload[0];
const coordinates = first?.centre?.coordinates;
if (!coordinates || coordinates.length < 2) {
return null;
}
return {
latitude: coordinates[1],
longitude: coordinates[0],
label: first?.nom || ville,
};
}
export async function computeAssociationDirectoryGeoUpdate(entry: AssociationDirectoryEntry, association?: Association | null) {
return computeAssociationDirectoryGeoUpdateForPrecision(
entry,
association,
(entry.geoPrecision as AssociationGeoPrecision | null) || "commune_center"
);
}
export async function computeAssociationDirectoryGeoUpdateForPrecision(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
preferredPrecision: AssociationGeoPrecision
) {
const updates: Partial<InsertAssociationDirectoryEntry> = {
geoLastSyncedAt: new Date(),
geoPrecision: preferredPrecision,
};
const addressQuery = buildAddressQuery(entry, association);
if (preferredPrecision === "exact_address" && addressQuery) {
try {
const geocoded = await geocodeAddress(addressQuery);
if (geocoded) {
updates.latitude = formatCoordinate(geocoded.latitude);
updates.longitude = formatCoordinate(geocoded.longitude);
updates.geoSource = "adresse_gouv";
updates.externalSourceStatus = "geocoded_from_address";
updates.externalSourceLabel = geocoded.label || "Adresse.data.gouv.fr";
return updates;
}
} catch {
// Fall back to commune center below.
}
}
const ville = association?.ville || entry.ville;
const codePostal = association?.codePostal || entry.codePostal;
if (ville) {
try {
const communeCenter = await fetchCommuneCenter(ville, codePostal);
if (communeCenter) {
updates.latitude = formatCoordinate(communeCenter.latitude);
updates.longitude = formatCoordinate(communeCenter.longitude);
updates.geoSource = "commune_center";
updates.externalSourceStatus = "commune_center_fallback";
updates.externalSourceLabel = communeCenter.label || ville;
return updates;
}
} catch {
// Fall through to unavailable state.
}
}
updates.latitude = null;
updates.longitude = null;
updates.geoSource = null;
updates.externalSourceStatus = "unresolved";
updates.externalSourceLabel = null;
return updates;
}
export function getPublicMapCoordinates(entry: AssociationDirectoryEntry) {
if (!entry.latitude || !entry.longitude) {
return null;
}
const latitude = Number(entry.latitude);
const longitude = Number(entry.longitude);
if (Number.isNaN(latitude) || Number.isNaN(longitude)) {
return null;
}
return {
latitude,
longitude,
precision: (
entry.geoPrecision === "hidden"
? (entry.geoSource === "commune_center" ? "commune_center" : "exact_address")
: entry.geoPrecision
) as AssociationGeoPrecision,
};
}

View file

@ -0,0 +1,442 @@
import type { Association, AssociationDirectoryEntry, InsertAssociationDirectoryEntry } from "../drizzle/schema";
export const HELLOASSO_SETTINGS_KEY = "system.associationDirectory.helloasso";
export type HelloAssoSettings = {
enabled: boolean;
clientId: string;
clientSecret: string;
};
export type HelloAssoSettingsPublic = {
enabled: boolean;
clientId: string;
clientSecretConfigured: boolean;
};
type HelloAssoTokenResponse = {
access_token?: string;
token_type?: string;
expires_in?: number;
};
type HelloAssoDirectoryItem = {
action?: string | null;
record?: {
url?: string | null;
organizationSlug?: string | null;
} | null;
};
type HelloAssoDirectoryResponse = {
data?: HelloAssoDirectoryItem[] | null;
pagination?: {
continuationToken?: string | null;
} | null;
};
type HelloAssoOrganizationPublic = {
facebookPage?: string | null;
longDescription?: string | null;
webSite?: string | null;
address?: string | null;
rnaNumber?: string | null;
name?: string | null;
city?: string | null;
zipCode?: string | null;
description?: string | null;
updateDate?: string | null;
url?: string | null;
organizationSlug?: string | null;
};
type CandidateScore = {
slug: string;
detail: HelloAssoOrganizationPublic;
score: number;
exactRna: boolean;
exactName: boolean;
exactCity: boolean;
exactZipCode: boolean;
};
export type HelloAssoSyncResult = {
matched: boolean;
reason: string;
slug?: string;
candidateCount: number;
updates: Partial<InsertAssociationDirectoryEntry>;
};
export class HelloAssoSyncClient {
private accessTokenPromise: Promise<string> | null = null;
constructor(private readonly settings: HelloAssoSettings) {}
async getAccessToken() {
if (!this.accessTokenPromise) {
this.accessTokenPromise = fetchHelloAssoAccessToken(this.settings);
}
return this.accessTokenPromise;
}
}
export function sanitizeHelloAssoSettings(rawValue?: unknown): HelloAssoSettings {
const source = rawValue && typeof rawValue === "object" ? rawValue as Record<string, unknown> : {};
return {
enabled: source.enabled !== false,
clientId: typeof source.clientId === "string" ? source.clientId.trim() : "",
clientSecret: typeof source.clientSecret === "string" ? source.clientSecret.trim() : "",
};
}
export function serializeHelloAssoSettingsPublic(settings: HelloAssoSettings): HelloAssoSettingsPublic {
return {
enabled: settings.enabled,
clientId: settings.clientId,
clientSecretConfigured: settings.clientSecret.length > 0,
};
}
function normalizeText(value: string | null | undefined) {
return (value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase();
}
function cleanDigits(value: string | null | undefined) {
return (value || "").replace(/\D/g, "");
}
function normalizeRna(value: string | null | undefined) {
const trimmed = (value || "").trim().toUpperCase();
return /^W\d{9}$/.test(trimmed) ? trimmed : null;
}
function normalizeZipCode(value: string | null | undefined) {
const digits = cleanDigits(value);
return digits.length >= 5 ? digits.slice(0, 5) : null;
}
function parseDate(value: string | null | undefined) {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function normalizeOptionalUrl(value: string | null | undefined) {
const trimmed = String(value || "").trim();
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
function normalizeOptionalText(value: string | null | undefined) {
const trimmed = String(value || "").trim();
return trimmed || null;
}
function buildSearchBodies(entry: AssociationDirectoryEntry, association?: Association | null) {
const name = normalizeOptionalText(association?.nomAssociation || entry.nomAssociation);
const city = normalizeOptionalText(association?.ville || entry.ville);
const zipCode = normalizeZipCode(association?.codePostal || entry.codePostal);
const variants = [
{
name,
...(city ? { cities: [city] } : {}),
...(zipCode ? { zipCodes: [zipCode] } : {}),
},
{
name,
...(city ? { cities: [city] } : {}),
},
{
name,
...(zipCode ? { zipCodes: [zipCode] } : {}),
},
{
name,
},
];
const seen = new Set<string>();
return variants.filter((variant) => {
if (!variant.name) return false;
const key = JSON.stringify(variant);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function fetchHelloAssoJson<T>(path: string, token: string, init?: RequestInit) {
const response = await fetch(`https://api.helloasso.com/v5${path}`, {
...init,
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...(init?.headers || {}),
},
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`HelloAsso HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
}
return await response.json() as T;
}
async function fetchHelloAssoAccessToken(settings: HelloAssoSettings) {
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: settings.clientId,
client_secret: settings.clientSecret,
});
const response = await fetch("https://api.helloasso.com/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!response.ok) {
const raw = await response.text().catch(() => "");
throw new Error(
response.status === 401 || response.status === 403
? "Identifiants HelloAsso invalides ou non autorisés"
: `Impossible d'obtenir un jeton HelloAsso (${response.status})${raw ? `: ${raw.slice(0, 160)}` : ""}`
);
}
const payload = await response.json() as HelloAssoTokenResponse;
if (!payload.access_token) {
throw new Error("HelloAsso n'a pas renvoyé de jeton d'accès exploitable");
}
return payload.access_token;
}
async function searchHelloAssoDirectory(
token: string,
entry: AssociationDirectoryEntry,
association?: Association | null
) {
const slugs = new Set<string>();
for (const body of buildSearchBodies(entry, association)) {
try {
const response = await fetchHelloAssoJson<HelloAssoDirectoryResponse>("/directory/organizations?pageSize=8", token, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
for (const item of response.data || []) {
const slug = item.record?.organizationSlug?.trim();
if (!slug) continue;
if ((item.action || "").toLowerCase() === "delete") continue;
slugs.add(slug);
}
if (slugs.size > 0) {
break;
}
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
if (message.includes("403")) {
throw new Error("Le client HelloAsso doit disposer du privilège OrganizationOpenDirectory pour interroger le répertoire.");
}
throw error;
}
}
return Array.from(slugs);
}
function scoreHelloAssoCandidate(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
detail: HelloAssoOrganizationPublic
): CandidateScore {
const localName = normalizeText(association?.nomAssociation || entry.nomAssociation);
const localCity = normalizeText(association?.ville || entry.ville);
const localZipCode = normalizeZipCode(association?.codePostal || entry.codePostal);
const localRna = normalizeRna(association?.rna || entry.rna);
const remoteName = normalizeText(detail.name);
const remoteCity = normalizeText(detail.city);
const remoteZipCode = normalizeZipCode(detail.zipCode);
const remoteRna = normalizeRna(detail.rnaNumber);
const exactRna = Boolean(localRna && remoteRna && localRna === remoteRna);
const exactName = Boolean(localName && remoteName && localName === remoteName);
const exactCity = Boolean(localCity && remoteCity && localCity === remoteCity);
const exactZipCode = Boolean(localZipCode && remoteZipCode && localZipCode === remoteZipCode);
let score = 0;
if (exactRna) score += 200;
if (exactName) score += 90;
else if (remoteName && (remoteName.includes(localName) || localName.includes(remoteName))) score += 35;
if (exactCity) score += 20;
if (exactZipCode) score += 20;
if (normalizeOptionalUrl(detail.webSite) && normalizeOptionalUrl(detail.webSite) === normalizeOptionalUrl(association?.siteWeb || entry.siteWeb)) {
score += 30;
}
return {
slug: detail.organizationSlug || "",
detail,
score,
exactRna,
exactName,
exactCity,
exactZipCode,
};
}
function pickHelloAssoCandidate(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
details: HelloAssoOrganizationPublic[]
) {
const scored = details
.filter((detail) => Boolean(detail.organizationSlug))
.map((detail) => scoreHelloAssoCandidate(entry, association, detail))
.sort((a, b) => b.score - a.score);
if (scored.length === 0) return null;
const exactRna = scored.filter((candidate) => candidate.exactRna);
if (exactRna.length === 1) return exactRna[0];
const exactNameAndLocation = scored.filter((candidate) => candidate.exactName && (candidate.exactCity || candidate.exactZipCode));
if (exactNameAndLocation.length === 1) return exactNameAndLocation[0];
const exactNameOnly = scored.filter((candidate) => candidate.exactName);
if (exactNameOnly.length === 1) return exactNameOnly[0];
const [best, second] = scored;
if (best && best.score >= 120 && (!second || best.score - second.score >= 20)) {
return best;
}
return null;
}
function buildHelloAssoUpdates(detail: HelloAssoOrganizationPublic): Partial<InsertAssociationDirectoryEntry> {
const description = normalizeOptionalText(detail.longDescription) || normalizeOptionalText(detail.description);
const address = normalizeOptionalText(detail.address);
const city = normalizeOptionalText(detail.city);
const zipCode = normalizeZipCode(detail.zipCode);
const webSite = normalizeOptionalUrl(detail.webSite);
const facebookPage = normalizeOptionalUrl(detail.facebookPage);
const rna = normalizeRna(detail.rnaNumber);
const updateDate = parseDate(detail.updateDate);
const slug = normalizeOptionalText(detail.organizationSlug);
return {
...(rna ? { rna } : {}),
...(address ? { adresse: address } : {}),
...(city ? { ville: city } : {}),
...(zipCode ? { codePostal: zipCode } : {}),
...(webSite ? { siteWeb: webSite } : {}),
...(facebookPage ? { facebookUrl: facebookPage } : {}),
...(description ? { objetAssociation: description } : {}),
externalSourceStatus: "helloasso_synced",
externalSourceLabel: slug ? `HelloAsso · ${slug}` : "HelloAsso",
...(updateDate ? { registryLastUpdatedAt: updateDate } : {}),
};
}
export async function computeAssociationDirectoryHelloAssoUpdate(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
settings: HelloAssoSettings,
client?: HelloAssoSyncClient
): Promise<HelloAssoSyncResult> {
if (!settings.enabled) {
return {
matched: false,
reason: "La synchronisation HelloAsso est désactivée.",
candidateCount: 0,
updates: {
externalSourceStatus: "helloasso_disabled",
externalSourceLabel: "HelloAsso",
},
};
}
if (!settings.clientId || !settings.clientSecret) {
return {
matched: false,
reason: "Les identifiants HelloAsso ne sont pas configurés.",
candidateCount: 0,
updates: {
externalSourceStatus: "helloasso_not_configured",
externalSourceLabel: "HelloAsso",
},
};
}
const syncClient = client || new HelloAssoSyncClient(settings);
const token = await syncClient.getAccessToken();
const slugs = await searchHelloAssoDirectory(token, entry, association);
if (slugs.length === 0) {
return {
matched: false,
reason: "Aucun organisme HelloAsso compatible n'a été trouvé pour cette association.",
candidateCount: 0,
updates: {
externalSourceStatus: "helloasso_no_match",
externalSourceLabel: "HelloAsso",
},
};
}
const details = await Promise.all(
slugs.map(async (slug) => {
try {
return await fetchHelloAssoJson<HelloAssoOrganizationPublic>(`/organizations/${encodeURIComponent(slug)}`, token);
} catch {
return null;
}
})
);
const matched = pickHelloAssoCandidate(entry, association, details.filter(Boolean) as HelloAssoOrganizationPublic[]);
if (!matched) {
return {
matched: false,
reason: "Des résultats HelloAsso ont été trouvés, mais aucun rapprochement n'est assez fiable pour mettre à jour la fiche automatiquement.",
candidateCount: details.filter(Boolean).length,
updates: {
externalSourceStatus: "helloasso_ambiguous_match",
externalSourceLabel: "HelloAsso",
},
};
}
return {
matched: true,
reason: matched.exactRna
? "Correspondance HelloAsso validée par le RNA."
: matched.exactName && (matched.exactCity || matched.exactZipCode)
? "Correspondance HelloAsso validée par le nom et la localisation."
: "Correspondance HelloAsso validée par le nom de l'association.",
slug: matched.slug,
candidateCount: details.filter(Boolean).length,
updates: buildHelloAssoUpdates(matched.detail),
};
}

View file

@ -0,0 +1,44 @@
export const associationCommuneOptions = [
{ value: "all", label: "Toutes" },
{ value: "kourou", label: "Kourou" },
{ value: "sinnamary", label: "Sinnamary" },
{ value: "iracoubo", label: "Iracoubo" },
{ value: "saint_elie", label: "Saint-Élie" },
] as const;
export type AssociationCommuneFilter = (typeof associationCommuneOptions)[number]["value"];
const communeVariantMap: Record<Exclude<AssociationCommuneFilter, "all">, string[]> = {
kourou: ["Kourou"],
sinnamary: ["Sinnamary"],
iracoubo: ["Iracoubo"],
saint_elie: ["Saint-Élie", "Saint Elie", "ST ELIE", "St Elie", "St-Élie", "Saint-Elie"],
};
export function getAssociationCommuneLabel(value: AssociationCommuneFilter) {
return associationCommuneOptions.find(option => option.value === value)?.label ?? "Toutes";
}
export function normalizeAssociationCommune(value: string | null | undefined): AssociationCommuneFilter {
const normalized = (value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[-_]/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
if (normalized.includes("saint elie") || normalized.includes("st elie")) return "saint_elie";
if (normalized.includes("kourou")) return "kourou";
if (normalized.includes("sinnamary")) return "sinnamary";
if (normalized.includes("iracoubo")) return "iracoubo";
return "all";
}
export function getAssociationCommuneVariants(value?: string | null) {
if (!value || value === "all") {
return [];
}
return communeVariantMap[value as Exclude<AssociationCommuneFilter, "all">] || [];
}

View file

@ -0,0 +1,29 @@
export const associationGeoSources = [
"manual",
"adresse_gouv",
"dataasso",
"commune_center",
] as const;
export type AssociationGeoSource = (typeof associationGeoSources)[number];
export const associationGeoPrecisions = [
"exact_address",
"commune_center",
"hidden",
] as const;
export type AssociationGeoPrecision = (typeof associationGeoPrecisions)[number];
export const associationGeoSourceLabels: Record<AssociationGeoSource, string> = {
manual: "Position définie manuellement",
adresse_gouv: "Adresse.data.gouv.fr",
dataasso: "DataAsso / référentiel association",
commune_center: "Centre de commune",
};
export const associationGeoPrecisionLabels: Record<AssociationGeoPrecision, string> = {
exact_address: "Adresse exacte",
commune_center: "Centre de commune",
hidden: "Masquée du public",
};

View file

@ -0,0 +1,88 @@
export const associationThematicValues = [
"culture_loisirs",
"social_sante",
"education_formation",
"economie_territoire",
"environnement_patrimoine",
"institutions_divers",
] as const;
export type AssociationThematic = typeof associationThematicValues[number];
export const associationThematicDefinitions: Record<
AssociationThematic,
{ label: string; description: string }
> = {
culture_loisirs: {
label: "Culture & Loisirs",
description: "Arts, sport, chasse, pêche, activités civiques et religieuses.",
},
social_sante: {
label: "Social & Santé",
description: "Caritatif, humanitaire, aide aux seniors, santé, services aux familles.",
},
education_formation: {
label: "Éducation et formation",
description: "Écoles, formation continue, apprentissage.",
},
economie_territoire: {
label: "Économie & Territoire",
description: "Emploi, insertion, logement, tourisme, défense d'intérêts économiques.",
},
environnement_patrimoine: {
label: "Environnement et patrimoine",
description: "Écologie, cadre de vie, protection des monuments.",
},
institutions_divers: {
label: "Institutions & Divers",
description: "Justice, sécurité civile, recherche, activités politiques.",
},
};
export const associationThematicOptions = associationThematicValues.map((value) => ({
value,
label: associationThematicDefinitions[value].label,
description: associationThematicDefinitions[value].description,
}));
export function getAssociationThematicLabel(value: AssociationThematic | string | null | undefined) {
if (!value) return "Non renseignée";
return associationThematicDefinitions[value as AssociationThematic]?.label ?? value;
}
export function getAssociationThematicDescription(value: AssociationThematic | string | null | undefined) {
if (!value) return "";
return associationThematicDefinitions[value as AssociationThematic]?.description ?? "";
}
export function parseAssociationThematics(value: string | null | undefined): AssociationThematic[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.filter((item): item is AssociationThematic =>
associationThematicValues.includes(item as AssociationThematic)
);
}
} catch {
if (associationThematicValues.includes(value as AssociationThematic)) {
return [value as AssociationThematic];
}
}
return [];
}
export function serializeAssociationThematics(values: Array<AssociationThematic | string> | null | undefined) {
const normalized = Array.from(
new Set(
(values || []).filter((item): item is AssociationThematic =>
associationThematicValues.includes(item as AssociationThematic)
)
)
);
return normalized.length > 0 ? JSON.stringify(normalized) : null;
}
export function getAssociationThematicLabels(values: Array<AssociationThematic | string> | null | undefined) {
return (values || []).map((value) => getAssociationThematicLabel(value));
}