feat(template): per-experiment daily record template — rename/add/hide fields, custom_fields JSONB
This commit is contained in:
@@ -5,6 +5,7 @@ import Button from '../components/ui/Button';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import AnimalForm from '../components/AnimalForm';
|
||||
import TemplateEditor from '../components/TemplateEditor';
|
||||
import Alert from '../components/ui/Alert';
|
||||
import AuditLogSection from '../components/AuditLogSection';
|
||||
|
||||
@@ -22,6 +23,7 @@ export default function ExperimentDetail() {
|
||||
const [editAnimal, setEditAnimal] = useState(null);
|
||||
const [deleteAnimal, setDeleteAnimal] = useState(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [showTemplate, setShowTemplate] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -69,7 +71,16 @@ export default function ExperimentDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading…</div>;
|
||||
function handleTemplateSaved(updatedTemplate) {
|
||||
setShowTemplate(false);
|
||||
if (updatedTemplate) {
|
||||
// Update local experiment state so everything downstream re-renders
|
||||
setExperiment((prev) => ({ ...prev, template: updatedTemplate }));
|
||||
flash('Record template updated.');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
||||
if (error) return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<Alert type="error" message={error} />
|
||||
@@ -93,12 +104,44 @@ export default function ExperimentDetail() {
|
||||
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowTemplate(true)}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
Record Template
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{successMsg && <Alert type="success" message={successMsg} />}
|
||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||
|
||||
{/* Template summary strip */}
|
||||
{experiment.template && experiment.template.length > 0 && (
|
||||
<div className="mb-5 flex flex-wrap gap-1.5">
|
||||
{experiment.template.map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||
f.active ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-gray-100 border-gray-200 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{f.active ? null : <span title="hidden">◌</span>}
|
||||
{f.label}
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowTemplate(true)}
|
||||
className="text-xs text-gray-400 hover:text-blue-600 underline underline-offset-2 transition-colors"
|
||||
>
|
||||
edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{animals.length === 0 ? (
|
||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
||||
<p className="text-gray-400 mb-3">No animals enrolled in this experiment yet.</p>
|
||||
@@ -110,7 +153,7 @@ export default function ExperimentDetail() {
|
||||
<div
|
||||
key={animal.id}
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
|
||||
onClick={() => navigate(`/animals/${animal.id}`)}
|
||||
onClick={() => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
||||
@@ -129,19 +172,19 @@ export default function ExperimentDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AuditLogSection tableName="animals" recordId={undefined} />
|
||||
<AuditLogSection tableName="animals" />
|
||||
|
||||
{/* Template editor modal */}
|
||||
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="lg">
|
||||
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
|
||||
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
|
||||
<AnimalForm
|
||||
existing={editAnimal}
|
||||
experimentId={id}
|
||||
onSuccess={handleAnimalSaved}
|
||||
onCancel={() => setEditAnimal(null)}
|
||||
/>
|
||||
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
Reference in New Issue
Block a user