From f989f592a9dca893833b456b53092d5f72395d90 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Fri, 27 Mar 2026 13:21:30 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20add=20schedule=20UI=20=E2=80=94=20day?= =?UTF-8?q?=20picker,=20time,=20emails=20in=20theme=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/schedules.ts | 35 +++ .../components/settings/SettingsSchedule.tsx | 286 ++++++++++++++++++ frontend/src/i18n/fr.ts | 19 ++ frontend/src/pages/ThemeManager.tsx | 4 + 4 files changed, 344 insertions(+) create mode 100644 frontend/src/api/schedules.ts create mode 100644 frontend/src/components/settings/SettingsSchedule.tsx diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts new file mode 100644 index 0000000..ecccda8 --- /dev/null +++ b/frontend/src/api/schedules.ts @@ -0,0 +1,35 @@ +import { api } from './client'; + +// ---- Schedule types ---- + +export interface ScheduleResponse { + id: string; + theme_id: string; + enabled: boolean; + days: string[]; + time_utc: string; + emails: string[]; + last_run_at: string | null; +} + +export interface UpsertScheduleRequest { + enabled: boolean; + days: string[]; + time_utc: string; + emails: string[]; +} + +/** Schedules API endpoints (per-theme generation schedule). */ +export const schedulesApi = { + /** GET /themes/:themeId/schedule -- retrieve the schedule for a theme (404 if none). */ + get: (themeId: string): Promise => + api.get(`/themes/${themeId}/schedule`), + + /** PUT /themes/:themeId/schedule -- create or update the schedule for a theme. */ + upsert: (themeId: string, data: UpsertScheduleRequest): Promise => + api.put(`/themes/${themeId}/schedule`, data), + + /** DELETE /themes/:themeId/schedule -- remove the schedule for a theme. */ + remove: (themeId: string): Promise => + api.delete(`/themes/${themeId}/schedule`), +}; diff --git a/frontend/src/components/settings/SettingsSchedule.tsx b/frontend/src/components/settings/SettingsSchedule.tsx new file mode 100644 index 0000000..f482046 --- /dev/null +++ b/frontend/src/components/settings/SettingsSchedule.tsx @@ -0,0 +1,286 @@ +import { type Component, createSignal, onMount, For, Show } from 'solid-js'; +import { Trash2, Plus } from 'lucide-solid'; +import { schedulesApi } from '~/api/schedules'; +import type { ScheduleResponse } from '~/api/schedules'; +import { useI18n } from '~/i18n'; +import { isApiError } from '~/types'; +import { useToast } from '~/components/ui/Toast'; + +interface SettingsScheduleProps { + themeId: string; +} + +const DAYS = [ + { code: 'mon', labelKey: 'schedule.dayMon' as const }, + { code: 'tue', labelKey: 'schedule.dayTue' as const }, + { code: 'wed', labelKey: 'schedule.dayWed' as const }, + { code: 'thu', labelKey: 'schedule.dayThu' as const }, + { code: 'fri', labelKey: 'schedule.dayFri' as const }, + { code: 'sat', labelKey: 'schedule.daySat' as const }, + { code: 'sun', labelKey: 'schedule.daySun' as const }, +]; + +/** + * Schedule configuration section within the ThemeManager. + * + * Loads the existing schedule on mount (treats 404 as "no schedule yet"), + * and auto-saves on every change. Grays out the form when the schedule is + * disabled, but keeps it visible so users can still configure it before + * re-enabling. + */ +const SettingsSchedule: Component = (props) => { + const { t } = useI18n(); + const { addToast } = useToast(); + + const [schedule, setSchedule] = createSignal(null); + const [enabled, setEnabled] = createSignal(false); + const [selectedDays, setSelectedDays] = createSignal([]); + const [timeUtc, setTimeUtc] = createSignal('08:00'); + const [emails, setEmails] = createSignal([]); + const [newEmail, setNewEmail] = createSignal(''); + const [saving, setSaving] = createSignal(false); + + // ---- Load schedule on mount ---- + onMount(async () => { + try { + const data = await schedulesApi.get(props.themeId); + setSchedule(data); + setEnabled(data.enabled); + setSelectedDays(data.days); + setTimeUtc(data.time_utc); + setEmails(data.emails); + } catch (err) { + // 404 means no schedule configured yet — that's fine + if (isApiError(err) && err.status === 404) { + setSchedule(null); + } else { + console.error('Failed to load schedule:', err); + } + } + }); + + // ---- Auto-save helper ---- + const save = async (patch: { + enabled?: boolean; + days?: string[]; + time_utc?: string; + emails?: string[]; + }) => { + setSaving(true); + try { + const updated = await schedulesApi.upsert(props.themeId, { + enabled: patch.enabled ?? enabled(), + days: patch.days ?? selectedDays(), + time_utc: patch.time_utc ?? timeUtc(), + emails: patch.emails ?? emails(), + }); + setSchedule(updated); + addToast({ type: 'success', message: t('schedule.saved'), duration: 3000 }); + } catch (err) { + const msg = isApiError(err) ? err.message : t('common.error'); + addToast({ type: 'error', message: msg, duration: 5000 }); + } finally { + setSaving(false); + } + }; + + // ---- Toggle enabled ---- + const handleToggleEnabled = (checked: boolean) => { + setEnabled(checked); + void save({ enabled: checked }); + }; + + // ---- Toggle a day ---- + const toggleDay = (code: string) => { + const next = selectedDays().includes(code) + ? selectedDays().filter((d) => d !== code) + : [...selectedDays(), code]; + setSelectedDays(next); + void save({ days: next }); + }; + + // ---- Time change ---- + const handleTimeChange = (value: string) => { + setTimeUtc(value); + void save({ time_utc: value }); + }; + + // ---- Email management ---- + const handleAddEmail = () => { + const email = newEmail().trim(); + if (!email || emails().length >= 3 || emails().includes(email)) return; + const next = [...emails(), email]; + setEmails(next); + setNewEmail(''); + void save({ emails: next }); + }; + + const handleRemoveEmail = (email: string) => { + const next = emails().filter((e) => e !== email); + setEmails(next); + void save({ emails: next }); + }; + + // ---- Delete schedule ---- + const handleDelete = async () => { + try { + await schedulesApi.remove(props.themeId); + setSchedule(null); + setEnabled(false); + setSelectedDays([]); + setTimeUtc('08:00'); + setEmails([]); + addToast({ type: 'success', message: t('schedule.deleted'), duration: 3000 }); + } catch (err) { + const msg = isApiError(err) ? err.message : t('common.error'); + addToast({ type: 'error', message: msg, duration: 5000 }); + } + }; + + const isDisabled = () => !enabled(); + + return ( +
+

