feat(template): per-experiment daily record template — rename/add/hide fields, custom_fields JSONB
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user