feat: permanent calendar below analytics, editable day modal, per-subject bar chart
This commit is contained in:
@@ -3,54 +3,91 @@ import {
|
|||||||
format, startOfMonth, endOfMonth, eachDayOfInterval,
|
format, startOfMonth, endOfMonth, eachDayOfInterval,
|
||||||
getDay, addMonths, subMonths,
|
getDay, addMonths, subMonths,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
|
||||||
|
CartesianGrid, Tooltip, Legend,
|
||||||
|
} from 'recharts';
|
||||||
import { experimentsApi } from '../api/client';
|
import { experimentsApi } from '../api/client';
|
||||||
import Modal from './ui/Modal';
|
import Modal from './ui/Modal';
|
||||||
|
import DailyStatusForm from './DailyStatusForm';
|
||||||
|
|
||||||
// ── Day detail panel rendered inside modal ─────────────────────────────────────
|
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||||
|
|
||||||
function DayDetail({ statuses, animals, template }) {
|
function SubjectReadOnly({ status, template }) {
|
||||||
const animalMap = useMemo(
|
|
||||||
() => Object.fromEntries(animals.map((a) => [a.id, a])),
|
|
||||||
[animals],
|
|
||||||
);
|
|
||||||
const activeFields = template.filter((f) => f.active);
|
const activeFields = template.filter((f) => f.active);
|
||||||
|
function getVal(s, f) {
|
||||||
function getVal(status, field) {
|
if (f.builtin) return s[f.key];
|
||||||
if (field.builtin) return status[field.key];
|
return s.custom_fields?.[f.fieldId];
|
||||||
return status.custom_fields?.[field.fieldId];
|
|
||||||
}
|
}
|
||||||
|
const nonEmpty = activeFields.filter((f) => {
|
||||||
if (statuses.length === 0) {
|
|
||||||
return <p className="text-gray-500 text-sm py-2">No data recorded for this day.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
|
|
||||||
{statuses.map((status) => {
|
|
||||||
const animal = animalMap[status.animal_id];
|
|
||||||
const nonEmptyFields = activeFields.filter((f) => {
|
|
||||||
const v = getVal(status, f);
|
const v = getVal(status, f);
|
||||||
return v !== null && v !== undefined && String(v).trim() !== '';
|
return v !== null && v !== undefined && String(v).trim() !== '';
|
||||||
});
|
});
|
||||||
|
if (nonEmpty.length === 0) return <p className="text-xs text-gray-400">No field values recorded.</p>;
|
||||||
return (
|
return (
|
||||||
<div key={status.id} className="bg-gray-50 rounded-xl p-4 border border-gray-100">
|
|
||||||
<div className="font-semibold text-gray-900 mb-2 text-sm">
|
|
||||||
{animal?.animal_name ?? 'Unknown subject'}
|
|
||||||
{animal?.animal_id_string && (
|
|
||||||
<span className="ml-2 text-gray-400 font-normal">· {animal.animal_id_string}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{nonEmptyFields.length === 0 ? (
|
|
||||||
<p className="text-xs text-gray-400">No field values recorded.</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
|
||||||
{nonEmptyFields.map((field) => (
|
{nonEmpty.map((f) => (
|
||||||
<div key={field.fieldId ?? field.key} className="text-sm">
|
<div key={f.fieldId ?? f.key} className="text-sm">
|
||||||
<span className="text-gray-500 font-medium">{field.label}:</span>{' '}
|
<span className="text-gray-500 font-medium">{f.label}:</span>{' '}
|
||||||
<span className="text-gray-900 whitespace-pre-wrap">{String(getVal(status, field))}</span>
|
<span className="text-gray-900 whitespace-pre-wrap">{String(getVal(status, f))}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayPanel({ date, animals, allStatuses, template, experimentId, onStatusChange, onStatusCreate }) {
|
||||||
|
const dateStr = format(date, 'yyyy-MM-dd');
|
||||||
|
const [editingAnimalId, setEditingAnimalId] = useState(null);
|
||||||
|
|
||||||
|
const statusByAnimal = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
allStatuses.forEach((s) => {
|
||||||
|
if (s.date.slice(0, 10) === dateStr) map[s.animal_id] = s;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allStatuses, dateStr]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
{animals.map((animal) => {
|
||||||
|
const existing = statusByAnimal[animal.id];
|
||||||
|
const isEditing = editingAnimalId === animal.id;
|
||||||
|
return (
|
||||||
|
<div key={animal.id} className="bg-gray-50 rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="font-semibold text-gray-900 text-sm">
|
||||||
|
{animal.animal_name ?? 'Unknown subject'}
|
||||||
|
{animal.animal_id_string && (
|
||||||
|
<span className="ml-2 text-gray-400 font-normal">· {animal.animal_id_string}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isEditing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingAnimalId(animal.id)}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 border border-blue-200 rounded px-2 py-0.5 hover:bg-blue-50 transition-colors"
|
||||||
|
>
|
||||||
|
{existing ? 'Edit' : '+ Add'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isEditing ? (
|
||||||
|
<DailyStatusForm
|
||||||
|
animalId={animal.id}
|
||||||
|
experimentId={experimentId}
|
||||||
|
template={template}
|
||||||
|
existing={existing ? { ...existing, date: dateStr } : undefined}
|
||||||
|
onSuccess={(saved) => {
|
||||||
|
if (existing) { onStatusChange(saved); } else { onStatusCreate(saved); }
|
||||||
|
setEditingAnimalId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingAnimalId(null)}
|
||||||
|
/>
|
||||||
|
) : existing ? (
|
||||||
|
<SubjectReadOnly status={existing} template={template} />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-400">No data recorded for this day.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -59,65 +96,69 @@ function DayDetail({ statuses, animals, template }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main calendar component ────────────────────────────────────────────────────
|
export default function ExperimentCalendar({ experimentId, template, allStatuses, animals, onStatusChange, onStatusCreate }) {
|
||||||
|
|
||||||
export default function ExperimentCalendar({ experimentId, template, allStatuses, animals }) {
|
|
||||||
const [currentMonth, setCurrentMonth] = useState(() => new Date());
|
const [currentMonth, setCurrentMonth] = useState(() => new Date());
|
||||||
const [selectedField, setSelectedField] = useState(null);
|
const [selectedField, setSelectedField] = useState(null);
|
||||||
const [calendarDays, setCalendarDays] = useState({});
|
const [calendarDays, setCalendarDays] = useState({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedDate, setSelectedDate] = useState(null);
|
const [selectedDate, setSelectedDate] = useState(null);
|
||||||
|
|
||||||
// Build field options from active template fields
|
|
||||||
const fieldOptions = useMemo(
|
const fieldOptions = useMemo(
|
||||||
() =>
|
() => template.filter((f) => f.active).map((f) => ({ value: f.builtin ? f.key : f.fieldId, label: f.label })),
|
||||||
template
|
|
||||||
.filter((f) => f.active)
|
|
||||||
.map((f) => ({
|
|
||||||
value: f.builtin ? f.key : f.fieldId,
|
|
||||||
label: f.label,
|
|
||||||
})),
|
|
||||||
[template],
|
[template],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Default: first field whose label contains "weight", else first active field
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fieldOptions.length === 0 || selectedField) return;
|
if (fieldOptions.length === 0 || selectedField) return;
|
||||||
const weightField = fieldOptions.find((f) =>
|
const weightField = fieldOptions.find((f) => f.label.toLowerCase().includes('weight'));
|
||||||
f.label.toLowerCase().includes('weight'),
|
setSelectedField(weightField ? weightField.value : fieldOptions[0]?.value ?? null);
|
||||||
);
|
|
||||||
setSelectedField(weightField ? weightField.value : fieldOptions[0].value);
|
|
||||||
}, [fieldOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [fieldOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Fetch calendar counts whenever field changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedField) return;
|
if (!selectedField) return;
|
||||||
let cancelled = false;
|
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
|
||||||
setLoading(true);
|
const days = {};
|
||||||
experimentsApi
|
allStatuses.forEach((s) => {
|
||||||
.getCalendar(experimentId, selectedField)
|
const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField];
|
||||||
.then((data) => { if (!cancelled) setCalendarDays(data.days ?? {}); })
|
if (val !== null && val !== undefined && String(val).trim() !== '') {
|
||||||
.catch(() => { if (!cancelled) setCalendarDays({}); })
|
const key = s.date.slice(0, 10);
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
days[key] = (days[key] || 0) + 1;
|
||||||
return () => { cancelled = true; };
|
}
|
||||||
}, [experimentId, selectedField]);
|
});
|
||||||
|
setCalendarDays(days);
|
||||||
|
}, [allStatuses, selectedField]);
|
||||||
|
|
||||||
// Build the grid: leading empty cells + each day of the month
|
|
||||||
const monthStart = startOfMonth(currentMonth);
|
const monthStart = startOfMonth(currentMonth);
|
||||||
const monthEnd = endOfMonth(currentMonth);
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||||
const leadingPad = getDay(monthStart); // 0 = Sunday
|
const leadingPad = getDay(monthStart);
|
||||||
|
|
||||||
// Statuses filtered to the selected day
|
const barData = useMemo(() => {
|
||||||
const selectedDayStatuses = useMemo(() => {
|
if (!selectedField) return [];
|
||||||
if (!selectedDate) return [];
|
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
|
||||||
const key = format(selectedDate, 'yyyy-MM-dd');
|
return animals
|
||||||
return allStatuses.filter((s) => s.date.slice(0, 10) === key);
|
.map((animal) => {
|
||||||
}, [selectedDate, allStatuses]);
|
const statuses = allStatuses.filter((s) => s.animal_id === animal.id);
|
||||||
|
const total = statuses.length;
|
||||||
|
const success = statuses.filter((s) => {
|
||||||
|
const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField];
|
||||||
|
return val !== null && val !== undefined && String(val).trim() !== '';
|
||||||
|
}).length;
|
||||||
|
return {
|
||||||
|
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
|
||||||
|
total,
|
||||||
|
success,
|
||||||
|
failure: total - success,
|
||||||
|
pct: total > 0 ? Math.round((success / total) * 100) : 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((d) => d.total > 0);
|
||||||
|
}, [animals, allStatuses, selectedField]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mt-6">
|
||||||
{/* Controls */}
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Daily Record Calendar</h3>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-5">
|
<div className="flex flex-wrap items-center gap-3 mb-5">
|
||||||
<span className="text-sm text-gray-600 font-medium">Count by field:</span>
|
<span className="text-sm text-gray-600 font-medium">Count by field:</span>
|
||||||
<select
|
<select
|
||||||
@@ -127,51 +168,28 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
|
|||||||
onChange={(e) => setSelectedField(e.target.value)}
|
onChange={(e) => setSelectedField(e.target.value)}
|
||||||
disabled={fieldOptions.length === 0}
|
disabled={fieldOptions.length === 0}
|
||||||
>
|
>
|
||||||
{fieldOptions.map((f) => (
|
{fieldOptions.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
||||||
<option key={f.value} value={f.value}>{f.label}</option>
|
|
||||||
))}
|
|
||||||
{fieldOptions.length === 0 && <option value="">No fields configured</option>}
|
{fieldOptions.length === 0 && <option value="">No fields configured</option>}
|
||||||
</select>
|
</select>
|
||||||
{loading && (
|
{loading && <span className="text-xs text-gray-400" aria-live="polite">Loading...</span>}
|
||||||
<span className="text-xs text-gray-400" aria-live="polite">Loading…</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Month navigation */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button aria-label="Previous month" onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
|
||||||
aria-label="Previous month"
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">‹</button>
|
||||||
onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
|
<span className="font-semibold text-gray-900 text-base">{format(currentMonth, 'MMMM yyyy')}</span>
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none"
|
<button aria-label="Next month" onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
|
||||||
>
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">›</button>
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<h3 className="font-semibold text-gray-900 text-base">
|
|
||||||
{format(currentMonth, 'MMMM yyyy')}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
aria-label="Next month"
|
|
||||||
onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day-of-week headers */}
|
|
||||||
<div className="grid grid-cols-7 mb-1">
|
<div className="grid grid-cols-7 mb-1">
|
||||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
{['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map((d) => (
|
||||||
<div key={d} className="text-center text-xs font-medium text-gray-400 py-1 select-none">
|
<div key={d} className="text-center text-xs font-medium text-gray-400 py-1 select-none">{d}</div>
|
||||||
{d}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar grid */}
|
|
||||||
<div className="grid grid-cols-7 gap-1">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{Array.from({ length: leadingPad }).map((_, i) => (
|
{Array.from({ length: leadingPad }).map((_, i) => <div key={`pad-${i}`} />)}
|
||||||
<div key={`pad-${i}`} />
|
|
||||||
))}
|
|
||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
const dateStr = format(day, 'yyyy-MM-dd');
|
const dateStr = format(day, 'yyyy-MM-dd');
|
||||||
const count = calendarDays[dateStr] ?? 0;
|
const count = calendarDays[dateStr] ?? 0;
|
||||||
@@ -184,39 +202,60 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
|
|||||||
onClick={() => setSelectedDate(day)}
|
onClick={() => setSelectedDate(day)}
|
||||||
className={[
|
className={[
|
||||||
'relative flex flex-col items-center justify-center rounded-lg py-1.5 min-h-[2.75rem] text-sm select-none transition-all',
|
'relative flex flex-col items-center justify-center rounded-lg py-1.5 min-h-[2.75rem] text-sm select-none transition-all',
|
||||||
hasData
|
hasData ? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer' : 'text-gray-300 cursor-default',
|
||||||
? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer'
|
|
||||||
: 'text-gray-300 cursor-default',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<span className={hasData ? 'font-semibold text-blue-900 text-xs' : 'text-xs'}>
|
<span className={hasData ? 'font-semibold text-blue-900 text-xs' : 'text-xs'}>{day.getDate()}</span>
|
||||||
{day.getDate()}
|
|
||||||
</span>
|
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<span
|
<span data-testid={`count-${dateStr}`} className="text-[10px] leading-none font-bold text-blue-600 mt-0.5">{count}</span>
|
||||||
data-testid={`count-${dateStr}`}
|
|
||||||
className="text-[10px] leading-none font-bold text-blue-600 mt-0.5"
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day detail modal */}
|
{barData.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">
|
||||||
|
Field completeness by subject
|
||||||
|
{fieldOptions.find((f) => f.value === selectedField) && (
|
||||||
|
<span className="ml-2 font-normal text-gray-400">
|
||||||
|
— {fieldOptions.find((f) => f.value === selectedField).label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Bar dataKey="total" name="Total" fill="#94a3b8" radius={[3,3,0,0]} />
|
||||||
|
<Bar dataKey="success" name="Success" fill="#22c55e" radius={[3,3,0,0]} />
|
||||||
|
<Bar dataKey="failure" name="Failure" fill="#f87171" radius={[3,3,0,0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-4">
|
||||||
|
{barData.map((d) => (
|
||||||
|
<span key={d.name} className="text-xs text-gray-500">
|
||||||
|
<span className="font-medium text-gray-700">{d.name}</span>: {d.pct}% complete
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<Modal
|
<Modal isOpen={!!selectedDate} onClose={() => setSelectedDate(null)} title={format(selectedDate, 'MMMM d, yyyy')} size="lg">
|
||||||
isOpen={!!selectedDate}
|
<DayPanel
|
||||||
onClose={() => setSelectedDate(null)}
|
date={selectedDate}
|
||||||
title={format(selectedDate, 'MMMM d, yyyy')}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<DayDetail
|
|
||||||
statuses={selectedDayStatuses}
|
|
||||||
animals={animals}
|
animals={animals}
|
||||||
|
allStatuses={allStatuses}
|
||||||
template={template}
|
template={template}
|
||||||
|
experimentId={experimentId}
|
||||||
|
onStatusChange={(updated) => { onStatusChange?.(updated); setSelectedDate(null); }}
|
||||||
|
onStatusCreate={(created) => { onStatusCreate?.(created); setSelectedDate(null); }}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export default function ExperimentDetail() {
|
|||||||
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||||
{/* Mode toggle */}
|
{/* Mode toggle */}
|
||||||
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||||
{[['list', 'List'], ['group', 'Group'], ['calendar', 'Calendar']].map(([mode, label]) => (
|
{[['list', 'List'], ['group', 'Group']].map(([mode, label]) => (
|
||||||
<button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
|
<button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
|
||||||
className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
|
className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
|
||||||
${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||||
@@ -357,14 +357,6 @@ export default function ExperimentDetail() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{displayMode === 'calendar' && (
|
|
||||||
<ExperimentCalendar
|
|
||||||
experimentId={id}
|
|
||||||
template={dailyTemplate}
|
|
||||||
allStatuses={experimentStatuses}
|
|
||||||
animals={animals}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -375,6 +367,23 @@ export default function ExperimentDetail() {
|
|||||||
dailyTemplate={dailyTemplate}
|
dailyTemplate={dailyTemplate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{animals.length > 0 && (
|
||||||
|
<ExperimentCalendar
|
||||||
|
experimentId={id}
|
||||||
|
template={dailyTemplate}
|
||||||
|
allStatuses={experimentStatuses}
|
||||||
|
animals={animals}
|
||||||
|
onStatusChange={(updated) => {
|
||||||
|
setExperimentStatuses((prev) =>
|
||||||
|
prev.map((s) => (s.id === updated.id ? updated : s))
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onStatusCreate={(created) => {
|
||||||
|
setExperimentStatuses((prev) => [...prev, created]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AuditLogSection tableName="animals" limit={5} />
|
<AuditLogSection tableName="animals" limit={5} />
|
||||||
|
|
||||||
{/* Template editor modal */}
|
{/* Template editor modal */}
|
||||||
|
|||||||
Reference in New Issue
Block a user