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

File diff suppressed because it is too large Load Diff

View File

@ -321,9 +321,10 @@ const Bilan = () => {
const [openaiKey, setOpenaiKey] = useState("");
const [openaiModel, setOpenaiModel] = useState("gpt-4o-mini");
const [observation, setObservation] = useState("");
const [status, setStatus] = useState(null);
const [toasts, setToasts] = useState([]);
const [isAiLoading, setIsAiLoading] = useState(false);
const [dragOverBucket, setDragOverBucket] = useState(null);
const [showApiConfig, setShowApiConfig] = useState(false);
const draggingIdRef = useRef(null);
useEffect(() => {
@ -343,22 +344,18 @@ const Bilan = () => {
);
const showStatus = useCallback((message, type = "info", withProgress = false) => {
setStatus({
id: Date.now(),
message,
type,
withProgress,
});
const id = Date.now();
setToasts((prev) => [...prev, { id, message, type, withProgress }]);
if (!withProgress) {
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, type === "info" ? 3000 : 5000);
}
}, []);
useEffect(() => {
if (!status || status.withProgress) return;
const timeout = setTimeout(
() => setStatus(null),
status.type === "info" ? 3000 : 5000
);
return () => clearTimeout(timeout);
}, [status]);
const dismissProgressToasts = useCallback(() => {
setToasts((prev) => prev.filter((t) => !t.withProgress));
}, []);
const updateItem = useCallback((id, updater) => {
setItems((prev) =>
@ -683,12 +680,13 @@ ${notesFormateur}`,
} catch (error) {
showStatus(`Erreur OpenAI : ${error.message || error}`, "error");
} finally {
setStatus((prev) => (prev ? { ...prev, withProgress: false } : prev));
dismissProgressToasts();
setIsAiLoading(false);
}
}, [
aiNotes,
buildPayloadForAI,
dismissProgressToasts,
insertAISummaryIntoObservation,
observation,
openaiKey,
@ -909,9 +907,9 @@ ${notesFormateur}`,
} catch (error) {
showStatus(`Erreur PDF : ${error.message || error}`, "error");
} 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: "" };
@ -1057,112 +1055,152 @@ ${notesFormateur}`,
})}
</div>
<div className="ai">
<strong>Résumé</strong>
<div className="row">
<div>
<label htmlFor="aiNotes">Notes formateur</label>
<textarea
id="aiNotes"
value={aiNotes}
onChange={(event) => setAiNotes(event.target.value)}
/>
</div>
<div>
<label htmlFor="aiTone">Tonalité</label>
<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 className="evaluation-summary-section">
<h3>Synthèse & Observations</h3>
<div className="summary-grid">
{/* Colonne gauche : Saisie et Actions */}
<div className="summary-inputs-panel">
<div className="input-group">
<label htmlFor="aiNotes">Notes du formateur</label>
<textarea
id="aiNotes"
placeholder="Saisissez des commentaires sur le comportement, le travail, etc."
value={aiNotes}
onChange={(event) => setAiNotes(event.target.value)}
/>
</div>
)}
{anySelected && (
<button type="button" onClick={downloadPDF}>
Exporter PDF
</button>
)}
<div className="row-options">
<div className="input-group">
<label htmlFor="aiTone">Tonalité de l'IA</label>
<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>
{status && (
<div className={`status ${status.type}`}>
{status.withProgress ? (
</div>
<div className="toast-container">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
{toast.withProgress ? (
<>
<div>{status.message}</div>
<div className="progress">
<div className="progress-bar" />
<span>{toast.message}</span>
<div className="toast-progress">
<div className="toast-progress-bar" />
</div>
</>
) : (
status.message
<span>{toast.message}</span>
)}
</div>
)}
<textarea
id="observation"
className="observation-textarea"
placeholder="Observation générée ici…"
value={observation}
onChange={(event) => setObservation(event.target.value)}
/>
))}
</div>
</div>
);