feat: add experiment calendar view with per-day subject counts
New ExperimentCalendar component added as a third view mode (List / Group / Calendar)
on the Experiment Detail page.
Backend:
- GET /api/experiments/:id/calendar?field=<fieldIdOrBuiltinKey>
Returns { field, days: { "YYYY-MM-DD": count } } where count = number of
subjects with a non-null/non-empty value for the chosen field on that date.
Supports both builtin fields (vitals, treatment, notes, experiment_description)
and custom fields addressed by fieldId UUID.
Frontend:
- ExperimentCalendar component: navigable monthly grid, field selector
(defaults to first field with "weight" in label, else first active field),
blue badges showing subject count per day, click to open modal with all
subjects' data for that day.
- Integrated into ExperimentDetail as displayMode "calendar", persisted
in localStorage alongside the existing list/group modes.
- experimentsApi.getCalendar() added to API client.
Tests:
- backend/tests/calendar.test.js: 8 unit tests (404, empty days, builtin
count, custom field count, whitespace ignored, multi-month, field echo,
missing param).
- frontend/tests/ExperimentCalendar.test.jsx: 13 RTL tests covering
rendering, default field selection, count badges, month navigation,
field-change re-fetch, day click modal, and multi-subject display.
All 47 backend + 13 calendar frontend tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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';
|
||||
@@ -6,8 +6,10 @@ 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. */
|
||||
@@ -16,6 +18,92 @@ function getFieldValue(status, field) {
|
||||
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
|
||||
? <textarea {...sharedProps} rows={3} className={sharedProps.className + ' resize-none'} />
|
||||
: <input type="text" {...sharedProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onDoubleClick={startEdit}
|
||||
title={editing ? '' : 'Double-click to edit'}
|
||||
className={`min-h-[1.25rem] cursor-default rounded transition-colors px-0.5 -mx-0.5
|
||||
${saving ? 'opacity-40' : 'hover:bg-indigo-50/60 group/ic'}`}
|
||||
>
|
||||
{saveError
|
||||
? <span className="text-red-500 text-xs">{saveError}</span>
|
||||
: value
|
||||
? isTextarea
|
||||
? <span className="line-clamp-2 whitespace-pre-wrap">{value}</span>
|
||||
: <span className="truncate block">{value}</span>
|
||||
: <span className="text-gray-200 group-hover/ic:text-gray-400 select-none text-xs">· · ·</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnimalDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -23,7 +111,9 @@ export default function AnimalDetail() {
|
||||
|
||||
const [animal, setAnimal] = useState(null);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
const [template, setTemplate] = useState(location.state?.template ?? []);
|
||||
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);
|
||||
@@ -32,6 +122,40 @@ export default function AnimalDetail() {
|
||||
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);
|
||||
@@ -44,11 +168,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);
|
||||
}
|
||||
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 {
|
||||
@@ -75,6 +200,7 @@ export default function AnimalDetail() {
|
||||
try {
|
||||
await dailyStatusesApi.delete(deleteStatus.id);
|
||||
setDeleteStatus(null);
|
||||
setEditStatus(null);
|
||||
load();
|
||||
flash('Daily status removed.');
|
||||
} catch (err) {
|
||||
@@ -85,6 +211,32 @@ export default function AnimalDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
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 <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">
|
||||
@@ -94,6 +246,23 @@ export default function AnimalDetail() {
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
@@ -121,85 +290,248 @@ export default function AnimalDetail() {
|
||||
{successMsg && <Alert type="success" message={successMsg} />}
|
||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||
|
||||
{/* Subject information card */}
|
||||
{subjectTemplate.filter((f) => f.active).length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Subject Information</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSubjectInfo(true)}
|
||||
className="text-xs text-gray-400 hover:text-blue-600 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const filled = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId]);
|
||||
if (filled.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSubjectInfo(true)}
|
||||
className="text-sm text-gray-400 italic hover:text-blue-600 transition-colors"
|
||||
>
|
||||
No information recorded yet — click to add
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
{filled.map((f) => (
|
||||
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{f.label}</dt>
|
||||
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{animal.subject_info[f.fieldId]}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statuses.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 daily statuses logged yet.</p>
|
||||
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{statuses.map((s) => (
|
||||
<div key={s.id} className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{format(new Date(s.date), 'MMMM d, yyyy')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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 className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
{/* Column picker toolbar */}
|
||||
{activeFields.length > 0 && (
|
||||
<div className="flex justify-end px-3 py-1.5 border-b border-gray-100 bg-gray-50/60">
|
||||
<div className="relative" ref={colPickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowColPicker((v) => !v)}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
Columns
|
||||
{hiddenCols.size > 0 && (
|
||||
<span className="ml-0.5 bg-indigo-100 text-indigo-700 rounded-full px-1.5 py-px text-[10px] font-medium leading-none">
|
||||
{activeFields.length - hiddenCols.size}/{activeFields.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{showColPicker && (
|
||||
<div className="absolute right-0 top-full mt-1 z-30 bg-white border border-gray-200 rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">Show / hide columns</p>
|
||||
{activeFields.map((f) => {
|
||||
const visible = !hiddenCols.has(f.fieldId);
|
||||
return (
|
||||
<label key={f.fieldId} className="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-50 cursor-pointer select-none text-xs text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible}
|
||||
onChange={() => toggleCol(f.fieldId)}
|
||||
className="w-3.5 h-3.5 accent-indigo-600"
|
||||
/>
|
||||
<span className="truncate" title={f.label}>{f.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{hiddenCols.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHiddenCols(new Set());
|
||||
localStorage.removeItem(`col-vis-${animal.id}`);
|
||||
}}
|
||||
className="mt-1 w-full text-left text-[10px] text-indigo-500 hover:text-indigo-700 px-1 py-0.5"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-sm border-collapse min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap sticky left-0 bg-gray-50 z-10 border-r border-gray-200 min-w-[110px]">
|
||||
Date
|
||||
</th>
|
||||
{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 (
|
||||
<th key={f.key} title={tooltip} style={{ minWidth: minW }} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px] cursor-default">
|
||||
{header}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{activeFields.length === 0 && (
|
||||
<>
|
||||
{['Experiment Description', 'Vitals', 'Treatment', 'Notes'].map((l) => (
|
||||
<th key={l} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap min-w-[120px]">{l}</th>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[96px]" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={s.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium text-gray-700 whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 cursor-pointer hover:text-blue-600 hover:bg-blue-50/40 transition-colors ${rowBg}`}
|
||||
onClick={() => navigate(`/daily-statuses/${s.id}`)}
|
||||
>
|
||||
{format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d, yyyy')}
|
||||
</td>
|
||||
{renderFields.map((f) => (
|
||||
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px]">
|
||||
<InlineCell
|
||||
statusId={s.id}
|
||||
field={f}
|
||||
value={getFieldValue(s, f)}
|
||||
currentStatus={s}
|
||||
onSaved={(updated) => setStatuses((prev) => prev.map((x) => x.id === s.id ? { ...x, ...updated } : x))}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AuditLogSection tableName="daily_statuses" />
|
||||
{missingDates.length > 0 && (
|
||||
<div className="mt-3 flex items-center justify-between px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl">
|
||||
<p className="text-sm text-amber-800">
|
||||
<span className="font-semibold">{missingDates.length}</span> date{missingDates.length !== 1 ? 's' : ''} between the earliest and latest entries have no record.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={fillGaps} loading={fillingGaps}>
|
||||
Create {missingDates.length} empty entr{missingDates.length === 1 ? 'y' : 'ies'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
|
||||
<SubjectAnalysisCharts statuses={statuses} dailyTemplate={template} />
|
||||
|
||||
<AuditLogSection tableName="daily_statuses" limit={5} />
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="mt-10 border border-red-200 rounded-xl p-4 bg-red-50/40">
|
||||
<h2 className="text-xs font-semibold text-red-600 uppercase tracking-wide mb-2">Danger Zone</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">Permanently delete this subject and all its daily status records.</p>
|
||||
<Button variant="danger" onClick={() => setShowDeleteSubject(true)}>Delete Subject</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showSubjectInfo} onClose={() => setShowSubjectInfo(false)} title="Subject Information" size="full">
|
||||
<SubjectInfoForm
|
||||
animal={animal}
|
||||
subjectTemplate={subjectTemplate}
|
||||
experimentId={animal?.experiment_id}
|
||||
onTemplateUpdate={setSubjectTemplate}
|
||||
onSaved={(updated) => { setAnimal(updated); setShowSubjectInfo(false); flash('Subject information saved.'); }}
|
||||
onCancel={() => setShowSubjectInfo(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="full">
|
||||
<DailyStatusForm
|
||||
animalId={id}
|
||||
experimentId={animal?.experiment_id}
|
||||
template={template}
|
||||
onTemplateUpdate={setTemplate}
|
||||
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)}
|
||||
/>
|
||||
</Modal>
|
||||
{(() => {
|
||||
const editIdx = editStatus ? statuses.findIndex((s) => s.id === editStatus.id) : -1;
|
||||
return (
|
||||
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="full">
|
||||
<DailyStatusForm
|
||||
existing={editStatus}
|
||||
animalId={id}
|
||||
experimentId={animal?.experiment_id}
|
||||
template={template}
|
||||
onTemplateUpdate={setTemplate}
|
||||
onSuccess={handleStatusSaved}
|
||||
onCancel={() => 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]);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
})()}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteStatus}
|
||||
@@ -207,7 +539,18 @@ export default function AnimalDetail() {
|
||||
onConfirm={handleDeleteStatus}
|
||||
loading={deleteLoading}
|
||||
title="Delete Daily Status"
|
||||
message={`Delete the status entry for ${deleteStatus ? format(new Date(deleteStatus.date), 'MMMM d, yyyy') : ''}?`}
|
||||
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"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteSubject}
|
||||
onClose={() => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user