feat: replace inline status with floating toast notifications
This commit is contained in:
parent
37261ba30d
commit
126c44db78
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user