Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue