From 1df477927a73ecb8e460feb20f4f112955406227 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Wed, 10 Dec 2025 12:06:14 +0100 Subject: [PATCH] feat: Add publicities management page with year/month filtering, list/calendar views, and CRUD functionality. --- client/src/pages/Publicities.tsx | 463 +++++++++++++++++-------------- 1 file changed, 256 insertions(+), 207 deletions(-) diff --git a/client/src/pages/Publicities.tsx b/client/src/pages/Publicities.tsx index a54c6d2..e600bed 100644 --- a/client/src/pages/Publicities.tsx +++ b/client/src/pages/Publicities.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Plus, Calendar, Edit, Trash2, Eye, Filter, Grid, List } from "lucide-react"; +import { Plus, Calendar, Edit, Trash2, Eye, Filter, Grid, List, Download } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -25,12 +25,12 @@ export default function Publicities() { const [isViewModalOpen, setIsViewModalOpen] = useState(false); const [selectedPublicity, setSelectedPublicity] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - + const { user } = useAuthUnified(); const { selectedStoreId } = useStore(); const { toast } = useToast(); const queryClient = useQueryClient(); - + // Seuls les admins peuvent modifier/supprimer const canModify = user?.role === 'admin'; @@ -52,36 +52,36 @@ export default function Publicities() { } const data = await response.json(); console.log('📢 Publicities received:', data.length, 'items', data.slice(0, 2)); - + // CRITICAL FIX: Force frontend sorting by pubNumber to ensure correct order - const sortedData = Array.isArray(data) + const sortedData = Array.isArray(data) ? data.sort((a, b) => { - const numA = parseInt(a.pubNumber) || 0; - const numB = parseInt(b.pubNumber) || 0; - return numA - numB; - }) + const numA = parseInt(a.pubNumber) || 0; + const numB = parseInt(b.pubNumber) || 0; + return numA - numB; + }) : []; - + console.log('📢 Publicities after frontend sort:', { first5: sortedData.slice(0, 5).map(p => `N°${p.pubNumber}`), total: sortedData.length }); - + return sortedData; }, }); - console.log('📢 Publicities Debug:', { - isLoading, - publicitiesCount: publicities?.length, + console.log('📢 Publicities Debug:', { + isLoading, + publicitiesCount: publicities?.length, selectedYear, selectedStoreId, publicities: publicities?.slice(0, 2) }); - console.log('📢 Publicities Debug:', { - isLoading, - publicitiesCount: publicities?.length, + console.log('📢 Publicities Debug:', { + isLoading, + publicitiesCount: publicities?.length, selectedYear, selectedStoreId, publicities: publicities?.slice(0, 2) @@ -96,14 +96,14 @@ export default function Publicities() { mutationFn: async (id: number) => { // Try POST first (for production environments that block DELETE) console.log(`🗑️ [Frontend] Attempting to delete publicity ${id} using POST method`); - + try { - const response = await fetch(`/api/publicities/${id}/delete`, { + const response = await fetch(`/api/publicities/${id}/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - + if (response.ok) { console.log(`✅ [Frontend] Successfully deleted publicity ${id} using POST`); return response.json(); @@ -111,12 +111,12 @@ export default function Publicities() { // Route not found, try DELETE method as fallback (for dev environment) console.log(`⚠️ [Frontend] POST route not found, trying DELETE method`); const deleteResponse = await fetch(`/api/publicities/${id}`, { method: 'DELETE' }); - + if (!deleteResponse.ok) { const error = await deleteResponse.json(); throw new Error(error.message || 'Erreur lors de la suppression'); } - + // Handle empty response (204 No Content) if (deleteResponse.status === 204) { return { message: "Publicity deleted successfully" }; @@ -138,9 +138,9 @@ export default function Publicities() { setSelectedPublicity(null); }, onError: (error: Error) => { - toast({ + toast({ variant: "destructive", - description: error.message || "Erreur lors de la suppression" + description: error.message || "Erreur lors de la suppression" }); } }); @@ -165,7 +165,7 @@ export default function Publicities() { const weekPublicities = publicities.filter(pub => { const pubStart = safeDate(pub.startDate); const pubEnd = safeDate(pub.endDate); - + // More robust overlap detection: check if any part of the publicity period overlaps with the week return pubStart && pubEnd && (pubStart <= weekEnd && pubEnd >= weekStart); }); @@ -194,7 +194,7 @@ export default function Publicities() { const getYearWeeks = () => { const yearStart = startOfYear(new Date(selectedYear, 0, 1)); const yearEnd = endOfYear(new Date(selectedYear, 11, 31)); - + const weeks = eachWeekOfInterval( { start: yearStart, end: yearEnd }, { weekStartsOn: 1 } @@ -203,18 +203,18 @@ export default function Publicities() { return weeks.map((weekStart, index) => { const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 }); const weekNumber = index + 1; - - const weekMidpoint = new Date(weekStart.getTime() + (3.5 * 24 * 60 * 60 * 1000)); + + const weekMidpoint = new Date(weekStart.getTime() + (3.5 * 24 * 60 * 60 * 1000)); let month = getMonth(weekMidpoint); - + if (weekStart.getFullYear() < selectedYear && weekEnd.getFullYear() === selectedYear) { month = 0; // Janvier } else if (weekStart.getFullYear() === selectedYear && weekEnd.getFullYear() > selectedYear) { month = 11; // Décembre } - + const participation = getWeekParticipation(weekStart, weekEnd); - + return { weekStart, weekEnd, @@ -256,6 +256,49 @@ export default function Publicities() { setSelectedPublicity(null); }; + // Export CSV function for current year data + const exportToCSV = () => { + if (!publicities || publicities.length === 0) { + toast({ variant: "destructive", description: "Aucune donnée à exporter" }); + return; + } + + const headers = ["N° PUB", "Désignation", "Date Début", "Date Fin", "Année", "Statut", "Magasins", "Créé le"]; + + const rows = publicities.map(pub => { + const now = new Date(); + const start = safeDate(pub.startDate); + const end = safeDate(pub.endDate); + const status = start && end && now >= start && now <= end ? "En cours" + : start && start > now ? "À venir" + : "Terminée"; + const stores = pub.participations.map(p => p.group?.name || "").filter(Boolean).join(", "); + + return [ + pub.pubNumber, + `"${(pub.designation || "").replace(/"/g, '""')}"`, + safeFormat(pub.startDate, "dd/MM/yyyy"), + safeFormat(pub.endDate, "dd/MM/yyyy"), + pub.year, + status, + `"${stores}"`, + safeFormat(pub.createdAt, "dd/MM/yyyy HH:mm") + ].join(";"); + }); + + // UTF-8 BOM for Excel compatibility with French accents + const csvContent = "\uFEFF" + headers.join(";") + "\n" + rows.join("\n"); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `publicites_${selectedYear}.csv`; + link.click(); + URL.revokeObjectURL(url); + + toast({ description: `Export CSV de ${publicities.length} publicités réussi` }); + }; + const canCreateOrEdit = user?.role === 'admin' || user?.role === 'manager'; const canDelete = user?.role === 'admin'; @@ -275,82 +318,88 @@ export default function Publicities() {
- {/* View Mode Toggle */} -
- + +
+ + {/* Year Overview Toggle */} + - -
- {/* Year Overview Toggle */} - - - {/* Year Filter */} -
- - -
- - {/* Month Filter (only for calendar view) */} - {viewMode === 'calendar' && ( - - )} - - {canModify && ( - - )} + {canModify && ( + + )} @@ -422,100 +471,100 @@ export default function Publicities() { -
- {/* Months Grid */} -
- {Array.from({ length: 12 }, (_, monthIndex) => { - const monthName = format(new Date(selectedYear, monthIndex, 1), 'MMM', { locale: fr }); - const monthWeeks = yearWeeks.filter(week => week.month === monthIndex); - - return ( -
-
- {monthName} -
-
- {monthWeeks.map((week, weekIndex) => { - const hasPublicity = week.publicities.length > 0; - const storeColors = week.storeColors; - - return ( -
+ {/* Months Grid */} +
+ {Array.from({ length: 12 }, (_, monthIndex) => { + const monthName = format(new Date(selectedYear, monthIndex, 1), 'MMM', { locale: fr }); + const monthWeeks = yearWeeks.filter(week => week.month === monthIndex); + + return ( +
+
+ {monthName} +
+
+ {monthWeeks.map((week, weekIndex) => { + const hasPublicity = week.publicities.length > 0; + const storeColors = week.storeColors; + + return ( +
p.pubNumber).join(', ')}` : 'Aucune publicité'}`} - > - {week.weekNumber} - {/* Store participation indicators */} - {storeColors.length > 0 && ( -
- {storeColors.slice(0, 4).map((color, idx) => ( -
- ))} - {storeColors.length > 4 && ( -
- + -
- )} -
- )} -
- ); - })} + title={`Semaine ${week.weekNumber} (${format(week.weekStart, 'dd/MM', { locale: fr })} - ${format(week.weekEnd, 'dd/MM', { locale: fr })}) - ${hasPublicity ? `${week.publicities.map(p => p.pubNumber).join(', ')}` : 'Aucune publicité'}`} + > + {week.weekNumber} + {/* Store participation indicators */} + {storeColors.length > 0 && ( +
+ {storeColors.slice(0, 4).map((color, idx) => ( +
+ ))} + {storeColors.length > 4 && ( +
+ + +
+ )} +
+ )} +
+ ); + })} +
+
+ ); + })} +
+ + {/* Legend */} +
+
+
+
+
+ Semaine avec publicité +
+
+
+ Semaine sans publicité
- ); - })} -
- - {/* Legend */} -
-
-
-
- Semaine avec publicité -
-
-
- Semaine sans publicité +
+ {groups.slice(0, 3).map(group => ( +
+ ))} +
+ Indicateurs magasins (coin supérieur droit)
-
-
- {groups.slice(0, 3).map(group => ( -
- ))} -
- Indicateurs magasins (coin supérieur droit) +
+ • Survolez les semaines pour voir les détails des publicités
-
- • Survolez les semaines pour voir les détails des publicités -
-
- - + + )} {/* Content */} @@ -559,16 +608,16 @@ export default function Publicities() { {day}
))} - + {/* Calendar days */} {(() => { const monthStart = startOfMonth(new Date(selectedYear, selectedMonth, 1)); const monthEnd = endOfMonth(monthStart); const startDate = startOfWeek(monthStart, { weekStartsOn: 1 }); const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 }); - + const days = eachDayOfInterval({ start: startDate, end: endDate }); - + return days.map(day => { const isCurrentMonth = isSameMonth(day, monthStart); const dayPublicities = publicities.filter(pub => { @@ -576,17 +625,17 @@ export default function Publicities() { const pubEnd = safeDate(pub.endDate); return pubStart && pubEnd && day >= pubStart && day <= pubEnd; }); - + const participatingStores = new Set(); dayPublicities.forEach(pub => { pub.participations.forEach(participation => { participatingStores.add(participation.groupId); }); }); - + return ( -
0 && (
{dayPublicities.slice(0, 2).map(pub => ( -
@@ -620,7 +669,7 @@ export default function Publicities() { {Array.from(participatingStores).slice(0, 3).map(storeId => { const group = groups.find(g => g.id === storeId); return ( -
- + {/* Legend */}

Légende

@@ -650,7 +699,7 @@ export default function Publicities() {
{groups.slice(0, 3).map(group => ( -
{publicity.participations.slice(0, 2).map((participation) => ( -
{participation.group?.name || 'Magasin'} @@ -767,7 +816,7 @@ export default function Publicities() { > - + {canModify && ( <> - + -