import { useState } from "react"; import { trpc } from "@/lib/trpc"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { Separator } from "@/components/ui/separator"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "@/components/ui/card"; import { Rss, Plus, Pencil, Trash2, CheckCircle2, XCircle, Clock, AlertCircle, Settings2, Zap, Globe, ChevronDown, ChevronUp, Save, } from "lucide-react"; import { useLocalAuth } from "@/contexts/LocalAuthContext"; import { cn } from "@/lib/utils"; // ─── Types ──────────────────────────────────────────────────────────────────── type FeedType = "veille" | "aap"; type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "generale"; type CategorieAap = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; interface AutoRule { keyword: string; value: string; } interface RssFeed { id: number; url: string; name: string; feedType: FeedType; defaultTypeVeille: TypeVeille | null; defaultCategorieAap: CategorieAap | null; autoRules: AutoRule[] | null; isActive: boolean; lastFetchedAt: Date | null; lastFetchStatus: "ok" | "error" | "pending" | null; lastFetchError: string | null; } // ─── Constantes ─────────────────────────────────────────────────────────────── const TYPE_VEILLE_LABELS: Record = { reglementaire: "Réglementaire", concurrentielle: "Concurrentielle", technologique: "Technologique", generale: "Générale", }; const CATEGORIE_AAP_OPTIONS: CategorieAap[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]; const TYPE_VEILLE_OPTIONS: TypeVeille[] = ["reglementaire", "concurrentielle", "technologique", "generale"]; const INTERVAL_OPTIONS = [ { value: 30, label: "30 minutes" }, { value: 60, label: "1 heure" }, { value: 120, label: "2 heures" }, { value: 360, label: "6 heures" }, { value: 720, label: "12 heures" }, { value: 1440, label: "24 heures" }, ]; // ─── Formulaire de flux ─────────────────────────────────────────────────────── interface FeedFormData { url: string; name: string; feedType: FeedType; defaultTypeVeille: TypeVeille | ""; defaultCategorieAap: CategorieAap | ""; autoRules: AutoRule[]; isActive: boolean; } const EMPTY_FORM: FeedFormData = { url: "", name: "", feedType: "veille", defaultTypeVeille: "reglementaire", defaultCategorieAap: "", autoRules: [], isActive: true, }; function FeedFormDialog({ open, onClose, feed, onSaved, }: { open: boolean; onClose: () => void; feed: RssFeed | null; onSaved: () => void; }) { const isEdit = !!feed; const [form, setForm] = useState(() => feed ? { url: feed.url, name: feed.name, feedType: feed.feedType, defaultTypeVeille: feed.defaultTypeVeille ?? "", defaultCategorieAap: feed.defaultCategorieAap ?? "", autoRules: feed.autoRules ?? [], isActive: feed.isActive, } : { ...EMPTY_FORM } ); const [newRuleKeyword, setNewRuleKeyword] = useState(""); const [newRuleValue, setNewRuleValue] = useState(""); const createMutation = trpc.rss.create.useMutation({ onSuccess: () => { toast.success("Flux RSS ajouté"); onSaved(); onClose(); }, onError: (e) => toast.error("Erreur", { description: e.message }), }); const updateMutation = trpc.rss.update.useMutation({ onSuccess: () => { toast.success("Flux RSS mis à jour"); onSaved(); onClose(); }, onError: (e) => toast.error("Erreur", { description: e.message }), }); const isPending = createMutation.isPending || updateMutation.isPending; const handleSubmit = () => { if (!form.url || !form.name) { toast.error("URL et nom sont requis"); return; } const payload = { url: form.url, name: form.name, feedType: form.feedType, defaultTypeVeille: form.feedType === "veille" && form.defaultTypeVeille ? form.defaultTypeVeille : undefined, defaultCategorieAap: form.feedType === "aap" && form.defaultCategorieAap ? (form.defaultCategorieAap as CategorieAap) : undefined, autoRules: form.autoRules.length > 0 ? form.autoRules : undefined, isActive: form.isActive, }; if (isEdit && feed) { updateMutation.mutate({ id: feed.id, ...payload }); } else { createMutation.mutate(payload); } }; const addRule = () => { if (!newRuleKeyword.trim() || !newRuleValue.trim()) return; setForm((f) => ({ ...f, autoRules: [...f.autoRules, { keyword: newRuleKeyword.trim(), value: newRuleValue.trim() }], })); setNewRuleKeyword(""); setNewRuleValue(""); }; const removeRule = (idx: number) => { setForm((f) => ({ ...f, autoRules: f.autoRules.filter((_, i) => i !== idx) })); }; return ( !o && onClose()}> {isEdit ? "Modifier le flux RSS" : "Ajouter un flux RSS"} Configurez l'URL, le type de contenu et les règles d'automatisme pour ce flux.
{/* URL */}
setForm((f) => ({ ...f, url: e.target.value }))} />
{/* Nom */}
setForm((f) => ({ ...f, name: e.target.value }))} />
{/* Type de flux */}
{(["veille", "aap"] as FeedType[]).map((type) => ( ))}
{/* Valeur par défaut selon le type */} {form.feedType === "veille" && (
)} {form.feedType === "aap" && (
)} {/* Règles d'automatisme */}

