Checkpoint: Refonte majeure du système de rappels : sélection granulaire au niveau des dates de formation individuelles (dateFormationId) au lieu des séquences. Permet de choisir précisément quelles dates d'une séquence recevront un rappel. Interface hiérarchique avec séquences dépliables et checkboxes par date. Scheduler adapté pour traiter date par date.
This commit is contained in:
199
client/src/components/DateFormationMultiSelect.tsx
Normal file
199
client/src/components/DateFormationMultiSelect.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Check, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateFormation {
|
||||
id: number;
|
||||
date: string;
|
||||
heureDebut: string;
|
||||
heureFin: string;
|
||||
}
|
||||
|
||||
interface Sequence {
|
||||
id: number;
|
||||
nom: string;
|
||||
formationNom: string;
|
||||
dates: DateFormation[];
|
||||
}
|
||||
|
||||
interface DateFormationMultiSelectProps {
|
||||
sequences: Sequence[];
|
||||
selectedDateIds: number[];
|
||||
onSelectionChange: (dateIds: number[]) => void;
|
||||
}
|
||||
|
||||
export function DateFormationMultiSelect({
|
||||
sequences,
|
||||
selectedDateIds,
|
||||
onSelectionChange,
|
||||
}: DateFormationMultiSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expandedSequences, setExpandedSequences] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggleSequence = (sequenceId: number) => {
|
||||
const newExpanded = new Set(expandedSequences);
|
||||
if (newExpanded.has(sequenceId)) {
|
||||
newExpanded.delete(sequenceId);
|
||||
} else {
|
||||
newExpanded.add(sequenceId);
|
||||
}
|
||||
setExpandedSequences(newExpanded);
|
||||
};
|
||||
|
||||
const toggleDate = (dateId: number) => {
|
||||
const newSelection = selectedDateIds.includes(dateId)
|
||||
? selectedDateIds.filter((id) => id !== dateId)
|
||||
: [...selectedDateIds, dateId];
|
||||
onSelectionChange(newSelection);
|
||||
};
|
||||
|
||||
const toggleAllDatesInSequence = (sequence: Sequence) => {
|
||||
const sequenceDateIds = sequence.dates.map((d) => d.id);
|
||||
const allSelected = sequenceDateIds.every((id) => selectedDateIds.includes(id));
|
||||
|
||||
if (allSelected) {
|
||||
// Désélectionner toutes les dates de cette séquence
|
||||
onSelectionChange(selectedDateIds.filter((id) => !sequenceDateIds.includes(id)));
|
||||
} else {
|
||||
// Sélectionner toutes les dates de cette séquence
|
||||
const newSelection = [...new Set([...selectedDateIds, ...sequenceDateIds])];
|
||||
onSelectionChange(newSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const getSelectedCount = () => {
|
||||
return selectedDateIds.length;
|
||||
};
|
||||
|
||||
const getSelectedLabel = () => {
|
||||
const count = getSelectedCount();
|
||||
if (count === 0) {
|
||||
return "Toutes les dates (par défaut)";
|
||||
}
|
||||
return `${count} date(s) sélectionnée(s)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{getSelectedLabel()}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<div className="max-h-[400px] overflow-y-auto p-2">
|
||||
{sequences.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
Aucune séquence disponible
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sequences.map((sequence) => {
|
||||
const sequenceDateIds = sequence.dates.map((d) => d.id);
|
||||
const allSelected = sequenceDateIds.length > 0 &&
|
||||
sequenceDateIds.every((id) => selectedDateIds.includes(id));
|
||||
const someSelected = sequenceDateIds.some((id) => selectedDateIds.includes(id));
|
||||
const isExpanded = expandedSequences.has(sequence.id);
|
||||
|
||||
return (
|
||||
<div key={sequence.id} className="space-y-1">
|
||||
{/* En-tête de séquence */}
|
||||
<div className="flex items-center gap-2 rounded-sm hover:bg-accent p-2">
|
||||
<button
|
||||
onClick={() => toggleSequence(sequence.id)}
|
||||
className="flex items-center gap-1 flex-1 text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{sequence.nom}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{sequence.formationNom} • {sequence.dates.length} date(s)
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleAllDatesInSequence(sequence)}
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-sm border border-primary",
|
||||
allSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: someSelected
|
||||
? "bg-primary/50 text-primary-foreground"
|
||||
: "opacity-50 hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{(allSelected || someSelected) && (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Liste des dates */}
|
||||
{isExpanded && (
|
||||
<div className="ml-6 space-y-1">
|
||||
{sequence.dates.map((date) => {
|
||||
const isSelected = selectedDateIds.includes(date.id);
|
||||
return (
|
||||
<button
|
||||
key={date.id}
|
||||
onClick={() => toggleDate(date.id)}
|
||||
className="flex items-center gap-2 w-full rounded-sm hover:bg-accent p-2 text-left"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="flex-1 text-sm">
|
||||
{formatDate(date.date)} • {date.heureDebut} - {date.heureFin}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t p-2 text-xs text-muted-foreground">
|
||||
Si aucune date n'est sélectionnée, le rappel s'appliquera à toutes les dates
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { trpc } from "@/lib/trpc";
|
||||
import { Bell, Edit, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { FileUpload } from "@/components/FileUpload";
|
||||
import { SequenceMultiSelect } from "@/components/SequenceMultiSelect";
|
||||
import { DateFormationMultiSelect } from "@/components/DateFormationMultiSelect";
|
||||
|
||||
// Types de templates disponibles pour les rappels
|
||||
const TEMPLATE_TYPES = [
|
||||
@@ -48,7 +48,7 @@ export default function AdminRappels() {
|
||||
joursAvant: 7,
|
||||
heureEnvoi: "09:00",
|
||||
actif: true,
|
||||
sequenceIds: [] as number[],
|
||||
dateFormationIds: [] as number[],
|
||||
fichier: null as {
|
||||
nomFichier: string;
|
||||
urlFichier: string;
|
||||
@@ -114,7 +114,7 @@ export default function AdminRappels() {
|
||||
joursAvant: 7,
|
||||
heureEnvoi: "09:00",
|
||||
actif: true,
|
||||
sequenceIds: [],
|
||||
dateFormationIds: [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -135,7 +135,7 @@ export default function AdminRappels() {
|
||||
joursAvant: rappel.joursAvant,
|
||||
heureEnvoi: rappel.heureEnvoi,
|
||||
actif: rappel.actif,
|
||||
sequenceIds: rappel.sequenceIds || [],
|
||||
dateFormationIds: rappel.dateFormationIds || [],
|
||||
fichier: rappel.nomFichier ? {
|
||||
nomFichier: rappel.nomFichier,
|
||||
urlFichier: rappel.urlFichier,
|
||||
@@ -382,20 +382,22 @@ export default function AdminRappels() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Séquences concernées (optionnel)</Label>
|
||||
<SequenceMultiSelect
|
||||
<Label>Dates de formation concernées (optionnel)</Label>
|
||||
<DateFormationMultiSelect
|
||||
sequences={sequences?.map(seq => ({
|
||||
id: seq.id,
|
||||
nom: seq.nom,
|
||||
formationNom: seq.formation?.nom
|
||||
formationNom: seq.formation?.nom || '',
|
||||
dates: seq.dates?.map(d => ({
|
||||
id: d.id,
|
||||
date: d.dateDebut,
|
||||
heureDebut: d.heureDebut || '',
|
||||
heureFin: d.heureFin || '',
|
||||
})) || [],
|
||||
})) || []}
|
||||
selectedIds={formData.sequenceIds}
|
||||
onChange={(sequenceIds) => setFormData({ ...formData, sequenceIds })}
|
||||
placeholder="Toutes les séquences (par défaut)"
|
||||
selectedDateIds={formData.dateFormationIds}
|
||||
onSelectionChange={(dateFormationIds) => setFormData({ ...formData, dateFormationIds })}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Si aucune séquence n'est sélectionnée, le rappel s'appliquera à toutes les séquences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FileUpload
|
||||
@@ -526,20 +528,22 @@ export default function AdminRappels() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Séquences concernées (optionnel)</Label>
|
||||
<SequenceMultiSelect
|
||||
<Label>Dates de formation concernées (optionnel)</Label>
|
||||
<DateFormationMultiSelect
|
||||
sequences={sequences?.map(seq => ({
|
||||
id: seq.id,
|
||||
nom: seq.nom,
|
||||
formationNom: seq.formation?.nom
|
||||
formationNom: seq.formation?.nom || '',
|
||||
dates: seq.dates?.map(d => ({
|
||||
id: d.id,
|
||||
date: d.dateDebut,
|
||||
heureDebut: d.heureDebut || '',
|
||||
heureFin: d.heureFin || '',
|
||||
})) || [],
|
||||
})) || []}
|
||||
selectedIds={formData.sequenceIds}
|
||||
onChange={(sequenceIds) => setFormData({ ...formData, sequenceIds })}
|
||||
placeholder="Toutes les séquences (par défaut)"
|
||||
selectedDateIds={formData.dateFormationIds}
|
||||
onSelectionChange={(dateFormationIds) => setFormData({ ...formData, dateFormationIds })}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Si aucune séquence n'est sélectionnée, le rappel s'appliquera à toutes les séquences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FileUpload
|
||||
|
||||
Reference in New Issue
Block a user