Initial local backup snapshot

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

View file

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

View file

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

View file

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

View file

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