{t('schedule.title')}

+

+ {t('schedule.noSchedule')} +

+
+ + {/* Enable toggle */} +
+ handleToggleEnabled(e.currentTarget.checked)} + class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" + /> + + +
+ +
+ + {/* Day picker */} +
+ +
+ + {(day) => ( + + )} + +
+
+ + {/* Time input */} +
+ + handleTimeChange(e.currentTarget.value)} + class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md py-2 px-3 border" + /> +
+ + {/* Email recipients */} +
+ + + {/* Existing emails */} +
+ + {(email) => ( +
+ {email} + +
+ )} +
+
+ + {/* Add email row (hidden when 3 reached) */} + +
+ setNewEmail(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddEmail(); + } + }} + /> + +
+
+ = 3}> +

{t('schedule.maxEmails')}

+
+
+ + {/* Delete schedule (only shown when a schedule exists) */} + +
+ +
+
+
+
+ ); +}; + +export default SettingsSchedule; diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index e912d13..a85dcb8 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -425,6 +425,25 @@ const fr = { 'llmLogs.back': 'Retour', 'llmLogs.articleUrl': 'Article', + // Schedule + 'schedule.title': 'Planification', + 'schedule.enabled': 'Planification activee', + 'schedule.days': 'Jours', + 'schedule.time': 'Heure (UTC)', + 'schedule.emails': 'Destinataires', + 'schedule.addEmail': 'Ajouter une adresse', + 'schedule.maxEmails': '3 adresses maximum', + 'schedule.saved': 'Planification enregistree', + 'schedule.deleted': 'Planification supprimee', + 'schedule.noSchedule': 'Aucune planification configuree.', + 'schedule.dayMon': 'L', + 'schedule.dayTue': 'M', + 'schedule.dayWed': 'M', + 'schedule.dayThu': 'J', + 'schedule.dayFri': 'V', + 'schedule.daySat': 'S', + 'schedule.daySun': 'D', + // Common 'common.loading': 'Chargement...', 'common.error': 'Une erreur est survenue.', diff --git a/frontend/src/pages/ThemeManager.tsx b/frontend/src/pages/ThemeManager.tsx index bdacd10..601ccce 100644 --- a/frontend/src/pages/ThemeManager.tsx +++ b/frontend/src/pages/ThemeManager.tsx @@ -25,6 +25,7 @@ import { useI18n } from '~/i18n'; import { isApiError } from '~/types'; import type { Source } from '~/types'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; +import SettingsSchedule from '~/components/settings/SettingsSchedule'; /** * Theme management page ("Personnaliser les syntheses"). @@ -908,6 +909,9 @@ const ThemeManager: Component = () => {
+ {/* ── Schedule card ── */} + + {/* ── Delete theme ── */}