mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-14 10:49:10 +02:00
Update `Publicities.tsx` to generate year options from 2025 to 2035, modifying the `yearOptions` array generation logic. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 0ec86cdf-a5be-4b0e-84a3-07b926f5cc80 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/0ec86cdf-a5be-4b0e-84a3-07b926f5cc80/0gylnen
922 lines
38 KiB
TypeScript
922 lines
38 KiB
TypeScript
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 { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
|
import { useStore } from "@/components/Layout";
|
|
import { format, startOfMonth, endOfMonth, eachWeekOfInterval, eachDayOfInterval, startOfWeek, endOfWeek, isSameMonth, isWithinInterval, startOfYear, endOfYear, getWeek, getMonth } from "date-fns";
|
|
import { fr } from "date-fns/locale";
|
|
import { safeFormat, safeDate, safeCompareDate } from "@/lib/dateUtils";
|
|
import type { PublicityWithRelations, Group } from "@shared/schema";
|
|
import PublicityForm from "@/components/PublicityForm";
|
|
|
|
export default function Publicities() {
|
|
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); // Année courante par défaut
|
|
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth()); // Mois courant par défaut
|
|
const [viewMode, setViewMode] = useState<'list' | 'calendar'>('list'); // Mode d'affichage
|
|
const [showYearOverview, setShowYearOverview] = useState(true); // Affichage vue d'ensemble
|
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
|
const [selectedPublicity, setSelectedPublicity] = useState<PublicityWithRelations | null>(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';
|
|
|
|
// Generate year options (2025 to 2035)
|
|
const currentYear = new Date().getFullYear();
|
|
const yearOptions = Array.from({ length: 11 }, (_, i) => currentYear + i);
|
|
|
|
const { data: publicities = [], isLoading } = useQuery({
|
|
queryKey: ['/api/publicities', selectedYear, selectedStoreId],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams();
|
|
params.append('year', selectedYear.toString());
|
|
if (selectedStoreId) {
|
|
params.append('storeId', selectedStoreId.toString());
|
|
}
|
|
const response = await fetch(`/api/publicities?${params}`);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch 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)
|
|
? data.sort((a, b) => {
|
|
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,
|
|
selectedYear,
|
|
selectedStoreId,
|
|
publicities: publicities?.slice(0, 2)
|
|
});
|
|
|
|
console.log('📢 Publicities Debug:', {
|
|
isLoading,
|
|
publicitiesCount: publicities?.length,
|
|
selectedYear,
|
|
selectedStoreId,
|
|
publicities: publicities?.slice(0, 2)
|
|
});
|
|
|
|
const { data: groups = [] } = useQuery<Group[]>({
|
|
queryKey: ['/api/groups'],
|
|
enabled: !!user,
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async (id: number) => {
|
|
const response = await fetch(`/api/publicities/${id}`, { method: 'DELETE' });
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.message || 'Erreur lors de la suppression');
|
|
}
|
|
// Handle empty response (204 No Content) in production
|
|
if (response.status === 204) {
|
|
return { message: "Publicity deleted successfully" };
|
|
}
|
|
return response.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/publicities'] });
|
|
toast({ description: "Publicité supprimée avec succès" });
|
|
setIsDeleteModalOpen(false);
|
|
setSelectedPublicity(null);
|
|
},
|
|
onError: (error: Error) => {
|
|
toast({
|
|
variant: "destructive",
|
|
description: error.message || "Erreur lors de la suppression"
|
|
});
|
|
}
|
|
});
|
|
|
|
// Generate month options
|
|
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
|
|
value: i,
|
|
label: format(new Date(selectedYear, i, 1), 'MMMM', { locale: fr })
|
|
}));
|
|
|
|
// Calendar helper functions
|
|
const getWeekParticipation = (weekStart: Date, weekEnd: Date) => {
|
|
// Vérifier que publicities est un tableau
|
|
if (!Array.isArray(publicities)) {
|
|
return {
|
|
publicities: [],
|
|
participatingStores: [],
|
|
storeColors: []
|
|
};
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
const participatingStores = new Set<number>();
|
|
weekPublicities.forEach(pub => {
|
|
// Vérifier que participations existe et est un tableau
|
|
if (pub.participations && Array.isArray(pub.participations)) {
|
|
pub.participations.forEach(participation => {
|
|
participatingStores.add(participation.groupId);
|
|
});
|
|
}
|
|
});
|
|
|
|
return {
|
|
publicities: weekPublicities,
|
|
participatingStores: Array.from(participatingStores),
|
|
storeColors: Array.from(participatingStores).map(storeId => {
|
|
const group = groups.find(g => g.id === storeId);
|
|
return group?.color || '#666666';
|
|
})
|
|
};
|
|
};
|
|
|
|
// Year overview - Generate weeks for the selected year
|
|
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 }
|
|
);
|
|
|
|
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));
|
|
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,
|
|
weekNumber,
|
|
month,
|
|
...participation
|
|
};
|
|
});
|
|
};
|
|
|
|
const yearWeeks = getYearWeeks();
|
|
|
|
const handleView = (publicity: PublicityWithRelations) => {
|
|
setSelectedPublicity(publicity);
|
|
setIsViewModalOpen(true);
|
|
};
|
|
|
|
const handleEdit = (publicity: PublicityWithRelations) => {
|
|
setSelectedPublicity(publicity);
|
|
setIsEditModalOpen(true);
|
|
};
|
|
|
|
const handleDelete = (publicity: PublicityWithRelations) => {
|
|
setSelectedPublicity(publicity);
|
|
setIsDeleteModalOpen(true);
|
|
};
|
|
|
|
const confirmDelete = () => {
|
|
if (selectedPublicity) {
|
|
deleteMutation.mutate(selectedPublicity.id);
|
|
}
|
|
};
|
|
|
|
const closeModals = () => {
|
|
setIsCreateModalOpen(false);
|
|
setIsEditModalOpen(false);
|
|
setIsViewModalOpen(false);
|
|
setIsDeleteModalOpen(false);
|
|
setSelectedPublicity(null);
|
|
};
|
|
|
|
const canCreateOrEdit = user?.role === 'admin' || user?.role === 'manager';
|
|
const canDelete = user?.role === 'admin';
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
|
<Calendar className="w-6 h-6 mr-3 text-green-600" />
|
|
Publicités
|
|
</h2>
|
|
<p className="text-gray-600 mt-1">
|
|
Gestion des campagnes publicitaires
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{/* 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={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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-blue-100 rounded-lg">
|
|
<Calendar className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-600">Total {selectedYear}</p>
|
|
<p className="text-2xl font-semibold">{Array.isArray(publicities) ? publicities.length : 0}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-green-100 rounded-lg">
|
|
<Calendar className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-600">En cours</p>
|
|
<p className="text-2xl font-semibold">
|
|
{publicities.filter(p => {
|
|
const now = new Date();
|
|
const start = safeDate(p.startDate);
|
|
const end = safeDate(p.endDate);
|
|
return start && end && start <= now && now <= end;
|
|
}).length}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-purple-100 rounded-lg">
|
|
<Calendar className="h-5 w-5 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-600">À venir</p>
|
|
<p className="text-2xl font-semibold">
|
|
{publicities.filter(p => {
|
|
const startDate = safeDate(p.startDate);
|
|
return startDate && startDate > new Date();
|
|
}).length}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Year Overview - Weekly Timeline */}
|
|
{showYearOverview && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Calendar className="h-5 w-5" />
|
|
Vue d'ensemble {selectedYear} - Semaines avec publicités
|
|
</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);
|
|
|
|
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={`
|
|
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'
|
|
}
|
|
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>
|
|
);
|
|
})}
|
|
</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 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>
|
|
</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>
|
|
)}
|
|
|
|
{/* Content */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
) : publicities.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="p-12 text-center">
|
|
<Calendar className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
Aucune publicité pour {selectedYear}
|
|
</h3>
|
|
<p className="text-gray-600 mb-6">
|
|
Commencez par créer votre première campagne publicitaire.
|
|
</p>
|
|
{canModify && (
|
|
<Button onClick={() => setIsCreateModalOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Créer une publicité
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : viewMode === 'calendar' ? (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Calendar className="h-5 w-5" />
|
|
Vue calendrier - {monthOptions[selectedMonth].label} {selectedYear}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{/* Calendar Grid */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{/* Days of week headers */}
|
|
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => (
|
|
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
|
|
{day}
|
|
</div>
|
|
))}
|
|
|
|
{/* 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 => {
|
|
const pubStart = safeDate(pub.startDate);
|
|
const pubEnd = safeDate(pub.endDate);
|
|
return pubStart && pubEnd && day >= pubStart && day <= pubEnd;
|
|
});
|
|
|
|
const participatingStores = new Set<number>();
|
|
dayPublicities.forEach(pub => {
|
|
pub.participations.forEach(participation => {
|
|
participatingStores.add(participation.groupId);
|
|
});
|
|
});
|
|
|
|
return (
|
|
<div
|
|
key={day.toISOString()}
|
|
className={`
|
|
p-2 min-h-[60px] border border-gray-200 rounded-lg
|
|
${isCurrentMonth ? 'bg-white' : 'bg-gray-50'}
|
|
${dayPublicities.length > 0 ? 'ring-2 ring-blue-200' : ''}
|
|
`}
|
|
>
|
|
<div className="text-sm font-medium text-gray-900 mb-1">
|
|
{format(day, 'd')}
|
|
</div>
|
|
{dayPublicities.length > 0 && (
|
|
<div className="space-y-1">
|
|
{dayPublicities.slice(0, 2).map(pub => (
|
|
<div
|
|
key={pub.id}
|
|
className="text-xs bg-blue-100 text-blue-800 rounded px-1 py-0.5 truncate"
|
|
title={pub.designation}
|
|
>
|
|
{pub.pubNumber}
|
|
</div>
|
|
))}
|
|
{dayPublicities.length > 2 && (
|
|
<div className="text-xs text-gray-500">
|
|
+{dayPublicities.length - 2} autre{dayPublicities.length > 3 ? 's' : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Store participation indicators */}
|
|
{participatingStores.size > 0 && (
|
|
<div className="flex gap-1 mt-1">
|
|
{Array.from(participatingStores).slice(0, 3).map(storeId => {
|
|
const group = groups.find(g => g.id === storeId);
|
|
return (
|
|
<div
|
|
key={storeId}
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: group?.color || '#666666' }}
|
|
title={group?.name || 'Magasin'}
|
|
/>
|
|
);
|
|
})}
|
|
{participatingStores.size > 3 && (
|
|
<div className="text-xs text-gray-500">+{participatingStores.size - 3}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
})()}
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
|
<h4 className="text-sm font-medium text-gray-900 mb-2">Légende</h4>
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-blue-100 border border-blue-200 rounded"></div>
|
|
<span>Jour avec publicité</span>
|
|
</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>Magasins participants</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<div>
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
N° PUB
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Désignation
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Période
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Statut
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Magasins
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Créé par
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{publicities
|
|
.map((publicity) => {
|
|
const now = new Date();
|
|
const start = safeDate(publicity.startDate);
|
|
const end = safeDate(publicity.endDate);
|
|
const isActive = start && end && now >= start && now <= end;
|
|
const isUpcoming = start && start > now;
|
|
const isPast = end && end < now;
|
|
|
|
return (
|
|
<tr key={publicity.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{publicity.pubNumber}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="text-sm text-gray-900 max-w-xs truncate">
|
|
{publicity.designation}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{safeFormat(publicity.startDate, "dd/MM/yy")} - {safeFormat(publicity.endDate, "dd/MM/yy")}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{isActive && <Badge className="bg-green-100 text-green-800">En cours</Badge>}
|
|
{isUpcoming && <Badge className="bg-blue-100 text-blue-800">À venir</Badge>}
|
|
{isPast && <Badge variant="secondary">Terminée</Badge>}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex flex-wrap gap-1 max-w-xs">
|
|
{publicity.participations.length === 0 ? (
|
|
<Badge className="text-xs bg-red-100 text-red-800 hover:bg-red-100">
|
|
Aucun magasin
|
|
</Badge>
|
|
) : (
|
|
<>
|
|
{publicity.participations.slice(0, 2).map((participation) => (
|
|
<Badge key={participation.groupId} variant="outline" className="text-xs">
|
|
<div
|
|
className="w-2 h-2 rounded-full mr-1"
|
|
style={{ backgroundColor: participation.group?.color || '#666666' }}
|
|
/>
|
|
{participation.group?.name || 'Magasin'}
|
|
</Badge>
|
|
))}
|
|
{publicity.participations.length > 2 && (
|
|
<Badge variant="outline" className="text-xs">
|
|
+{publicity.participations.length - 2}
|
|
</Badge>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{publicity.creator?.name || publicity.creator?.username || 'Utilisateur'}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleView(publicity)}
|
|
title="Voir les détails"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{canModify && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleEdit(publicity)}
|
|
title="Modifier"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDelete(publicity)}
|
|
className="text-red-600 hover:text-red-700"
|
|
title="Supprimer"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Create Modal */}
|
|
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Créer une nouvelle publicité</DialogTitle>
|
|
<DialogDescription>
|
|
Remplissez les informations de la campagne publicitaire et sélectionnez les magasins participants.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<PublicityForm
|
|
groups={groups}
|
|
selectedYear={selectedYear}
|
|
onSuccess={closeModals}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Modal */}
|
|
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Modifier la publicité</DialogTitle>
|
|
<DialogDescription>
|
|
Modifiez les informations de la campagne publicitaire.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<PublicityForm
|
|
publicity={selectedPublicity}
|
|
groups={groups}
|
|
selectedYear={selectedYear}
|
|
onSuccess={closeModals}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* View Modal */}
|
|
<Dialog open={isViewModalOpen} onOpenChange={setIsViewModalOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Détails de la publicité</DialogTitle>
|
|
<DialogDescription>
|
|
Informations complètes de la campagne publicitaire.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{selectedPublicity && (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-700">N° PUB</label>
|
|
<p className="text-lg">{selectedPublicity.pubNumber}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-700">Année</label>
|
|
<p className="text-lg">{selectedPublicity.year}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-700">Désignation</label>
|
|
<p className="mt-1">{selectedPublicity.designation}</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-700">Date de début</label>
|
|
<p>{safeFormat(selectedPublicity.startDate, "dd/MM/yyyy")}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-700">Date de fin</label>
|
|
<p>{safeFormat(selectedPublicity.endDate, "dd/MM/yyyy")}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-700">Magasins participants</label>
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{selectedPublicity.participations.length === 0 ? (
|
|
<Badge className="bg-red-100 text-red-800 hover:bg-red-100">Aucun magasin</Badge>
|
|
) : (
|
|
selectedPublicity.participations.map((participation) => (
|
|
<Badge key={participation.groupId} variant="outline">
|
|
<div
|
|
className="w-3 h-3 rounded-full mr-2"
|
|
style={{ backgroundColor: participation.group?.color || '#666666' }}
|
|
/>
|
|
{participation.group?.name || 'Magasin'}
|
|
</Badge>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600">
|
|
<div>
|
|
<label className="font-medium">Créé par</label>
|
|
<p>{selectedPublicity.creator?.name || selectedPublicity.creator?.username || 'Utilisateur'}</p>
|
|
</div>
|
|
<div>
|
|
<label className="font-medium">Créé le</label>
|
|
<p>{safeFormat(selectedPublicity.createdAt, "dd/MM/yyyy à HH:mm")}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Supprimer la publicité</DialogTitle>
|
|
<DialogDescription>
|
|
Êtes-vous sûr de vouloir supprimer cette publicité ? Cette action est irréversible.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{selectedPublicity && (
|
|
<div className="space-y-4">
|
|
<div className="p-4 bg-gray-50 rounded-lg">
|
|
<p className="font-medium">{selectedPublicity.pubNumber}</p>
|
|
<p className="text-sm text-gray-600">{selectedPublicity.designation}</p>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => setIsDeleteModalOpen(false)}>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={confirmDelete}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
{deleteMutation.isPending ? 'Suppression...' : 'Supprimer'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
} |