feat(template): per-experiment daily record template — rename/add/hide fields, custom_fields JSONB

This commit is contained in:
Experiments DB Dev
2026-04-15 13:56:17 -04:00
parent 80fb1c6e27
commit 86a56427b7
10 changed files with 570 additions and 189 deletions
+59 -21
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { animalsApi, dailyStatusesApi } from '../api/client';
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
import { format } from 'date-fns';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
@@ -9,12 +9,20 @@ import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
/** Render the value for a template field from a status record */
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.key];
}
export default function AnimalDetail() {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [animal, setAnimal] = useState(null);
const [statuses, setStatuses] = useState([]);
const [template, setTemplate] = useState(location.state?.template ?? []);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
@@ -34,6 +42,12 @@ export default function AnimalDetail() {
]);
setAnimal(animalData);
setStatuses(statusData);
// Fetch template if not passed via navigation state
if (!location.state?.template) {
const tmpl = await experimentsApi.getTemplate(animalData.experiment_id);
setTemplate(tmpl);
}
} catch (err) {
setError(err.message);
} finally {
@@ -70,7 +84,7 @@ export default function AnimalDetail() {
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
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} />
@@ -78,15 +92,15 @@ export default function AnimalDetail() {
</div>
);
const activeFields = template.filter((f) => f.active);
return (
<div className="max-w-5xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="text-sm text-gray-500 mb-4">
<Link to="/" className="hover:text-blue-600">Experiments</Link>
<span className="mx-2">/</span>
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">
Experiment
</Link>
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
<span className="mx-2">/</span>
<span className="text-gray-900 font-medium">{animal.animal_name}</span>
</nav>
@@ -124,39 +138,63 @@ export default function AnimalDetail() {
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
</div>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{[
['Experiment Description', s.experiment_description],
['Vitals', s.vitals],
['Treatment', s.treatment],
['Notes', s.notes],
]
.filter(([, v]) => v)
.map(([label, value]) => (
<div key={label} className="flex flex-col">
{activeFields.length > 0 ? (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{activeFields
.map((f) => ({ field: f, value: getFieldValue(s, f) }))
.filter(({ value }) => value)
.map(({ field, value }) => (
<div key={field.key} className={field.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{field.label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div>
))}
</dl>
) : null}
{/* Fallback: show raw builtin fields if template is empty */}
{activeFields.length === 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{[
['Experiment Description', s.experiment_description],
['Vitals', s.vitals],
['Treatment', s.treatment],
['Notes', s.notes],
].filter(([, v]) => v).map(([label, value]) => (
<div key={label}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div>
))}
</dl>
{!s.experiment_description && !s.vitals && !s.treatment && !s.notes && (
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
</dl>
)}
{activeFields.length > 0 &&
activeFields.every((f) => !getFieldValue(s, f)) && (
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
)}
</div>
))}
</div>
)}
<AuditLogSection tableName="daily_statuses" recordId={undefined} />
<AuditLogSection tableName="daily_statuses" />
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
<DailyStatusForm animalId={id} onSuccess={handleStatusSaved} onCancel={() => setShowAddStatus(false)} />
<DailyStatusForm
animalId={id}
template={template}
onSuccess={handleStatusSaved}
onCancel={() => setShowAddStatus(false)}
/>
</Modal>
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
<DailyStatusForm
existing={editStatus}
animalId={id}
template={template}
onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)}
/>