Afficher les publications déjà prévues par jour et par magasin

Le sélecteur de date des Reels ne montrait rien de ce qui était déjà
programmé, et celui des posts ne marquait qu'un jour « occupé » sans
dire pour quel magasin ni à quelle heure.

- GET /api/scheduled-posts/agenda : publications planifiées et Reels
  encore en montage (qui n'ont pas de scheduled_posts avant la fin du
  rendu), limités aux magasins accessibles.
- Calendrier : pastille par jour, en rouge si un magasin coché a déjà
  une publication, en gris s'il s'agit d'autres magasins.
- Heures : badge sur les heures déjà prises sur les magasins cochés.
- Panneau « Déjà prévu ce jour-là » groupé par magasin, avec une
  alerte non bloquante en cas de doublon.
- Branché sur Nouveau post, Reel et Programmer un Reel.
- Icône Reel dans la vue calendrier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMU8FG2v2EE54YWHhqehPw
This commit is contained in:
Claude committed 2026-10-12 10:19:51 +00:00
1 parent 62475eb43b
commit 53ab6f489a
11 files changed
+553 -43

No files matched your search

+3 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2, Edit, MoreHorizontal, Eye, Image, Smartphone } from "lucide-react";
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2, Edit, MoreHorizontal, Eye, Image, Smartphone, Clapperboard } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
@@ -126,6 +126,8 @@ export default function CalendarView() {
<Smartphone className="w-2.5 h-2.5" />
</div>
);
} else if (postType === 'reel') {
return <Clapperboard className="w-3 h-3" />;
}
return null;
};
+90 -23
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import type { DayContentProps } from "react-day-picker";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
@@ -7,25 +8,32 @@ import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { DayAgendaPanel } from "@/components/day-agenda-panel";
import { usePublicationAgenda } from "@/hooks/use-publication-agenda";
import { busyHours, conflictsFor, dayKey, groupByPage, occupiesSlot } from "@shared/agenda";
interface DateTimePickerProps {
value: Date | undefined;
onChange: (date: Date | undefined) => void;
occupiedDates?: Date[];
/** Magasins visés : leurs publications déjà prévues sont signalées. */
selectedPageIds?: string[];
placeholder?: string;
}
const OPTIMAL_HOURS = [8, 12, 13, 18, 19, 20];
export function DateTimePicker({ value, onChange, occupiedDates = [], placeholder = "Sélectionner une date" }: DateTimePickerProps) {
export function DateTimePicker({ value, onChange, selectedPageIds = [], placeholder = "Sélectionner une date" }: DateTimePickerProps) {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value);
const [selectedHour, setSelectedHour] = useState<number>(value?.getHours() || 8);
const [selectedMinute, setSelectedMinute] = useState<number>(value?.getMinutes() || 0);
const [month, setMonth] = useState<Date>(value ?? new Date());
const { itemsByDay } = usePublicationAgenda(month);
// Sync internal state with parent value prop
useEffect(() => {
if (value) {
setSelectedDate(value);
setMonth(value);
setSelectedHour(value.getHours());
setSelectedMinute(value.getMinutes());
} else {
@@ -63,15 +71,44 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
}
};
// Convert occupied dates to a Set of date strings for efficient lookup
const occupiedDateStrings = new Set(
occupiedDates.map(date => format(date, 'yyyy-MM-dd'))
// Publications qui occupent encore un créneau, jour par jour
const activeItemsOn = useCallback(
(date: Date) => (itemsByDay.get(dayKey(date)) ?? []).filter(occupiesSlot),
[itemsByDay]
);
const isSelectedStoreBusy = (date: Date) => conflictsFor(activeItemsOn(date), selectedPageIds).length > 0;
const isOtherStoreBusy = (date: Date) => activeItemsOn(date).length > 0 && !isSelectedStoreBusy(date);
// Check if a date is occupied
const isDateOccupied = (date: Date) => {
return occupiedDateStrings.has(format(date, 'yyyy-MM-dd'));
};
const selectedDayItems = selectedDate ? itemsByDay.get(dayKey(selectedDate)) ?? [] : [];
const hoursTaken = busyHours(selectedDayItems, selectedPageIds);
const selectedKey = selectedPageIds.join(",");
// Pastille du jour : publications sur les magasins choisis, sinon sur les autres
const AgendaDayContent = useCallback(
({ date }: DayContentProps) => {
const items = activeItemsOn(date);
const mine = conflictsFor(items, selectedKey ? selectedKey.split(",") : []).length;
const title = groupByPage(items)
.map(({ page, items: pageItems }) => `${page.pageName} : ${pageItems.length}`)
.join("\n");
return (
<span className="relative flex h-full w-full items-center justify-center" title={title || undefined}>
{date.getDate()}
{items.length > 0 && (
<span
className={cn(
"absolute right-0 top-0 min-w-[14px] rounded-full px-1 text-[9px] font-bold leading-[14px]",
mine > 0 ? "bg-destructive text-destructive-foreground" : "bg-muted-foreground/70 text-background"
)}
>
{mine || items.length}
</span>
)}
</span>
);
},
[activeItemsOn, selectedKey]
);
return (
<div className="space-y-3">
@@ -95,16 +132,34 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
month={month}
onMonthChange={setMonth}
locale={fr}
modifiers={{
occupied: (date) => isDateOccupied(date)
selectedStoreBusy: isSelectedStoreBusy,
otherStoreBusy: isOtherStoreBusy,
}}
modifiersClassNames={{
occupied: "bg-destructive/15 text-destructive font-bold hover:bg-destructive/25"
selectedStoreBusy: "bg-destructive/15 text-destructive font-bold hover:bg-destructive/25",
otherStoreBusy: "bg-muted font-semibold",
}}
components={{ DayContent: AgendaDayContent }}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
data-testid="calendar-picker"
/>
<div className="space-y-1 border-t bg-muted/50 px-3 py-2 text-[10px]">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-destructive" />
<span>Déjà prévu sur un magasin sélectionné</span>
</div>
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-muted-foreground/70" />
<span>Prévu sur d'autres magasins</span>
</div>
{selectedPageIds.length === 0 && (
<p className="text-muted-foreground">Cochez un magasin pour repérer ses jours déjà occupés.</p>
)}
</div>
</PopoverContent>
</Popover>
{value && (
@@ -147,7 +202,9 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
<ScrollArea className="h-48 min-w-[96px] flex-1">
<div className="p-1.5">
<div className="text-[10px] font-semibold mb-1.5 px-1.5 text-muted-foreground">Heures</div>
{hours.map((hour) => (
{hours.map((hour) => {
const taken = hoursTaken.get(hour) ?? 0;
return (
<Button
key={hour}
variant="ghost"
@@ -163,8 +220,17 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
{isOptimalHour(hour) && selectedHour !== hour && (
<span className="ml-1 text-[10px]">✨</span>
)}
{taken > 0 && (
<span
className="ml-1 rounded-full bg-destructive px-1 text-[9px] font-bold leading-[14px] text-destructive-foreground"
title={`${taken} publication(s) déjà prévue(s) sur vos magasins`}
>
{taken}
</span>
)}
</Button>
))}
);
})}
</div>
</ScrollArea>
<ScrollArea className="h-48 min-w-[80px] flex-1 border-l">
@@ -194,20 +260,21 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
Heures optimales (8h, 12h-13h, 18h-20h)
</span>
</div>
{hoursTaken.size > 0 && (
<div className="mt-1 flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-destructive"></div>
<span className="text-[9px] font-semibold text-destructive">
Déjà prévu sur vos magasins
</span>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
{occupiedDates.length > 0 && (
<div className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-destructive"></div>
<span className="text-xs font-semibold text-destructive">
Les dates en fond rouge ont déjà des publications programmées
</span>
</div>
</div>
{selectedDate && (
<DayAgendaPanel date={selectedDate} items={selectedDayItems} selectedPageIds={selectedPageIds} />
)}
</div>
);
@@ -0,0 +1,97 @@
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { AlertTriangle, CalendarCheck, Clapperboard, Image, Layers, Loader2, Smartphone } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { PlatformIcon } from "@/components/platform-icon";
import { cn } from "@/lib/utils";
import { conflictsFor, groupByPage, type AgendaItem, type AgendaStatus } from "@shared/agenda";
const TYPE_META: Record<string, { label: string; icon: typeof Image }> = {
reel: { label: "Reel", icon: Clapperboard },
story: { label: "Story", icon: Smartphone },
both: { label: "Post + Story", icon: Layers },
feed: { label: "Post", icon: Image },
};
const STATUS_META: Record<AgendaStatus, { label: string; className: string }> = {
scheduled: { label: "Programmé", className: "bg-info/15 text-info" },
rendering: { label: "En montage", className: "bg-warning/15 text-warning" },
published: { label: "Publié", className: "bg-success/15 text-success" },
failed: { label: "Échec", className: "bg-destructive/15 text-destructive" },
};
const time = (item: AgendaItem) => format(new Date(item.scheduledAt), "HH:mm");
interface DayAgendaPanelProps {
date: Date;
items: AgendaItem[];
selectedPageIds: string[];
}
/** Ce qui est déjà prévu le jour choisi, magasin par magasin. */
export function DayAgendaPanel({ date, items, selectedPageIds }: DayAgendaPanelProps) {
const dayLabel = format(date, "EEEE d MMMM", { locale: fr });
const conflicts = conflictsFor(items, selectedPageIds);
const reelConflicts = conflicts.filter((item) => item.postType === "reel");
if (items.length === 0) {
return (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-3 text-xs text-muted-foreground" data-testid="day-agenda-empty">
<CalendarCheck className="h-4 w-4 text-success" />
Rien de prévu le {dayLabel} : la journée est libre sur tous les magasins.
</div>
);
}
return (
<div className="space-y-2" data-testid="day-agenda-panel">
{conflicts.length > 0 && (
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 p-3 text-xs text-warning" data-testid="day-agenda-conflict">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-0.5">
{(reelConflicts.length > 0 ? reelConflicts : conflicts).map((item) => (
<p key={item.id}>
<span className="font-semibold">{item.pageName}</span> a déjà {reelConflicts.length > 0 ? "un Reel" : "une publication"} à {time(item)} ce jour-là.
</p>
))}
<p className="text-warning/80">Vous pouvez quand même programmer, ou choisir un autre jour.</p>
</div>
</div>
)}
<div className="rounded-lg border">
<div className="border-b bg-muted/40 px-3 py-2 text-xs font-semibold">
Déjà prévu le {dayLabel}
</div>
<div className="divide-y">
{groupByPage(items, selectedPageIds).map(({ page, selected, items: pageItems }) => (
<div key={page.id} className={cn("space-y-1.5 px-3 py-2", selected && "bg-primary/5")}>
<div className="flex items-center gap-2">
<PlatformIcon platform={page.platform} size="sm" className="h-5 w-5 rounded" />
<span className="min-w-0 flex-1 truncate text-xs font-medium">{page.pageName}</span>
{selected && <Badge variant="outline" className="h-5 px-1.5 text-[10px]">Sélectionné</Badge>}
</div>
{pageItems.map((item) => {
const type = TYPE_META[item.postType] ?? TYPE_META.feed;
const status = STATUS_META[item.status];
const TypeIcon = type.icon;
return (
<div key={item.id} className="flex items-center gap-2 pl-7 text-xs" data-testid={`day-agenda-item-${item.id}`}>
<span className="w-10 shrink-0 font-mono font-semibold">{time(item)}</span>
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="shrink-0">{type.label}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.preview}</span>
<span className={cn("inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium", status.className)}>
{item.status === "rendering" && <Loader2 className="h-3 w-3 animate-spin" />}
{status.label}
</span>
</div>
);
})}
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,24 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { addDays, endOfMonth, startOfMonth } from "date-fns";
import { groupByDay, type AgendaItem } from "@shared/agenda";
/**
* Publications prévues autour du mois affiché, rangées par jour.
*
* La clé commence par `/api/scheduled-posts` : toute invalidation des
* publications planifiées (création, suppression, fin de montage) la rafraîchit.
*/
export function usePublicationAgenda(month: Date) {
// Le calendrier montre aussi les jours des mois voisins
const startDate = addDays(startOfMonth(month), -7).toISOString();
const endDate = addDays(endOfMonth(month), 7).toISOString();
const { data: items = [], isLoading } = useQuery<AgendaItem[]>({
queryKey: ["/api/scheduled-posts", "agenda", { startDate, endDate }],
refetchInterval: 30000,
});
const itemsByDay = useMemo(() => groupByDay(items), [items]);
return { items, itemsByDay, isLoading };
}
+3 -18
View File
@@ -38,7 +38,7 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import type { SocialPage, Media } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker";
@@ -110,13 +110,6 @@ function SortableMediaItem({
/** Nombre de médias récents proposés à la sélection. */
const RECENT_MEDIA_COUNT = 12;
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
function startOfToday(): Date {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
const STEPS: StepDef[] = [
{ id: "media", label: "Médias", icon: ImageIcon },
{ id: "text", label: "Texte", icon: Type },
@@ -182,12 +175,6 @@ export default function NewPost() {
.slice(0, RECENT_MEDIA_COUNT);
}, [allMedia]);
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
// passé n'a aucune influence sur une planification à venir.
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
@@ -845,12 +832,10 @@ export default function NewPost() {
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
occupiedDates={scheduledPosts
.filter(post => post.scheduledAt)
.map(post => new Date(post.scheduledAt!))}
selectedPageIds={selectedPages}
placeholder="Choisir une date"
/>
<p className="text-xs text-muted-foreground">Les créneaux déjà occupés sont signalés dans le calendrier.</p>
<p className="text-xs text-muted-foreground">Les jours et heures déjà occupés sur vos magasins sont signalés dans le calendrier.</p>
</div>
)}
</CardContent>
+1 -1
View File
@@ -884,7 +884,7 @@ export default function NewReel() {
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
occupiedDates={[]}
selectedPageIds={selectedPages}
placeholder="Choisir une date"
/>
)}
+1
View File
@@ -270,6 +270,7 @@ export default function ScheduleReel() {
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
selectedPageIds={selectedPages}
placeholder="Choisir la date et l'heure"
/>
{isPast && (