Initial commit: StudyOS platform
This commit is contained in:
745
client/src/pages/Settings.jsx
Normal file
745
client/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,745 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
TestTube,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getModels,
|
||||
createModel,
|
||||
updateModel,
|
||||
deleteModel,
|
||||
testModel,
|
||||
getConfig,
|
||||
updateConfig,
|
||||
testVlm,
|
||||
getPdfs,
|
||||
deletePdf,
|
||||
getProgress,
|
||||
resetProgressTopic,
|
||||
} from '../lib/api';
|
||||
|
||||
function ToggleSwitch({ checked, onChange }) {
|
||||
return (
|
||||
<button
|
||||
className={`toggle-switch ${checked ? 'on' : ''}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
aria-label="Toggle"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ title, onClose, children }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{title}</h3>
|
||||
<button onClick={onClose} className="modal-close-btn">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const [activeTab, setActiveTab] = useState('models');
|
||||
|
||||
// Models tab
|
||||
const [models, setModels] = useState([]);
|
||||
const [modelModalOpen, setModelModalOpen] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState(null);
|
||||
const [modelForm, setModelForm] = useState({
|
||||
name: '',
|
||||
api_base: '',
|
||||
api_key: '',
|
||||
provider: 'openai',
|
||||
is_default_main: false,
|
||||
is_default_fork: false,
|
||||
is_default_exam: false,
|
||||
});
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [testResults, setTestResults] = useState({});
|
||||
|
||||
// PDFs & VLM tab
|
||||
const [pdfs, setPdfs] = useState([]);
|
||||
const [vlmUrl, setVlmUrl] = useState('');
|
||||
const [vlmSaving, setVlmSaving] = useState(false);
|
||||
const [vlmTestResult, setVlmTestResult] = useState(null);
|
||||
|
||||
// Progress tab
|
||||
const [progress, setProgress] = useState([]);
|
||||
|
||||
const refreshModels = useCallback(async () => {
|
||||
try {
|
||||
const rows = await getModels();
|
||||
setModels(rows);
|
||||
} catch (err) {
|
||||
console.error('[Settings] refresh models error:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshPdfs = useCallback(async () => {
|
||||
try {
|
||||
const rows = await getPdfs();
|
||||
setPdfs(rows);
|
||||
} catch (err) {
|
||||
console.error('[Settings] refresh pdfs error:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshProgress = useCallback(async () => {
|
||||
try {
|
||||
const rows = await getProgress();
|
||||
setProgress(rows);
|
||||
} catch (err) {
|
||||
console.error('[Settings] refresh progress error:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await getConfig();
|
||||
if (cfg.vlm_endpoint) {
|
||||
setVlmUrl(cfg.vlm_endpoint);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Settings] load config error:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshModels();
|
||||
refreshPdfs();
|
||||
refreshProgress();
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
// Model handlers
|
||||
const openAddModel = () => {
|
||||
setEditingModel(null);
|
||||
setModelForm({
|
||||
name: '',
|
||||
api_base: '',
|
||||
api_key: '',
|
||||
provider: 'openai',
|
||||
is_default_main: false,
|
||||
is_default_fork: false,
|
||||
is_default_exam: false,
|
||||
});
|
||||
setModelModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModel = (model) => {
|
||||
setEditingModel(model);
|
||||
setModelForm({
|
||||
name: model.name || '',
|
||||
api_base: model.api_base || '',
|
||||
api_key: model.api_key || '',
|
||||
provider: model.provider || 'openai',
|
||||
is_default_main: !!model.is_default_main,
|
||||
is_default_fork: !!model.is_default_fork,
|
||||
is_default_exam: !!model.is_default_exam,
|
||||
});
|
||||
setModelModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveModel = async () => {
|
||||
if (!modelForm.name || !modelForm.api_base) return;
|
||||
setModelLoading(true);
|
||||
try {
|
||||
const body = {
|
||||
name: modelForm.name,
|
||||
api_base: modelForm.api_base,
|
||||
api_key: modelForm.api_key,
|
||||
provider: modelForm.provider,
|
||||
is_default_main: modelForm.is_default_main,
|
||||
is_default_fork: modelForm.is_default_fork,
|
||||
is_default_exam: modelForm.is_default_exam,
|
||||
};
|
||||
if (editingModel) {
|
||||
await updateModel(editingModel.id, body);
|
||||
} else {
|
||||
await createModel(body);
|
||||
}
|
||||
await refreshModels();
|
||||
setModelModalOpen(false);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setModelLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = async (id) => {
|
||||
if (!window.confirm('¿Eliminar este modelo? No se puede recuperar.')) return;
|
||||
try {
|
||||
await deleteModel(id);
|
||||
await refreshModels();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestModel = async (id) => {
|
||||
setTestResults((prev) => ({ ...prev, [id]: { loading: true } }));
|
||||
try {
|
||||
const result = await testModel(id);
|
||||
setTestResults((prev) => ({ ...prev, [id]: { loading: false, result } }));
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({ ...prev, [id]: { loading: false, error: err.message } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDefault = async (model, field) => {
|
||||
const newValue = !model[field];
|
||||
try {
|
||||
await updateModel(model.id, { [field]: newValue });
|
||||
await refreshModels();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// VLM handlers
|
||||
const handleSaveVlm = async () => {
|
||||
setVlmSaving(true);
|
||||
try {
|
||||
await updateConfig('vlm_endpoint', vlmUrl);
|
||||
setVlmTestResult({ success: true, message: 'Guardado' });
|
||||
} catch (err) {
|
||||
setVlmTestResult({ success: false, message: err.message });
|
||||
} finally {
|
||||
setVlmSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestVlm = async () => {
|
||||
setVlmTestResult({ loading: true });
|
||||
try {
|
||||
const result = await testVlm(vlmUrl);
|
||||
setVlmTestResult({
|
||||
success: result.success,
|
||||
message: result.success
|
||||
? `${result.message} (${result.latency_ms}ms)`
|
||||
: result.message,
|
||||
});
|
||||
} catch (err) {
|
||||
setVlmTestResult({
|
||||
success: false,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePdf = async (id) => {
|
||||
if (!window.confirm('¿Eliminar este PDF?')) return;
|
||||
try {
|
||||
await deletePdf(id);
|
||||
await refreshPdfs();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Progress handlers
|
||||
const handleResetTopic = async (topic) => {
|
||||
if (!window.confirm(`¿Resetear progreso de "${topic}"?`)) return;
|
||||
try {
|
||||
await resetProgressTopic(topic);
|
||||
await refreshProgress();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const progressChartData = progress.map((p) => ({
|
||||
name: p.topic,
|
||||
pct: p.percentage,
|
||||
fill:
|
||||
p.percentage >= 80
|
||||
? 'var(--accent-green)'
|
||||
: p.percentage >= 50
|
||||
? 'var(--accent-amber)'
|
||||
: 'var(--accent-coral)',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="settings-container">
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<div className="settings-tabs">
|
||||
<button
|
||||
className={`settings-tab ${activeTab === 'models' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('models')}
|
||||
>
|
||||
Modelos
|
||||
</button>
|
||||
<button
|
||||
className={`settings-tab ${activeTab === 'pdfs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('pdfs')}
|
||||
>
|
||||
PDFs & VLM
|
||||
</button>
|
||||
<button
|
||||
className={`settings-tab ${activeTab === 'progress' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('progress')}
|
||||
>
|
||||
Progreso
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab 1: Modelos */}
|
||||
{activeTab === 'models' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<button className="btn btn-primary" onClick={openAddModel}>
|
||||
<Plus size={14} style={{ marginRight: 6, verticalAlign: 'middle' }} />
|
||||
Add Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Provider</th>
|
||||
<th>API Base</th>
|
||||
<th style={{ textAlign: 'center' }}>Main</th>
|
||||
<th style={{ textAlign: 'center' }}>Fork</th>
|
||||
<th style={{ textAlign: 'center' }}>Exam</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((model) => (
|
||||
<tr key={model.id}>
|
||||
<td>
|
||||
<span style={{ fontWeight: 500 }}>{model.name}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`provider-badge ${model.provider}`}>
|
||||
{model.provider}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
|
||||
{model.api_base}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<ToggleSwitch
|
||||
checked={!!model.is_default_main}
|
||||
onChange={() => handleToggleDefault(model, 'is_default_main')}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<ToggleSwitch
|
||||
checked={!!model.is_default_fork}
|
||||
onChange={() => handleToggleDefault(model, 'is_default_fork')}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<ToggleSwitch
|
||||
checked={!!model.is_default_exam}
|
||||
onChange={() => handleToggleDefault(model, 'is_default_exam')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleTestModel(model.id)}
|
||||
disabled={testResults[model.id]?.loading}
|
||||
>
|
||||
{testResults[model.id]?.loading ? (
|
||||
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
) : (
|
||||
<TestTube size={12} />
|
||||
)}
|
||||
Test
|
||||
</button>
|
||||
{testResults[model.id]?.result && (
|
||||
<span style={{ fontSize: 11, color: 'var(--accent-green)' }}>
|
||||
{testResults[model.id].result.latency_ms}ms
|
||||
</span>
|
||||
)}
|
||||
{testResults[model.id]?.error && (
|
||||
<span style={{ fontSize: 11, color: 'var(--accent-coral)' }}>
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => openEditModel(model)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDeleteModel(model.id)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{models.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
|
||||
No hay modelos configurados
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2: PDFs & VLM */}
|
||||
{activeTab === 'pdfs' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
VLM Endpoint
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={vlmUrl}
|
||||
onChange={(e) => setVlmUrl(e.target.value)}
|
||||
placeholder="https://vlm.example.com/v1/chat"
|
||||
className="vlm-input"
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={handleSaveVlm} disabled={vlmSaving}>
|
||||
{vlmSaving ? (
|
||||
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />
|
||||
) : null}
|
||||
Guardar
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleTestVlm}>
|
||||
Test VLM
|
||||
</button>
|
||||
</div>
|
||||
{vlmTestResult && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
color: vlmTestResult.loading ? 'var(--text-tertiary)' : vlmTestResult.success ? 'var(--accent-green)' : 'var(--accent-coral)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{vlmTestResult.loading ? (
|
||||
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
) : vlmTestResult.success ? (
|
||||
<TestTube size={12} />
|
||||
) : (
|
||||
<AlertTriangle size={12} />
|
||||
)}
|
||||
{vlmTestResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
PDFs cargados
|
||||
</h2>
|
||||
<table className="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Orden</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pdfs.map((pdf) => (
|
||||
<tr key={pdf.id}>
|
||||
<td>{pdf.filename}</td>
|
||||
<td>{pdf.sort_order}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDeletePdf(pdf.id)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{pdfs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
|
||||
No hay PDFs cargados
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Progreso */}
|
||||
{activeTab === 'progress' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Gráfico de progreso
|
||||
</h2>
|
||||
<div className="progress-chart-container">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={progressChartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: 'var(--text-secondary)', fontSize: 11 }}
|
||||
axisLine={{ stroke: 'var(--border)' }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: 'var(--text-secondary)', fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
domain={[0, 100]}
|
||||
unit="%"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
itemStyle={{ color: 'var(--text-primary)' }}
|
||||
formatter={(value) => [`${value}%`, 'Porcentaje']}
|
||||
/>
|
||||
<Bar dataKey="pct" radius={[4, 4, 0, 0]}>
|
||||
{progressChartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Topic</th>
|
||||
<th>Exercises Done</th>
|
||||
<th>Exercises Correct</th>
|
||||
<th>Percentage</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{progress.map((p) => (
|
||||
<tr key={p.topic}>
|
||||
<td>
|
||||
<span style={{ fontWeight: 500 }}>{p.topic}</span>
|
||||
</td>
|
||||
<td>{p.exercises_done}</td>
|
||||
<td>{p.exercises_correct}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div className="progress-bar-bg" style={{ width: 120 }}>
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{
|
||||
width: `${p.percentage}%`,
|
||||
background:
|
||||
p.percentage >= 80
|
||||
? 'var(--accent-green)'
|
||||
: p.percentage >= 50
|
||||
? 'var(--accent-amber)'
|
||||
: 'var(--accent-coral)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, minWidth: 32 }}>
|
||||
{p.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleResetTopic(p.topic)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{progress.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
|
||||
No hay progreso registrado
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Modal */}
|
||||
{modelModalOpen && (
|
||||
<Modal
|
||||
title={editingModel ? 'Editar Modelo' : 'Agregar Modelo'}
|
||||
onClose={() => setModelModalOpen(false)}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>Nombre</label>
|
||||
<input
|
||||
value={modelForm.name}
|
||||
onChange={(e) => setModelForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="gpt-4o"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>API Base</label>
|
||||
<input
|
||||
value={modelForm.api_base}
|
||||
onChange={(e) => setModelForm((f) => ({ ...f, api_base: e.target.value }))}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={modelForm.api_key}
|
||||
onChange={(e) => setModelForm((f) => ({ ...f, api_key: e.target.value }))}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Provider</label>
|
||||
<select
|
||||
value={modelForm.provider}
|
||||
onChange={(e) => setModelForm((f) => ({ ...f, provider: e.target.value }))}
|
||||
>
|
||||
<option value="openai">openai</option>
|
||||
<option value="anthropic">anthropic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<ToggleSwitch
|
||||
checked={modelForm.is_default_main}
|
||||
onChange={(v) =>
|
||||
setModelForm((f) => ({
|
||||
...f,
|
||||
is_default_main: v,
|
||||
is_default_fork: v ? false : f.is_default_fork,
|
||||
is_default_exam: v ? false : f.is_default_exam,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Default Main
|
||||
<span className="role-dot main" style={{ marginLeft: 4 }} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<ToggleSwitch
|
||||
checked={modelForm.is_default_fork}
|
||||
onChange={(v) =>
|
||||
setModelForm((f) => ({
|
||||
...f,
|
||||
is_default_fork: v,
|
||||
is_default_main: v ? false : f.is_default_main,
|
||||
is_default_exam: v ? false : f.is_default_exam,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Default Fork
|
||||
<span className="role-dot fork" style={{ marginLeft: 4 }} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<ToggleSwitch
|
||||
checked={modelForm.is_default_exam}
|
||||
onChange={(v) =>
|
||||
setModelForm((f) => ({
|
||||
...f,
|
||||
is_default_exam: v,
|
||||
is_default_main: v ? false : f.is_default_main,
|
||||
is_default_fork: v ? false : f.is_default_fork,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Default Exam
|
||||
<span className="role-dot exam" style={{ marginLeft: 4 }} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-secondary" onClick={() => setModelModalOpen(false)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSaveModel} disabled={modelLoading}>
|
||||
{modelLoading ? (
|
||||
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />
|
||||
) : null}
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user