templates/journal_officiel/index.html.twig line 1

Open in your IDE?
  1. {% extends 'base.html.twig' %}
  2. {% block title %}Journal Officiel - Archives des numéros{% endblock %}
  3. {% block meta %}
  4. <meta name="description" content="Consultez l'ensemble des archives du Journal Officiel : numéros publiés, actes officiels, textes de loi et décrets. Recherche par année, numéro, mot-clé.">
  5. <meta name="robots" content="index, follow">
  6. {% endblock %}
  7. {% block body %}
  8. <div class="container mx-auto px-4 py-8">
  9. <div class="max-w-7xl mx-auto">
  10. <!-- Fil d'Ariane -->
  11. <nav class="text-sm mb-6" aria-label="Breadcrumb">
  12. <ol class="flex flex-wrap items-center space-x-2 text-gray-600">
  13. <li><a href="{{ path('app_home') }}" class="hover:text-[#006633]">Accueil</a></li>
  14. <li><span class="mx-2">/</span></li>
  15. <li class="text-gray-900 font-medium" aria-current="page">Archives du Journal Officiel</li>
  16. </ol>
  17. </nav>
  18. <!-- Hero -->
  19. <div class="bg-gradient-to-r from-[#006633] to-[#008844] rounded-lg shadow-lg p-8 mb-8 text-white">
  20. <h1 class="text-3xl font-bold">Journal Officiel</h1>
  21. <p class="text-white/80 mt-2">Consultez les archives des numéros publiés – textes officiels, décrets, arrêtés et annonces légales</p>
  22. <div class="mt-4 text-sm text-white/70">
  23. Dernière mise à jour : {{ "now"|date("d/m/Y") }}
  24. </div>
  25. </div>
  26. <!-- Filtres + Tri -->
  27. <div class="bg-white rounded-lg shadow-md p-6 mb-8">
  28. <form method="GET" id="filterForm" class="space-y-4">
  29. <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  30. <div>
  31. <label for="year" class="block text-sm font-medium text-gray-700 mb-1">Année</label>
  32. <select name="year" id="year" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-[#006633]">
  33. <option value="">Toutes les années</option>
  34. {% for year in years|default([]) %}
  35. <option value="{{ year }}" {% if selectedYear|default('') == year %}selected{% endif %}>{{ year }}</option>
  36. {% endfor %}
  37. </select>
  38. </div>
  39. <div>
  40. <label for="month" class="block text-sm font-medium text-gray-700 mb-1">Mois</label>
  41. <select name="month" id="month" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-[#006633]">
  42. <option value="">Tous les mois</option>
  43. {% for m in 1..12 %}
  44. {% set monthName = ('01-' ~ m ~ '-2000')|date('F')|capitalize %}
  45. <option value="{{ m }}" {% if selectedMonth|default('') == m %}selected{% endif %}>{{ monthName }}</option>
  46. {% endfor %}
  47. </select>
  48. </div>
  49. <div>
  50. <label for="numero" class="block text-sm font-medium text-gray-700 mb-1">Numéro</label>
  51. <input type="text" name="numero" id="numero" value="{{ selectedNumero|default('') }}" placeholder="ex: 1234" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-[#006633]">
  52. </div>
  53. <div>
  54. <label for="keyword" class="block text-sm font-medium text-gray-700 mb-1">Mot‑clé (titre / contenu)</label>
  55. <input type="text" name="keyword" id="keyword" value="{{ keyword|default('') }}" placeholder="Recherche dans les actes" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-[#006633]">
  56. </div>
  57. </div>
  58. <div class="flex flex-wrap justify-between items-center gap-3">
  59. <div class="flex flex-wrap gap-3">
  60. <button type="submit" class="bg-[#006633] text-white px-5 py-2 rounded-lg hover:bg-[#005528] transition shadow-sm flex items-center gap-1">
  61. <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path></svg>
  62. Filtrer
  63. </button>
  64. <a href="{{ path('app_journal_officiel') }}" class="bg-gray-200 text-gray-800 px-5 py-2 rounded-lg hover:bg-gray-300 transition">Réinitialiser</a>
  65. <select id="sortSelect" class="border rounded-lg px-3 py-2 bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-[#006633]">
  66. <option value="date_desc">Date récente → ancienne</option>
  67. <option value="date_asc">Date ancienne → récente</option>
  68. <option value="numero_asc">Numéro croissant</option>
  69. <option value="numero_desc">Numéro décroissant</option>
  70. </select>
  71. </div>
  72. <div class="text-sm text-gray-500" id="resultCount">
  73. <strong>{{ numeros|default([])|length }}</strong> numéro(s) affiché(s)
  74. </div>
  75. </div>
  76. </form>
  77. </div>
  78. <!-- Grille des numéros -->
  79. <div id="numerosGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  80. {% for numero in numeros|default([]) %}
  81. <div class="bg-white rounded-lg shadow-md hover:shadow-lg transition overflow-hidden flex flex-col h-full"
  82. data-id="{{ numero.id }}"
  83. data-numero="{{ numero.numero }}"
  84. data-date="{{ numero.datePublication|date('Y-m-d') }}"
  85. data-description="{{ numero.description|default('')|e('html_attr') }}"
  86. data-categories="{{ numero.categories|default([])|join(',')|e('html_attr') }}"
  87. data-nb-actes="{{ numero.nbActes|default(0) }}">
  88. <div class="bg-gradient-to-r from-[#006633] to-[#008844] p-4">
  89. <div class="text-white">
  90. <div class="text-2xl font-bold flex items-center justify-between">
  91. <span>JO n°{{ numero.numero }}</span>
  92. {% if numero.estSpeciale|default(false) %}
  93. <span class="text-xs bg-yellow-200 text-yellow-800 px-2 py-1 rounded-full">Spécial</span>
  94. {% endif %}
  95. </div>
  96. <div class="text-sm text-white/80 mt-1">
  97. Publié le {{ numero.datePublication|date('d/m/Y') }}
  98. </div>
  99. </div>
  100. </div>
  101. <div class="p-4 flex-grow">
  102. <div class="text-sm text-gray-600 mb-2">
  103. 📄 {{ numero.nbActes|default(0) }} acte{% if numero.nbActes|default(0) > 1 %}s{% endif %} publié(s)
  104. </div>
  105. {% if numero.description|default(false) %}
  106. <p class="text-gray-700 text-sm mb-4 line-clamp-3">{{ numero.description }}</p>
  107. {% else %}
  108. <p class="text-gray-400 text-sm italic mb-4">Aucun résumé disponible</p>
  109. {% endif %}
  110. {% if numero.categories|default([])|length > 0 %}
  111. <div class="flex flex-wrap gap-1 mb-4">
  112. {% for cat in numero.categories|slice(0,2) %}
  113. <span class="inline-block bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded">{{ cat }}</span>
  114. {% endfor %}
  115. {% if numero.categories|length > 2 %}
  116. <span class="text-xs text-gray-500">+{{ numero.categories|length - 2 }}</span>
  117. {% endif %}
  118. </div>
  119. {% endif %}
  120. </div>
  121. <div class="p-4 pt-0 border-t border-gray-100 mt-2">
  122. <div class="flex space-x-3">
  123. {# REMPLACEMENT : bouton au lieu de lien pour éviter la redirection #}
  124. <button type="button" class="consult-btn flex-1 text-center bg-[#006633]/10 text-[#006633] px-3 py-2 rounded hover:bg-[#006633]/20 transition text-sm font-medium">
  125. Consulter
  126. </button>
  127. <button type="button" class="pdf-btn flex-1 text-center bg-gray-50 text-gray-600 px-3 py-2 rounded hover:bg-gray-100 transition text-sm flex items-center justify-center gap-1">
  128. <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M4 4v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6H6a2 2 0 00-2 2z"></path></svg>
  129. PDF
  130. </button>
  131. </div>
  132. </div>
  133. </div>
  134. {% else %}
  135. <div class="col-span-full text-center text-gray-500 py-12">
  136. <div class="text-6xl mb-4">📄</div>
  137. <p>Aucun numéro du Journal Officiel ne correspond à vos critères</p>
  138. <a href="{{ path('app_journal_officiel') }}" class="inline-block mt-4 text-[#006633] hover:underline">Voir tous les numéros</a>
  139. </div>
  140. {% endfor %}
  141. </div>
  142. <!-- Pagination (inchangée) -->
  143. {% set totalPages = totalPages|default(1) %}
  144. {% set currentPage = currentPage|default(1) %}
  145. {% if totalPages > 1 %}
  146. <div class="flex flex-wrap justify-center items-center space-x-1 mt-10">
  147. {# ... la pagination existante ... #}
  148. </div>
  149. {% endif %}
  150. <!-- Section aide -->
  151. <div class="mt-12 bg-gray-50 rounded-lg p-6 text-sm text-gray-700 border">
  152. <h2 class="text-lg font-semibold text-gray-800 mb-2">À propos des archives</h2>
  153. <p>Le Journal Officiel contient l’ensemble des textes législatifs, réglementaires et administratifs. Vous pouvez rechercher par numéro, année ou mot‑clé. Les PDF sont téléchargeables librement.</p>
  154. <div class="mt-3 flex flex-wrap gap-4">
  155. <a href="#" class="text-[#006633] hover:underline">🔍 Guide de recherche avancée</a>
  156. <a href="#" class="text-[#006633] hover:underline">📧 S’abonner aux alertes</a>
  157. <a href="#" class="text-[#006633] hover:underline">ℹ️ Aide & contact</a>
  158. </div>
  159. </div>
  160. </div>
  161. </div>
  162. <!-- Modale -->
  163. <div id="consultModal" class="modal" style="display: none;">
  164. <div class="modal-content bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 relative">
  165. <div class="bg-gradient-to-r from-[#006633] to-[#008844] p-4 rounded-t-lg text-white flex justify-between items-center">
  166. <h3 class="text-xl font-bold">Consultation du Journal Officiel</h3>
  167. <button id="closeModalBtn" class="text-white hover:text-gray-200 text-2xl leading-none">&times;</button>
  168. </div>
  169. <div id="modalBody" class="p-6">
  170. <!-- contenu dynamique -->
  171. </div>
  172. <div class="border-t p-4 flex justify-end">
  173. <button id="modalPdfBtn" class="bg-[#006633] text-white px-4 py-2 rounded-lg hover:bg-[#005528] transition flex items-center gap-2">
  174. <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M4 4v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6H6a2 2 0 00-2 2z"></path></svg>
  175. Télécharger ce PDF
  176. </button>
  177. </div>
  178. </div>
  179. </div>
  180. <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
  181. <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
  182. <script>
  183. document.addEventListener('DOMContentLoaded', function() {
  184. // Éléments
  185. const grid = document.getElementById('numerosGrid');
  186. const filterForm = document.getElementById('filterForm');
  187. const yearSelect = document.getElementById('year');
  188. const monthSelect = document.getElementById('month');
  189. const numeroInput = document.getElementById('numero');
  190. const keywordInput = document.getElementById('keyword');
  191. const sortSelect = document.getElementById('sortSelect');
  192. const resultCountSpan = document.getElementById('resultCount');
  193. const modal = document.getElementById('consultModal');
  194. const modalBody = document.getElementById('modalBody');
  195. const closeModalBtn = document.getElementById('closeModalBtn');
  196. const modalPdfBtn = document.getElementById('modalPdfBtn');
  197. let currentModalData = null;
  198. // Fermeture modale
  199. function closeModal() {
  200. modal.style.display = 'none';
  201. currentModalData = null;
  202. }
  203. closeModalBtn.addEventListener('click', closeModal);
  204. window.addEventListener('click', (e) => { if(e.target === modal) closeModal(); });
  205. // Affichage modale
  206. function showModal(card) {
  207. const numero = card.dataset.numero;
  208. const date = card.dataset.date;
  209. const description = card.dataset.description || 'Aucune description';
  210. const nbActes = card.dataset.nbActes;
  211. const categories = card.dataset.categories ? card.dataset.categories.split(',') : [];
  212. const formattedDate = new Date(date).toLocaleDateString('fr-FR');
  213. let categoriesHtml = '';
  214. if(categories.length) {
  215. categoriesHtml = '<div class="mt-2 flex flex-wrap gap-1">' + categories.map(c => `<span class="bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded">${c.trim()}</span>`).join('') + '</div>';
  216. }
  217. modalBody.innerHTML = `
  218. <h4 class="text-2xl font-bold text-[#006633]">JO n°${numero}</h4>
  219. <p class="text-gray-600 mt-1">Publié le ${formattedDate}</p>
  220. <div class="mt-4 text-sm text-gray-700">📄 ${nbActes} acte(s) publié(s)</div>
  221. <div class="mt-4"><h5 class="font-semibold text-gray-800">Résumé</h5><p class="text-gray-600">${description}</p></div>
  222. ${categoriesHtml}
  223. `;
  224. currentModalData = { numero, date: formattedDate, description, nbActes, categories };
  225. modal.style.display = 'flex';
  226. }
  227. // Génération PDF
  228. async function generatePDF(data) {
  229. const { jsPDF } = window.jspdf;
  230. const doc = new jsPDF();
  231. doc.setFontSize(18);
  232. doc.setTextColor(0, 102, 51);
  233. doc.text(`Journal Officiel n°${data.numero}`, 20, 30);
  234. doc.setFontSize(12);
  235. doc.setTextColor(0,0,0);
  236. doc.text(`Publié le ${data.date}`, 20, 45);
  237. doc.text(`Nombre d'actes : ${data.nbActes}`, 20, 60);
  238. doc.text("Résumé :", 20, 75);
  239. const splitDesc = doc.splitTextToSize(data.description || "Aucun résumé", 170);
  240. doc.text(splitDesc, 20, 85);
  241. if(data.categories && data.categories.length) {
  242. doc.text("Catégories : " + data.categories.join(', '), 20, 85 + splitDesc.length * 7);
  243. }
  244. doc.save(`JO_${data.numero}.pdf`);
  245. }
  246. // Filtrage + tri
  247. function filterAndSort() {
  248. const cards = Array.from(grid.querySelectorAll('.bg-white.rounded-lg.shadow-md'));
  249. const yearVal = yearSelect.value;
  250. const monthVal = monthSelect.value;
  251. const numeroVal = numeroInput.value.trim();
  252. const keywordVal = keywordInput.value.trim().toLowerCase();
  253. const sortVal = sortSelect.value;
  254. let visibleCards = cards.filter(card => {
  255. const cardNumero = card.dataset.numero;
  256. const cardDate = new Date(card.dataset.date);
  257. const cardYear = cardDate.getFullYear().toString();
  258. const cardMonth = (cardDate.getMonth() + 1).toString();
  259. const cardDescription = (card.dataset.description || '').toLowerCase();
  260. const cardCategories = (card.dataset.categories || '').toLowerCase();
  261. const searchableText = `${cardNumero} ${cardDescription} ${cardCategories}`;
  262. let match = true;
  263. if(yearVal && cardYear !== yearVal) match = false;
  264. if(match && monthVal && cardMonth !== monthVal) match = false;
  265. if(match && numeroVal && !cardNumero.includes(numeroVal)) match = false;
  266. if(match && keywordVal && !searchableText.includes(keywordVal)) match = false;
  267. return match;
  268. });
  269. // Tri
  270. if(sortVal === 'date_asc') visibleCards.sort((a,b) => new Date(a.dataset.date) - new Date(b.dataset.date));
  271. else if(sortVal === 'date_desc') visibleCards.sort((a,b) => new Date(b.dataset.date) - new Date(a.dataset.date));
  272. else if(sortVal === 'numero_asc') visibleCards.sort((a,b) => parseInt(a.dataset.numero) - parseInt(b.dataset.numero));
  273. else if(sortVal === 'numero_desc') visibleCards.sort((a,b) => parseInt(b.dataset.numero) - parseInt(a.dataset.numero));
  274. // Mise à jour DOM
  275. cards.forEach(card => card.style.display = 'none');
  276. visibleCards.forEach(card => { card.style.display = ''; grid.appendChild(card); });
  277. resultCountSpan.innerHTML = `<strong>${visibleCards.length}</strong> numéro(s) affiché(s)`;
  278. }
  279. // Écouteurs filtres
  280. yearSelect.addEventListener('change', filterAndSort);
  281. monthSelect.addEventListener('change', filterAndSort);
  282. numeroInput.addEventListener('input', filterAndSort);
  283. keywordInput.addEventListener('input', filterAndSort);
  284. sortSelect.addEventListener('change', filterAndSort);
  285. filterForm.addEventListener('submit', (e) => { e.preventDefault(); filterAndSort(); });
  286. // Délégation d'événements pour les boutons (y compris ceux ajoutés dynamiquement)
  287. grid.addEventListener('click', (e) => {
  288. const btn = e.target.closest('.consult-btn');
  289. if(btn) {
  290. e.preventDefault();
  291. const card = btn.closest('.bg-white.rounded-lg.shadow-md');
  292. if(card) showModal(card);
  293. return;
  294. }
  295. const pdfBtn = e.target.closest('.pdf-btn');
  296. if(pdfBtn) {
  297. e.preventDefault();
  298. const card = pdfBtn.closest('.bg-white.rounded-lg.shadow-md');
  299. if(card) {
  300. const data = {
  301. numero: card.dataset.numero,
  302. date: new Date(card.dataset.date).toLocaleDateString('fr-FR'),
  303. description: card.dataset.description,
  304. nbActes: card.dataset.nbActes,
  305. categories: card.dataset.categories ? card.dataset.categories.split(',') : []
  306. };
  307. generatePDF(data);
  308. }
  309. }
  310. });
  311. // PDF depuis la modale
  312. modalPdfBtn.addEventListener('click', () => { if(currentModalData) generatePDF(currentModalData); });
  313. // Initialisation
  314. filterAndSort();
  315. });
  316. </script>
  317. <style>
  318. .modal {
  319. display: none;
  320. position: fixed;
  321. top: 0;
  322. left: 0;
  323. width: 100%;
  324. height: 100%;
  325. background-color: rgba(0,0,0,0.5);
  326. z-index: 1000;
  327. justify-content: center;
  328. align-items: center;
  329. }
  330. .line-clamp-3 {
  331. display: -webkit-box;
  332. -webkit-line-clamp: 3;
  333. -webkit-box-orient: vertical;
  334. overflow: hidden;
  335. }
  336. </style>
  337. {% endblock %}