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

Binary file not shown.

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));
}

View file

@ -0,0 +1,73 @@
{
"status": "BROUILLON A COMPLETER",
"invoiceNumber": "FA-CCDS-2026-06-11-BROUILLON",
"issueDate": "11/06/2026",
"dueDate": "A completer",
"issuer": {
"name": "Nom / raison sociale a completer",
"addressLines": [
"Adresse a completer",
"Code postal et ville a completer"
],
"siret": "SIRET a completer",
"email": "Email a completer",
"phone": "Telephone a completer",
"vat": "Regime TVA a completer"
},
"recipient": {
"name": "Communaute de Communes des Savanes",
"addressLines": [
"Quartier Cabalou",
"1 rue Raymond Cresson",
"97310 Kourou"
],
"siret": "200 027 548 00029",
"reference": "Facture adressee a la CCDS"
},
"project": {
"title": "Portail des associations CCDS",
"url": "https://www.portail-association973.com"
},
"lineItems": [
{
"label": "Conception, developpement, integration et finalisation du portail web",
"details": [
"Projet realise pour le portail des associations de la CCDS",
"Travail incluant structuration fonctionnelle, developpements, corrections, ajustements metier et mise en coherence generale du site"
],
"quantity": "1",
"unitPrice": "A completer",
"total": "A completer"
},
{
"label": "Deploiement, exploitation technique, optimisation continue et accompagnement a la mise en production",
"details": [
"Mises a jour successives, stabilisation, finalisation des workflows, mise en place de la preproduction et du tour operationnel"
],
"quantity": "1",
"unitPrice": "A completer",
"total": "A completer"
},
{
"label": "Location / hebergement des serveurs OVH",
"details": [
"Portail heberge sur infrastructure OVH",
"Periode de facturation a completer"
],
"quantity": "1",
"unitPrice": "A completer",
"total": "A completer"
}
],
"totals": {
"subtotalHt": "A completer",
"vatAmount": "A completer",
"totalTtc": "A completer"
},
"notes": [
"Projet initie avec Manus IA puis repris et approfondi sous Codex jusqu'a un niveau de quasi finalisation.",
"La montee en competence technique a ete facilitee par William, ingenieur informatique, qui a accompagne l'usage de Manus IA et l'appropriation de termes techniques pour accelerer l'execution.",
"Par honnetete de facturation, cet accompagnement n'est pas facture ici comme ligne separee sauf accord explicite contraire.",
"RIB / IBAN et modalites de reglement a joindre dans la version finale."
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

View file

@ -0,0 +1,61 @@
{
"status": "FACTURE A COMPLETER AVANT ENVOI",
"invoiceNumber": "FA-CCDS-2026-06-11-HONNETE",
"issueDate": "11/06/2026",
"dueDate": "A completer",
"issuer": {
"name": "Nom / raison sociale a completer",
"addressLines": [
"Adresse a completer",
"Code postal et ville a completer"
],
"siret": "SIRET a completer",
"email": "Email a completer",
"phone": "Telephone a completer",
"vat": "Regime TVA a completer"
},
"recipient": {
"name": "Communaute de Communes des Savanes",
"addressLines": [
"Quartier Cabalou",
"1 rue Raymond Cresson",
"97310 Kourou"
],
"siret": "200 027 548 00029"
},
"project": {
"title": "Portail des associations CCDS",
"url": "https://www.portail-association973.com"
},
"lineItems": [
{
"label": "Realisation et finalisation du portail des associations CCDS",
"details": [
"Conception, developpement, integration, corrections et mise en coherence generale",
"Projet concerne : https://www.portail-association973.com"
],
"quantity": "1",
"unitPrice": "6 000,00 EUR",
"total": "6 000,00 EUR"
},
{
"label": "Location / hebergement des serveurs OVH",
"details": [
"Infrastructure OVH utilisee pour le projet",
"Periode de facturation a completer"
],
"quantity": "1",
"unitPrice": "300,00 EUR",
"total": "300,00 EUR"
}
],
"totals": {
"subtotalHt": "6 300,00 EUR",
"vatAmount": "A verifier selon regime",
"totalTtc": "6 300,00 EUR si TVA non applicable"
},
"notes": [
"Facture volontairement sobre et centree sur le travail reellement fourni pour le portail et l'hebergement OVH.",
"RIB / IBAN, regime TVA, echeance et periode exacte OVH a completer avant envoi."
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View file

@ -0,0 +1,74 @@
{
"status": "PROJET DE FACTURE A VERIFIER AVANT ENVOI",
"invoiceNumber": "FA-CCDS-2026-06-11-6300",
"issueDate": "11/06/2026",
"dueDate": "A completer",
"issuer": {
"name": "Nom / raison sociale a completer",
"addressLines": [
"Adresse a completer",
"Code postal et ville a completer"
],
"siret": "SIRET a completer",
"email": "Email a completer",
"phone": "Telephone a completer",
"vat": "Regime TVA a completer"
},
"recipient": {
"name": "Communaute de Communes des Savanes",
"addressLines": [
"Quartier Cabalou",
"1 rue Raymond Cresson",
"97310 Kourou"
],
"siret": "200 027 548 00029",
"reference": "Facture adressee a la CCDS"
},
"project": {
"title": "Portail des associations CCDS",
"url": "https://www.portail-association973.com"
},
"lineItems": [
{
"label": "Conception, developpement, integration et finalisation du portail web",
"details": [
"Projet realise pour le portail des associations de la CCDS",
"Travail incluant structuration fonctionnelle, developpements, corrections, ajustements metier et mise en coherence generale du site"
],
"quantity": "1",
"unitPrice": "4 500,00 EUR",
"total": "4 500,00 EUR"
},
{
"label": "Deploiement, exploitation technique, optimisation continue et accompagnement a la mise en production",
"details": [
"Mises a jour successives, stabilisation, finalisation des workflows, mise en place de la preproduction et du tour operationnel"
],
"quantity": "1",
"unitPrice": "1 500,00 EUR",
"total": "1 500,00 EUR"
},
{
"label": "Location / hebergement des serveurs OVH",
"details": [
"Portail heberge sur infrastructure OVH",
"Periode de facturation a completer"
],
"quantity": "1",
"unitPrice": "300,00 EUR",
"total": "300,00 EUR"
}
],
"totals": {
"subtotalHt": "6 300,00 EUR",
"vatAmount": "A verifier selon regime",
"totalTtc": "6 300,00 EUR si TVA non applicable"
},
"notes": [
"Projet initie avec Manus IA puis repris et approfondi sous Codex jusqu'a un niveau de quasi finalisation.",
"La montee en competence technique a ete facilitee par William, ingenieur informatique, qui a accompagne l'usage de Manus IA et l'appropriation de termes techniques pour accelerer l'execution.",
"Par honnetete de facturation, cet accompagnement n'est pas facture ici comme ligne separee sauf accord explicite contraire.",
"Chiffrage retenu pour ce projet : 4 500,00 EUR pour la conception / developpement du portail, 1 500,00 EUR pour le deploiement / finalisation / accompagnement technique, 300,00 EUR pour l'hebergement OVH.",
"RIB / IBAN, regime TVA et modalites de reglement a joindre dans la version finale."
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB