mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-18 04:36:32 +02:00
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:
11 files changed
+553
-43
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -884,7 +884,7 @@ export default function NewReel() {
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
occupiedDates={[]}
|
||||
selectedPageIds={selectedPages}
|
||||
placeholder="Choisir une date"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -270,6 +270,7 @@ export default function ScheduleReel() {
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
selectedPageIds={selectedPages}
|
||||
placeholder="Choisir la date et l'heure"
|
||||
/>
|
||||
{isPast && (
|
||||
|
||||
Reference in new issue
Block a user