Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
BIN
exports/annuaires-integration-20260605.tar.gz
Normal file
BIN
exports/annuaires-integration-20260605.tar.gz
Normal file
Binary file not shown.
108
exports/annuaires-integration-20260605/README.md
Normal file
108
exports/annuaires-integration-20260605/README.md
Normal 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.
|
||||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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`} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 d’invitation : {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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 l’annuaire.
|
||||
</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 l’association 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 l’association sur ${instagramHandle} pour suivre ses publications et moments forts.`
|
||||
: "Retrouve l’association 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.",
|
||||
};
|
||||
}
|
||||
180
exports/annuaires-integration-20260605/server/associationGeo.ts
Normal file
180
exports/annuaires-integration-20260605/server/associationGeo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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">] || [];
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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.
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
|
|
@ -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."
|
||||
]
|
||||
}
|
||||
BIN
exports/factures/facture-honnete-ccds-portail-ovh-2026-06-11.pdf
Normal file
BIN
exports/factures/facture-honnete-ccds-portail-ovh-2026-06-11.pdf
Normal file
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
|
|
@ -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.
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
Loading…
Add table
Add a link
Reference in a new issue