feat: Add publicities management page with year/month filtering, list/calendar views, and CRUD functionality.

This commit is contained in:
Michael committed 2025-12-10 12:06:14 +01:00
1 parent cca262230e
commit 1df477927a
1 file changed
+203 -154
+203 -154
View File
@@ -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";
@@ -56,10 +56,10 @@ export default function Publicities() {
// CRITICAL FIX: Force frontend sorting by pubNumber to ensure correct order
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:', {
@@ -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() {
</div>
<div className="flex items-center gap-3">
{/* View Mode Toggle */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
{/* View Mode Toggle */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="h-8"
>
<List className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'calendar' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('calendar')}
className="h-8"
>
<Grid className="h-4 w-4" />
</Button>
</div>
{/* Year Overview Toggle */}
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
variant={showYearOverview ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('list')}
onClick={() => setShowYearOverview(!showYearOverview)}
className="h-8"
>
<List className="h-4 w-4" />
<Calendar className="h-4 w-4 mr-2" />
Vue d'ensemble
</Button>
<Button
variant={viewMode === 'calendar' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('calendar')}
className="h-8"
>
<Grid className="h-4 w-4" />
{/* Year Filter */}
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-500" />
<Select
value={selectedYear.toString()}
onValueChange={(value) => setSelectedYear(parseInt(value))}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Month Filter (only for calendar view) */}
{viewMode === 'calendar' && (
<Select
value={selectedMonth.toString()}
onValueChange={(value) => setSelectedMonth(parseInt(value))}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthOptions.map((month) => (
<SelectItem key={month.value} value={month.value.toString()}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Export CSV Button */}
<Button variant="outline" onClick={exportToCSV} className="h-9">
<Download className="h-4 w-4 mr-2" />
Exporter CSV
</Button>
</div>
{/* Year Overview Toggle */}
<Button
variant={showYearOverview ? 'default' : 'outline'}
size="sm"
onClick={() => setShowYearOverview(!showYearOverview)}
className="h-8"
>
<Calendar className="h-4 w-4 mr-2" />
Vue d'ensemble
</Button>
{/* Year Filter */}
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-500" />
<Select
value={selectedYear.toString()}
onValueChange={(value) => setSelectedYear(parseInt(value))}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Month Filter (only for calendar view) */}
{viewMode === 'calendar' && (
<Select
value={selectedMonth.toString()}
onValueChange={(value) => setSelectedMonth(parseInt(value))}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthOptions.map((month) => (
<SelectItem key={month.value} value={month.value.toString()}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{canModify && (
<Button onClick={() => setIsCreateModalOpen(true)} className="bg-green-600 hover:bg-green-700 text-white shadow-md">
<Plus className="mr-2 h-4 w-4" />
Nouvelle publicité
</Button>
)}
{canModify && (
<Button onClick={() => setIsCreateModalOpen(true)} className="bg-green-600 hover:bg-green-700 text-white shadow-md">
<Plus className="mr-2 h-4 w-4" />
Nouvelle publicité
</Button>
)}
</div>
</div>
</div>
@@ -422,100 +471,100 @@ export default function Publicities() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Months Grid */}
<div className="grid grid-cols-12 gap-2">
{Array.from({ length: 12 }, (_, monthIndex) => {
const monthName = format(new Date(selectedYear, monthIndex, 1), 'MMM', { locale: fr });
const monthWeeks = yearWeeks.filter(week => week.month === monthIndex);
<div className="space-y-4">
{/* Months Grid */}
<div className="grid grid-cols-12 gap-2">
{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 (
<div key={monthIndex} className="space-y-2">
<div className="text-xs font-medium text-gray-600 text-center">
{monthName}
</div>
<div className="space-y-1">
{monthWeeks.map((week, weekIndex) => {
const hasPublicity = week.publicities.length > 0;
const storeColors = week.storeColors;
return (
<div key={monthIndex} className="space-y-2">
<div className="text-xs font-medium text-gray-600 text-center">
{monthName}
</div>
<div className="space-y-1">
{monthWeeks.map((week, weekIndex) => {
const hasPublicity = week.publicities.length > 0;
const storeColors = week.storeColors;
return (
<div
key={`${monthIndex}-${weekIndex}-${week.weekNumber}`}
className={`
return (
<div
key={`${monthIndex}-${weekIndex}-${week.weekNumber}`}
className={`
h-8 rounded border text-xs flex flex-col items-center justify-center cursor-pointer relative
${hasPublicity
? 'bg-blue-50 border-blue-200 text-blue-800'
: 'bg-gray-50 border-gray-200 text-gray-500'
}
? 'bg-blue-50 border-blue-200 text-blue-800'
: 'bg-gray-50 border-gray-200 text-gray-500'
}
hover:shadow-sm transition-shadow
`}
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é'}`}
>
<span className="text-xs font-medium">{week.weekNumber}</span>
{/* Store participation indicators */}
{storeColors.length > 0 && (
<div className="absolute top-0.5 right-0.5 flex flex-wrap gap-0.5 max-w-[16px]">
{storeColors.slice(0, 4).map((color, idx) => (
<div
key={idx}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: color }}
title={`Magasin ${idx + 1}`}
/>
))}
{storeColors.length > 4 && (
<div
className="w-1.5 h-1.5 rounded-full bg-gray-400 text-white text-xs flex items-center justify-center"
title={`+${storeColors.length - 4} autres magasins`}
>
+
</div>
)}
</div>
)}
</div>
);
})}
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é'}`}
>
<span className="text-xs font-medium">{week.weekNumber}</span>
{/* Store participation indicators */}
{storeColors.length > 0 && (
<div className="absolute top-0.5 right-0.5 flex flex-wrap gap-0.5 max-w-[16px]">
{storeColors.slice(0, 4).map((color, idx) => (
<div
key={idx}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: color }}
title={`Magasin ${idx + 1}`}
/>
))}
{storeColors.length > 4 && (
<div
className="w-1.5 h-1.5 rounded-full bg-gray-400 text-white text-xs flex items-center justify-center"
title={`+${storeColors.length - 4} autres magasins`}
>
+
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center justify-between text-sm text-gray-600">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-blue-50 border border-blue-200 rounded"></div>
<span>Semaine avec publicité</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-50 border border-gray-200 rounded"></div>
<span>Semaine sans publicité</span>
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center justify-between text-sm text-gray-600">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-blue-50 border border-blue-200 rounded"></div>
<span>Semaine avec publicité</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-50 border border-gray-200 rounded"></div>
<span>Semaine sans publicité</span>
<div className="flex gap-1">
{groups.slice(0, 3).map(group => (
<div
key={group.id}
className="w-2 h-2 rounded-full"
style={{ backgroundColor: group?.color || '#666666' }}
/>
))}
</div>
<span>Indicateurs magasins (coin supérieur droit)</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex gap-1">
{groups.slice(0, 3).map(group => (
<div
key={group.id}
className="w-2 h-2 rounded-full"
style={{ backgroundColor: group?.color || '#666666' }}
/>
))}
</div>
<span>Indicateurs magasins (coin supérieur droit)</span>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>• Survolez les semaines pour voir les détails des publicités</span>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>• Survolez les semaines pour voir les détails des publicités</span>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
)}
{/* Content */}