Si le titre ou le résumé d'un article contient le mot-clé, la {form.feedType === "veille" ? "catégorie de veille" : "catégorie AAP"} sera automatiquement assignée.

{/* Règles existantes */} {form.autoRules.length > 0 && (
{form.autoRules.map((rule, idx) => (
{rule.keyword} {rule.value}
))}
)} {/* Ajouter une règle */}
setNewRuleKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addRule()} className="h-8 text-sm" />
{form.feedType === "veille" ? ( ) : ( )}
{/* Actif */}

Désactivez pour suspendre la lecture sans supprimer le flux

setForm((f) => ({ ...f, isActive: v }))} />
); } // ─── Carte d'un flux ────────────────────────────────────────────────────────── function FeedCard({ feed, isAdmin, onEdit, onDelete, onToggle, }: { feed: RssFeed; isAdmin: boolean; onEdit: () => void; onDelete: () => void; onToggle: (active: boolean) => void; }) { const [rulesOpen, setRulesOpen] = useState(false); const rules = feed.autoRules ?? []; const statusIcon = () => { if (feed.lastFetchStatus === "ok") return ; if (feed.lastFetchStatus === "error") return ; return ; }; const statusLabel = () => { if (feed.lastFetchStatus === "ok" && feed.lastFetchedAt) { return `Dernière lecture : ${new Date(feed.lastFetchedAt).toLocaleString("fr-FR")}`; } if (feed.lastFetchStatus === "error") return `Erreur : ${feed.lastFetchError ?? "inconnue"}`; return "Jamais lu"; }; return (
{/* Icône type */}
{feed.feedType === "veille" ? : }
{/* Infos */}
{feed.name} {feed.feedType === "veille" ? "Veille" : "AAP"} {feed.feedType === "veille" && feed.defaultTypeVeille && ( {TYPE_VEILLE_LABELS[feed.defaultTypeVeille]} )} {feed.feedType === "aap" && feed.defaultCategorieAap && ( {feed.defaultCategorieAap} )}

{feed.url}

{statusIcon()} {statusLabel()}
{/* Règles d'automatisme */} {rules.length > 0 && (
{rulesOpen && (
{rules.map((rule, i) => ( {rule.keyword} {rule.value} ))}
)}
)}
{/* Actions */} {isAdmin && (
)}
); } // ─── Panneau de configuration ───────────────────────────────────────────────── function SettingsPanel({ isAdmin }: { isAdmin: boolean }) { const { data: settings, refetch } = trpc.rss.getSettings.useQuery(); const saveMutation = trpc.rss.saveSettings.useMutation({ onSuccess: () => { toast.success("Paramètres RSS sauvegardés"); refetch(); }, onError: (e) => toast.error("Erreur", { description: e.message }), }); const [fetchMode, setFetchMode] = useState<"interval" | "scheduled">(settings?.fetchMode ?? "scheduled"); const [fetchIntervalMinutes, setFetchIntervalMinutes] = useState(settings?.fetchIntervalMinutes ?? 360); const [scheduledTime, setScheduledTime] = useState(settings?.scheduledTime ?? "06:00"); const [autoFetchEnabled, setAutoFetchEnabled] = useState(settings?.autoFetchEnabled ?? true); // Sync state when settings load if (settings && settings.fetchMode !== fetchMode && !saveMutation.isPending) { setFetchMode(settings.fetchMode); setFetchIntervalMinutes(settings.fetchIntervalMinutes); setScheduledTime(settings.scheduledTime ?? "06:00"); setAutoFetchEnabled(settings.autoFetchEnabled); } const handleSave = () => { saveMutation.mutate({ fetchMode, fetchIntervalMinutes, scheduledTime, autoFetchEnabled, }); }; return ( Paramètres de lecture automatique Configurez la fréquence et le mode de lecture des flux RSS. {/* Activation */}

