import React, { useEffect, useRef, useState, useCallback } from 'react';
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';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm';
import SubjectInfoForm from '../components/SubjectInfoForm';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
import { SubjectAnalysisCharts } from '../components/AnalysisCharts';
/** Read the value for a template field from a status record.
* Custom fields are keyed by fieldId, not by the display key. */
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
// ── Inline cell editor ─────────────────────────────────────────────────────────
function InlineCell({ statusId, field, value, currentStatus, onSaved }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const inputRef = useRef(null);
const isTextarea = field.type === 'textarea';
function startEdit(e) {
e.preventDefault();
e.stopPropagation();
setDraft(value ?? '');
setSaveError(null);
setEditing(true);
}
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
if (inputRef.current.select) inputRef.current.select();
}
}, [editing]);
async function commit() {
setEditing(false);
const newValue = draft.trim() || null;
if (newValue === (value?.trim() || null)) return;
setSaving(true);
setSaveError(null);
try {
let body;
if (field.builtin) {
body = { [field.key]: newValue };
} else {
body = { custom_fields: { ...currentStatus.custom_fields, [field.fieldId]: newValue } };
}
const updated = await dailyStatusesApi.update(statusId, body);
onSaved(updated);
} catch (err) {
setSaveError(err.message ?? 'Save failed');
} finally {
setSaving(false);
}
}
function cancel() { setEditing(false); }
if (editing) {
const sharedProps = {
ref: inputRef,
value: draft,
onChange: (e) => setDraft(e.target.value),
onBlur: commit,
onClick: (e) => e.stopPropagation(),
onKeyDown: (e) => {
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
if (e.key === 'Enter' && !isTextarea) { e.preventDefault(); commit(); }
},
className: 'w-full border border-indigo-400 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300 bg-white',
};
return isTextarea
?
: ;
}
return (
{saveError
? {saveError}
: value
? isTextarea
? {value}
: {value}
: · · ·
}
);
}
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([]);
const [subjectTemplate, setSubjectTemplate] = useState([]);
const [showSubjectInfo, setShowSubjectInfo] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
const [showAddStatus, setShowAddStatus] = useState(false);
const [editStatus, setEditStatus] = useState(null);
const [deleteStatus, setDeleteStatus] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showDeleteSubject, setShowDeleteSubject] = useState(false);
const [deleteSubjectLoading, setDeleteSubjectLoading] = useState(false);
const [fillingGaps, setFillingGaps] = useState(false);
// Column visibility — persisted per experiment
const [hiddenCols, setHiddenCols] = useState(new Set());
const [showColPicker, setShowColPicker] = useState(false);
const colPickerRef = useRef(null);
useEffect(() => {
if (!animal) return;
const stored = localStorage.getItem(`col-vis-${animal.id}`);
setHiddenCols(stored ? new Set(JSON.parse(stored)) : new Set());
}, [animal?.id]);
function toggleCol(fieldId) {
setHiddenCols((prev) => {
const next = new Set(prev);
next.has(fieldId) ? next.delete(fieldId) : next.add(fieldId);
localStorage.setItem(`col-vis-${animal.id}`, JSON.stringify([...next]));
return next;
});
}
useEffect(() => {
if (!showColPicker) return;
function handleClick(e) {
if (colPickerRef.current && !colPickerRef.current.contains(e.target)) {
setShowColPicker(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showColPicker]);
async function load() {
setLoading(true);
setError(null);
try {
const [animalData, statusData] = await Promise.all([
animalsApi.get(id),
dailyStatusesApi.list(id),
]);
setAnimal(animalData);
setStatuses(statusData);
const [tmpl, subjTmpl] = await Promise.all([
experimentsApi.getTemplate(animalData.experiment_id),
experimentsApi.getSubjectTemplate(animalData.experiment_id),
]);
setTemplate(tmpl);
setSubjectTemplate(subjTmpl);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
function flash(msg) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function handleStatusSaved() {
setShowAddStatus(false);
setEditStatus(null);
load();
flash(editStatus ? 'Daily status updated.' : 'Daily status logged.');
}
async function handleDeleteStatus() {
setDeleteLoading(true);
try {
await dailyStatusesApi.delete(deleteStatus.id);
setDeleteStatus(null);
setEditStatus(null);
load();
flash('Daily status removed.');
} catch (err) {
setError(err.message);
setDeleteStatus(null);
} finally {
setDeleteLoading(false);
}
}
async function fillGaps() {
setFillingGaps(true);
try {
await Promise.all(missingDates.map((date) => dailyStatusesApi.create({ date, animal_id: id })));
load();
flash(`Created ${missingDates.length} missing entr${missingDates.length === 1 ? 'y' : 'ies'}.`);
} catch (err) {
setError(err.message);
} finally {
setFillingGaps(false);
}
}
async function handleDeleteSubject() {
setDeleteSubjectLoading(true);
try {
await animalsApi.delete(animal.id);
navigate(`/experiments/${animal.experiment_id}`, { replace: true });
} catch (err) {
setError(err.message);
setShowDeleteSubject(false);
} finally {
setDeleteSubjectLoading(false);
}
}
if (loading) return Loading…
;
if (error) return (
);
const activeFields = template.filter((f) => f.active);
const visibleFields = activeFields.filter((f) => !hiddenCols.has(f.fieldId));
// Dates with entries
const existingDateSet = new Set(statuses.map((s) => String(s.date).slice(0, 10)));
const missingDates = (() => {
if (statuses.length < 2) return [];
const sorted = [...existingDateSet].sort();
const missing = [];
const cur = new Date(sorted[0] + 'T12:00:00');
const end = new Date(sorted[sorted.length - 1] + 'T12:00:00');
while (cur < end) {
cur.setDate(cur.getDate() + 1);
const d = cur.toISOString().slice(0, 10);
if (!existingDateSet.has(d)) missing.push(d);
}
return missing;
})();
return (
{/* Breadcrumb */}
{animal.animal_name}
ID: {animal.animal_id_string}
{' · '}
{statuses.length} daily status {statuses.length !== 1 ? 'entries' : 'entry'}
{successMsg &&
}
{error &&
setError(null)} />}
{/* Subject information card */}
{subjectTemplate.filter((f) => f.active).length > 0 && (
Subject Information
{(() => {
const filled = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId]);
if (filled.length === 0) {
return (
);
}
return (
{filled.map((f) => (
- {f.label}
- {animal.subject_info[f.fieldId]}
))}
);
})()}
)}
{statuses.length === 0 ? (
No daily statuses logged yet.
) : (
{/* Column picker toolbar */}
{activeFields.length > 0 && (
{showColPicker && (
Show / hide columns
{activeFields.map((f) => {
const visible = !hiddenCols.has(f.fieldId);
return (
);
})}
{hiddenCols.size > 0 && (
)}
)}
)}
|
Date
|
{visibleFields.map((f) => {
const header = f.abbr ? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr) : (f.unit ? `${f.label} (${f.unit})` : f.label);
const tooltip = f.unit ? `${f.label} (${f.unit})` : f.label;
const minW = Math.max(80, header.length * 7.5 + 24);
return (
{header}
|
);
})}
{activeFields.length === 0 && (
<>
{['Experiment Description', 'Vitals', 'Treatment', 'Notes'].map((l) => (
{l} |
))}
>
)}
|
{statuses.map((s, i) => {
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
const renderFields = activeFields.length > 0 ? visibleFields : [
{ fieldId: '__exp_desc__', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
{ fieldId: '__vitals__', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
{ fieldId: '__treatment__',key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
{ fieldId: '__notes__', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
];
return (
| navigate(`/daily-statuses/${s.id}`)}
>
{format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d, yyyy')}
|
{renderFields.map((f) => (
setStatuses((prev) => prev.map((x) => x.id === s.id ? { ...x, ...updated } : x))}
/>
|
))}
|
);
})}
)}
{missingDates.length > 0 && (
{missingDates.length} date{missingDates.length !== 1 ? 's' : ''} between the earliest and latest entries have no record.
)}
{/* Danger zone */}
Danger Zone
Permanently delete this subject and all its daily status records.
setShowSubjectInfo(false)} title="Subject Information" size="full">
{ setAnimal(updated); setShowSubjectInfo(false); flash('Subject information saved.'); }}
onCancel={() => setShowSubjectInfo(false)}
/>
setShowAddStatus(false)} title="Log Daily Status" size="full">
setShowAddStatus(false)}
/>
{(() => {
const editIdx = editStatus ? statuses.findIndex((s) => s.id === editStatus.id) : -1;
return (
setEditStatus(null)} title="Edit Daily Status" size="full">
setEditStatus(null)}
onRequestDelete={() => setDeleteStatus(editStatus)}
hasPrev={editIdx > 0}
hasNext={editIdx < statuses.length - 1}
onNavigate={(direction, savedResult) => {
setStatuses((prev) => prev.map((s) => s.id === savedResult.id ? savedResult : s));
setEditStatus(statuses[direction === 'prev' ? editIdx - 1 : editIdx + 1]);
}}
/>
);
})()}
setDeleteStatus(null)}
onConfirm={handleDeleteStatus}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the status entry for ${deleteStatus ? format(new Date(String(deleteStatus.date).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy') : ''}? This cannot be undone.`}
confirmWord="delete"
/>
setShowDeleteSubject(false)}
onConfirm={handleDeleteSubject}
loading={deleteSubjectLoading}
title="Delete Subject"
message={`This will permanently delete "${animal.animal_name}" and all ${statuses.length} daily status record${statuses.length !== 1 ? 's' : ''}. This cannot be undone.`}
confirmWord="delete"
/>
);
}