feat: replace inline status with floating toast notifications

This commit is contained in:
sebvtl728
2026-06-21 16:45:38 +02:00
parent 37261ba30d
commit 126c44db78
2 changed files with 604 additions and 326 deletions
+451 -211
View File
File diff suppressed because it is too large Load Diff
+153 -115
View File
@@ -321,9 +321,10 @@ const Bilan = () => {
const [openaiKey, setOpenaiKey] = useState(""); const [openaiKey, setOpenaiKey] = useState("");
const [openaiModel, setOpenaiModel] = useState("gpt-4o-mini"); const [openaiModel, setOpenaiModel] = useState("gpt-4o-mini");
const [observation, setObservation] = useState(""); const [observation, setObservation] = useState("");
const [status, setStatus] = useState(null); const [toasts, setToasts] = useState([]);
const [isAiLoading, setIsAiLoading] = useState(false); const [isAiLoading, setIsAiLoading] = useState(false);
const [dragOverBucket, setDragOverBucket] = useState(null); const [dragOverBucket, setDragOverBucket] = useState(null);
const [showApiConfig, setShowApiConfig] = useState(false);
const draggingIdRef = useRef(null); const draggingIdRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -343,22 +344,18 @@ const Bilan = () => {
); );
const showStatus = useCallback((message, type = "info", withProgress = false) => { const showStatus = useCallback((message, type = "info", withProgress = false) => {
setStatus({ const id = Date.now();
id: Date.now(), setToasts((prev) => [...prev, { id, message, type, withProgress }]);
message, if (!withProgress) {
type, setTimeout(() => {
withProgress, setToasts((prev) => prev.filter((t) => t.id !== id));
}); }, type === "info" ? 3000 : 5000);
}
}, []); }, []);
useEffect(() => { const dismissProgressToasts = useCallback(() => {
if (!status || status.withProgress) return; setToasts((prev) => prev.filter((t) => !t.withProgress));
const timeout = setTimeout( }, []);
() => setStatus(null),
status.type === "info" ? 3000 : 5000
);
return () => clearTimeout(timeout);
}, [status]);
const updateItem = useCallback((id, updater) => { const updateItem = useCallback((id, updater) => {
setItems((prev) => setItems((prev) =>
@@ -683,12 +680,13 @@ ${notesFormateur}`,
} catch (error) { } catch (error) {
showStatus(`Erreur OpenAI : ${error.message || error}`, "error"); showStatus(`Erreur OpenAI : ${error.message || error}`, "error");
} finally { } finally {
setStatus((prev) => (prev ? { ...prev, withProgress: false } : prev)); dismissProgressToasts();
setIsAiLoading(false); setIsAiLoading(false);
} }
}, [ }, [
aiNotes, aiNotes,
buildPayloadForAI, buildPayloadForAI,
dismissProgressToasts,
insertAISummaryIntoObservation, insertAISummaryIntoObservation,
observation, observation,
openaiKey, openaiKey,
@@ -909,9 +907,9 @@ ${notesFormateur}`,
} catch (error) { } catch (error) {
showStatus(`Erreur PDF : ${error.message || error}`, "error"); showStatus(`Erreur PDF : ${error.message || error}`, "error");
} finally { } finally {
setStatus((prev) => (prev ? { ...prev, withProgress: false } : prev)); dismissProgressToasts();
} }
}, [currentCp, firstName, lastName, observation, showStatus]); }, [currentCp, dismissProgressToasts, firstName, lastName, observation, showStatus]);
const currentTitle = titles[currentCp] || { h1: "", h2: "" }; const currentTitle = titles[currentCp] || { h1: "", h2: "" };
@@ -1057,112 +1055,152 @@ ${notesFormateur}`,
})} })}
</div> </div>
<div className="ai"> <div className="evaluation-summary-section">
<strong>Résumé</strong> <h3>Synthèse & Observations</h3>
<div className="row">
<div> <div className="summary-grid">
<label htmlFor="aiNotes">Notes formateur</label> {/* Colonne gauche : Saisie et Actions */}
<textarea <div className="summary-inputs-panel">
id="aiNotes" <div className="input-group">
value={aiNotes} <label htmlFor="aiNotes">Notes du formateur</label>
onChange={(event) => setAiNotes(event.target.value)} <textarea
/> id="aiNotes"
</div> placeholder="Saisissez des commentaires sur le comportement, le travail, etc."
<div> value={aiNotes}
<label htmlFor="aiTone">Tonalité</label> onChange={(event) => setAiNotes(event.target.value)}
<select />
id="aiTone"
value={aiTone}
onChange={(event) => setAiTone(event.target.value)}
>
<option value="neutre">Neutre</option>
<option value="valorisant">Valorisant</option>
<option value="exigeant">Exigeant</option>
</select>
<label className="checkbox-inline">
<input
id="aiPrepend"
type="checkbox"
checked={aiPrepend}
onChange={(event) => setAiPrepend(event.target.checked)}
/>{" "}
Insérer en tête
</label>
</div>
</div>
<div className="row">
<div>
<label htmlFor="openaiKey">Clé API OpenAI</label>
<input
id="openaiKey"
type="password"
placeholder="sk-..."
value={openaiKey}
onChange={(event) => setOpenaiKey(event.target.value)}
/>
</div>
<div>
<label htmlFor="openaiModel">Modèle</label>
<select
id="openaiModel"
value={openaiModel}
onChange={(event) => setOpenaiModel(event.target.value)}
>
{FALLBACK_MODELS.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
</div>
<div className="controls ai-actions">
<button type="button" onClick={handleGenerateObservation}>
Résumé...
</button>
<button
type="button"
onClick={generateAISummaryOpenAI}
disabled={isAiLoading}
>
Générer l'observation
</button>
{isAiLoading && (
<div className="ai-loader" aria-live="polite">
<span className="spinner" />
<span>Génération en cours</span>
</div> </div>
)}
{anySelected && ( <div className="row-options">
<button type="button" onClick={downloadPDF}> <div className="input-group">
Exporter PDF <label htmlFor="aiTone">Tonalité de l'IA</label>
</button> <select
)} id="aiTone"
value={aiTone}
onChange={(event) => setAiTone(event.target.value)}
>
<option value="neutre">Neutre 😐</option>
<option value="valorisant">Valorisant 🌟</option>
<option value="exigeant">Exigeant 🎯</option>
</select>
</div>
<div className="input-group-checkbox">
<label className="checkbox-label">
<input
id="aiPrepend"
type="checkbox"
checked={aiPrepend}
onChange={(event) => setAiPrepend(event.target.checked)}
/>
<span>Insérer en tête du bilan</span>
</label>
</div>
</div>
<div className="api-config-section">
<button
type="button"
className="api-toggle-btn"
onClick={() => setShowApiConfig(!showApiConfig)}
>
{showApiConfig ? "▲ Masquer les paramètres API" : "⚙️ Configuration API OpenAI (Optionnelle)"}
</button>
{showApiConfig && (
<div className="api-fields-grid">
<div className="input-group">
<label htmlFor="openaiKey">Clé API OpenAI</label>
<input
id="openaiKey"
type="password"
placeholder="sk-..."
value={openaiKey}
onChange={(event) => setOpenaiKey(event.target.value)}
/>
</div>
<div className="input-group">
<label htmlFor="openaiModel">Modèle</label>
<select
id="openaiModel"
value={openaiModel}
onChange={(event) => setOpenaiModel(event.target.value)}
>
{FALLBACK_MODELS.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
</div>
)}
</div>
<div className="action-buttons-group">
<button
type="button"
className="btn-secondary"
onClick={handleGenerateObservation}
>
📝 Générer le Bilan
</button>
<button
type="button"
className="btn-primary"
onClick={generateAISummaryOpenAI}
disabled={isAiLoading}
>
✨ Résumer avec l'IA
</button>
{anySelected && (
<button
type="button"
className="btn-success"
onClick={downloadPDF}
>
📄 Télécharger le PDF
</button>
)}
</div>
{isAiLoading && (
<div className="ai-status-loader">
<span className="spinner" />
<span>Génération du résumé en cours</span>
</div>
)}
</div>
{/* Colonne droite : Aperçu / Résultat en direct */}
<div className="summary-output-panel">
<label htmlFor="observation">Aperçu du bilan final (Markdown)</label>
<textarea
id="observation"
className="observation-textarea"
placeholder="Le bilan d'évaluation généré apparaîtra ici. Vous pouvez également le modifier manuellement."
value={observation}
onChange={(event) => setObservation(event.target.value)}
/>
</div>
</div> </div>
</div> </div>
</div>
{status && ( <div className="toast-container">
<div className={`status ${status.type}`}> {toasts.map((toast) => (
{status.withProgress ? ( <div key={toast.id} className={`toast toast-${toast.type}`}>
{toast.withProgress ? (
<> <>
<div>{status.message}</div> <span>{toast.message}</span>
<div className="progress"> <div className="toast-progress">
<div className="progress-bar" /> <div className="toast-progress-bar" />
</div> </div>
</> </>
) : ( ) : (
status.message <span>{toast.message}</span>
)} )}
</div> </div>
)} ))}
<textarea
id="observation"
className="observation-textarea"
placeholder="Observation générée ici…"
value={observation}
onChange={(event) => setObservation(event.target.value)}
/>
</div> </div>
</div> </div>
); );