Lecture automatique

Active la récupération périodique des flux

{/* Mode */}
{(["scheduled", "interval"] as const).map((mode) => ( ))}
{/* Paramètre selon le mode */} {fetchMode === "scheduled" ? (
setScheduledTime(e.target.value)} disabled={!isAdmin} className="w-36" />
) : (
)} {isAdmin && ( )}
); } // ─── Page principale ────────────────────────────────────────────────────────── export default function RssFeeds() { const { user } = useLocalAuth(); const isAdmin = user?.role === "admin"; const { data: feeds = [], refetch, isLoading } = trpc.rss.list.useQuery(); const [dialogOpen, setDialogOpen] = useState(false); const [editFeed, setEditFeed] = useState(null); const [deleteId, setDeleteId] = useState(null); const [filterType, setFilterType] = useState<"all" | "veille" | "aap">("all"); const toggleMutation = trpc.rss.toggleActive.useMutation({ onSuccess: () => refetch(), onError: (e) => toast.error("Erreur", { description: e.message }), }); const deleteMutation = trpc.rss.delete.useMutation({ onSuccess: () => { toast.success("Flux supprimé"); setDeleteId(null); refetch(); }, onError: (e) => toast.error("Erreur", { description: e.message }), }); const filteredFeeds = feeds.filter((f) => filterType === "all" || f.feedType === filterType); const veilleCount = feeds.filter((f) => f.feedType === "veille").length; const aapCount = feeds.filter((f) => f.feedType === "aap").length; const activeCount = feeds.filter((f) => f.isActive).length; return (
{/* En-tête */}

Flux RSS

Gérez les sources RSS qui alimentent automatiquement la veille réglementaire et les appels à projets.

{isAdmin && ( )}
{/* Statistiques */}
{[ { label: "Total", value: feeds.length, color: "text-foreground" }, { label: "Actifs", value: activeCount, color: "text-green-600" }, { label: "Veille", value: veilleCount, color: "text-blue-600" }, { label: "AAP", value: aapCount, color: "text-amber-600" }, ].map((stat) => (

{stat.value}

{stat.label}

))}
{/* Filtres */}
{(["all", "veille", "aap"] as const).map((type) => ( ))}
{/* Liste des flux */}
{isLoading ? (

Chargement des flux…

) : filteredFeeds.length === 0 ? (

Aucun flux RSS configuré

{isAdmin && ( )}
) : ( filteredFeeds.map((feed) => ( { setEditFeed(feed as RssFeed); setDialogOpen(true); }} onDelete={() => setDeleteId(feed.id)} onToggle={(active) => toggleMutation.mutate({ id: feed.id, isActive: active })} /> )) )}
{/* Paramètres de lecture */} {/* Informations sur les automatismes */}

Comment fonctionnent les règles d'automatisme ?

Lors de la lecture d'un flux, chaque article est analysé. Si son titre ou son résumé contient un mot-clé défini dans les règles, la catégorie ou le type de veille correspondant lui est automatiquement assigné. Si aucune règle ne correspond, la valeur par défaut du flux est utilisée.

{/* Dialog ajout/édition */} { setDialogOpen(false); setEditFeed(null); }} feed={editFeed} onSaved={() => refetch()} /> {/* Confirmation suppression */} !o && setDeleteId(null)}> Supprimer ce flux RSS ? Cette action est irréversible. Le flux sera définitivement supprimé mais les entrées déjà importées seront conservées. Annuler deleteId && deleteMutation.mutate({ id: deleteId })} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > Supprimer
); }