Files
experiments-database/frontend/src/pages/DailyStatusDetail.jsx
T
2026-05-01 07:58:56 -04:00

765 lines
30 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { format } from 'date-fns';
import { dailyStatusesApi, experimentsApi, analysesApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert';
import CsvAnalysis from '../components/CsvAnalysis';
import AuditLogSection from '../components/AuditLogSection';
import RunSequenceView from '../components/RunSequenceView';
import FileDropZone from '../components/FileDropZone';
function fmtDate(raw) {
return format(new Date(String(raw).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy');
}
function fmtDateTime(raw) {
return format(new Date(raw), 'MMM d, yyyy · h:mm a');
}
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
// ── Analysis summary card ─────────────────────────────────────────────────────
function AnalysisSummaryCard({ summary, statusId, onSummaryUpdated }) {
const { counts = {}, total, success_rate, computed_at } = summary;
const COLORS = {
Success: 'bg-green-100 text-green-800',
Failure: 'bg-red-100 text-red-800',
Other: 'bg-gray-100 text-gray-700',
};
const EXTRA = ['bg-indigo-100 text-indigo-800', 'bg-yellow-100 text-yellow-800', 'bg-purple-100 text-purple-800', 'bg-pink-100 text-pink-800'];
const catList = Object.entries(counts).sort(([, a], [, b]) => b - a);
let extraIdx = 0;
const [showForm, setShowForm] = useState(false);
return (
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Session metrics</p>
<div className="flex items-center gap-3">
{computed_at && <p className="text-[10px] text-gray-400">Updated {fmtDateTime(computed_at)}</p>}
{!showForm && (
<button
type="button"
onClick={() => setShowForm(true)}
className="text-[10px] text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded px-2 py-0.5 transition-colors"
>
Edit manually
</button>
)}
</div>
</div>
{showForm ? (
<ManualMetricsForm
statusId={statusId}
existing={summary}
onSaved={(updated) => {
onSummaryUpdated({ ...updated, computed_at: new Date().toISOString() });
setShowForm(false);
}}
onClose={() => setShowForm(false)}
/>
) : (
<div className="flex flex-wrap gap-3 items-center">
{catList.map(([cat, n]) => {
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
return (
<div key={cat} className={`flex flex-col items-center px-3 py-2 rounded-lg ${cls}`}>
<span className="text-xs font-medium">{cat}</span>
<span className="text-xl font-bold leading-tight">{n}</span>
</div>
);
})}
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-blue-50 text-blue-800">
<span className="text-xs font-medium">Total</span>
<span className="text-xl font-bold leading-tight">{total}</span>
</div>
{success_rate != null && (
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-indigo-50 text-indigo-800">
<span className="text-xs font-medium">Success rate</span>
<span className="text-xl font-bold leading-tight">{(success_rate * 100).toFixed(1)}%</span>
</div>
)}
</div>
)}
</div>
);
}
// ── Manual metrics form ───────────────────────────────────────────────────────
const DEFAULT_CATS = ['Success', 'Failure', 'Other'];
function ManualMetricsForm({ statusId, existing, onSaved, onClose }) {
const initCounts = () => {
const base = { ...existing?.counts };
for (const c of DEFAULT_CATS) if (!(c in base)) base[c] = 0;
return base;
};
const [counts, setCounts] = useState(initCounts);
const [newCat, setNewCat] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const total = Object.values(counts).reduce((a, b) => a + (Number(b) || 0), 0);
const successRate = total > 0 && counts['Success'] != null
? (Number(counts['Success']) || 0) / total
: null;
function setCount(cat, raw) {
const v = Math.max(0, parseInt(raw, 10) || 0);
setCounts((prev) => ({ ...prev, [cat]: v }));
}
function addCat() {
const name = newCat.trim();
if (!name || name in counts) return;
setCounts((prev) => ({ ...prev, [name]: 0 }));
setNewCat('');
}
function removeCat(cat) {
if (DEFAULT_CATS.includes(cat)) return;
setCounts((prev) => { const n = { ...prev }; delete n[cat]; return n; });
}
async function save() {
setSaving(true);
setError(null);
try {
const finalCounts = Object.fromEntries(
Object.entries(counts).map(([k, v]) => [k, Number(v) || 0])
);
await dailyStatusesApi.updateAnalysisSummary(statusId, {
counts: finalCounts,
total,
success_rate: successRate,
source_analysis_id: null,
});
onSaved({ counts: finalCounts, total, success_rate: successRate });
} catch (err) {
setError(err.message ?? 'Failed to save');
} finally {
setSaving(false);
}
}
return (
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Set metrics manually</p>
<div className="flex flex-wrap gap-3 items-end mb-3">
{Object.entries(counts).map(([cat, val]) => (
<div key={cat} className="flex flex-col items-center gap-1">
<span className="text-xs text-gray-500">{cat}</span>
<input
type="number" min={0} value={val}
onChange={(e) => setCount(cat, e.target.value)}
className="w-20 text-center border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
{!DEFAULT_CATS.includes(cat) && (
<button type="button" onClick={() => removeCat(cat)}
className="text-[10px] text-red-400 hover:text-red-600">remove</button>
)}
</div>
))}
{/* Add custom category inline */}
<div className="flex flex-col items-center gap-1">
<span className="text-xs text-gray-400">+ category</span>
<div className="flex gap-1">
<input value={newCat} onChange={(e) => setNewCat(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addCat(); }}
placeholder="Name…"
className="w-24 border border-dashed border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-400" />
<button type="button" onClick={addCat}
className="text-xs text-indigo-500 hover:text-indigo-700 px-1">Add</button>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
<span>Total: <strong className="text-gray-800">{total}</strong></span>
{successRate != null && (
<span>Success rate: <strong className="text-gray-800">{(successRate * 100).toFixed(1)}%</strong></span>
)}
</div>
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={save} disabled={saving}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-gray-500 hover:text-gray-700 border border-gray-200 transition-colors">
Cancel
</button>
</div>
</div>
);
}
// ── Past analysis row (expandable) ────────────────────────────────────────────
function AnalysisRow({ analysis, onDelete }) {
const [expanded, setExpanded] = useState(false);
const [full, setFull] = useState(null);
const [loadingFull, setLoadingFull] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
async function expand() {
if (expanded) { setExpanded(false); return; }
setExpanded(true);
if (full) return;
setLoadingFull(true);
try {
const data = await analysesApi.get(analysis.id);
setFull(data);
} finally {
setLoadingFull(false);
}
}
async function handleDelete() {
setDeleting(true);
try {
await analysesApi.delete(analysis.id);
onDelete(analysis.id);
} finally {
setDeleting(false);
}
}
const counts = analysis.category_counts ?? {};
const summary = Object.entries(counts)
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, d]) => `${cat}: ${d.total}`)
.join(' · ');
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={expand}
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-gray-400 text-xs">{expanded ? '▾' : '▸'}</span>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">
{analysis.file_name ?? 'Unnamed file'}
</p>
<p className="text-xs text-gray-400">
{fmtDateTime(analysis.created_at)}
{' · '}
<span className="font-mono">{analysis.note_col}</span>
{' · '}
{analysis.total_note_rows} note rows
</p>
</div>
</div>
<div className="flex items-center gap-3 ml-4 shrink-0">
<span className="text-xs text-gray-500 hidden sm:block">{summary}</span>
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
className="text-xs text-red-400 hover:text-red-600 transition-colors">Delete</button>
</div>
</div>
{expanded && (
<div className="border-t border-gray-100 px-4 py-4 bg-gray-50/40">
{loadingFull && <p className="text-sm text-gray-400">Loading</p>}
{full && <AnalysisResultsView analysis={full} />}
</div>
)}
<ConfirmDialog
isOpen={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
loading={deleting}
title="Delete Analysis"
message="Remove this analysis record? The raw CSV is not affected."
/>
</div>
);
}
// ── Analysis results display (reused by both live and past) ───────────────────
export function AnalysisResultsView({ analysis }) {
const { timestamp_col, note_col, total_note_rows,
category_counts, consecutive_stats, runs } = analysis;
const customCategories = [];
function colorsFor(cat) {
const COLORS = {
Success: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500', border: 'border-green-300' },
Failure: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500', border: 'border-red-300' },
Other: { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-400', border: 'border-gray-300' },
};
const EXTRA = [
{ bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500', border: 'border-indigo-300' },
{ bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500', border: 'border-yellow-300' },
{ bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500', border: 'border-purple-300' },
];
if (COLORS[cat]) return COLORS[cat];
const idx = customCategories.indexOf(cat) % EXTRA.length;
return EXTRA[Math.max(0, idx)];
}
return (
<div className="space-y-5 text-sm">
<p className="text-xs text-gray-400">
{total_note_rows} note rows · timestamp: <span className="font-mono">{timestamp_col}</span>
{' '}/ note: <span className="font-mono">{note_col}</span>
</p>
{/* Category counts */}
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Category Counts</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-white border-b border-gray-200">
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Breakdown</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Total</th>
</tr>
</thead>
<tbody>
{Object.entries(category_counts ?? {})
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, data]) => {
const c = colorsFor(cat);
const bd = Object.entries(data.byNote ?? {})
.sort(([, a], [, b]) => b - a)
.map(([n, cnt]) => `${n}: ${cnt}`).join(' · ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{bd}</td>
<td className="px-3 py-1.5 text-right font-semibold text-gray-800">{data.total}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Consecutive stats */}
{Object.keys(consecutive_stats ?? {}).length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Consecutive Runs</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-white border-b border-gray-200">
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Runs</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Max</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Avg</th>
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Lengths</th>
</tr>
</thead>
<tbody>
{Object.entries(consecutive_stats).map(([cat, s]) => {
const c = colorsFor(cat);
const summary = Object.entries(
s.runLengths.reduce((acc, l) => { acc[l] = (acc[l] || 0) + 1; return acc; }, {})
).sort(([a], [b]) => Number(a) - Number(b))
.map(([l, cnt]) => `${l}×${cnt}`).join(', ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.totalRuns}</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.maxRun}</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{summary}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<RunSequenceView runs={runs ?? []} sequence={analysis.sequence ?? []} sessionEndTs={analysis.session_end_ts ?? null} />
</div>
);
}
// ── Past analyses inline viewer ───────────────────────────────────────────────
function PastAnalysesViewer({ analyses, onDelete, onNewAnalysis }) {
const [selectedId, setSelectedId] = useState(() => analyses[0]?.id ?? null);
const [cache, setCache] = useState({});
const [loadingId, setLoadingId] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
// Load full data whenever the selected id changes
useEffect(() => {
if (!selectedId || cache[selectedId]) return;
setLoadingId(selectedId);
analysesApi.get(selectedId)
.then((data) => setCache((prev) => ({ ...prev, [selectedId]: data })))
.catch(() => {})
.finally(() => setLoadingId(null));
}, [selectedId]);
// If the list changes (new analysis added / deleted), keep selection valid
useEffect(() => {
if (!analyses.length) { setSelectedId(null); return; }
if (!analyses.find((a) => a.id === selectedId)) setSelectedId(analyses[0].id);
}, [analyses]);
async function handleDelete(id) {
setDeleting(true);
try {
await analysesApi.delete(id);
setCache((prev) => { const n = { ...prev }; delete n[id]; return n; });
onDelete(id);
} finally {
setDeleting(false);
setConfirmDelete(null);
}
}
if (!analyses.length) return null;
const selected = analyses.find((a) => a.id === selectedId) ?? null;
const fullData = selectedId ? (cache[selectedId] ?? null) : null;
const isLoading = loadingId === selectedId;
const categorySummary = (a) =>
Object.entries(a.category_counts ?? {})
.sort(([, x], [, y]) => y.total - x.total)
.map(([cat, d]) => `${cat}: ${d.total}`)
.join(' · ');
return (
<div className="mt-8 border-t border-gray-200 pt-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Past Analyses ({analyses.length})
</h2>
</div>
{/* Tab selector — scrollable if many */}
{analyses.length > 1 && (
<div className="flex gap-1.5 overflow-x-auto pb-2 mb-4 scrollbar-thin">
{analyses.map((a) => {
const active = a.id === selectedId;
return (
<button
key={a.id}
type="button"
onClick={() => setSelectedId(a.id)}
className={`shrink-0 flex flex-col items-start px-3 py-2 rounded-lg border text-left transition-colors
${active
? 'bg-indigo-50 border-indigo-300 text-indigo-800'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}
>
<span className="text-xs font-medium truncate max-w-[160px]">
{a.file_name ?? 'Unnamed file'}
</span>
<span className={`text-[10px] mt-0.5 ${active ? 'text-indigo-500' : 'text-gray-400'}`}>
{fmtDateTime(a.created_at)}
</span>
</button>
);
})}
</div>
)}
{/* Selected analysis panel */}
{selected && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{/* Panel header */}
<div className="flex items-start justify-between px-4 py-3 border-b border-gray-100 bg-gray-50/50">
<div>
<p className="text-sm font-medium text-gray-800">
{selected.file_name ?? 'Unnamed file'}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{fmtDateTime(selected.created_at)}
{' · '}
<span className="font-mono">{selected.note_col}</span>
{' · '}
{selected.total_note_rows} note rows
{categorySummary(selected) && (
<span className="ml-2 text-gray-500">{categorySummary(selected)}</span>
)}
</p>
</div>
<button
type="button"
onClick={() => setConfirmDelete(selected.id)}
className="text-xs text-red-400 hover:text-red-600 transition-colors shrink-0 ml-4 mt-0.5"
>
Delete
</button>
</div>
{/* Panel body */}
<div className="px-4 py-4">
{isLoading && (
<p className="text-sm text-gray-400 py-4 text-center">Loading</p>
)}
{!isLoading && fullData && <AnalysisResultsView analysis={fullData} />}
</div>
</div>
)}
<ConfirmDialog
isOpen={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={() => handleDelete(confirmDelete)}
loading={deleting}
title="Delete Analysis"
message="Remove this analysis record? The raw CSV is not affected."
/>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DailyStatusDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [status, setStatus] = useState(null);
const [template, setTemplate] = useState([]);
const [allStatuses, setAllStatuses] = useState([]);
const [analyses, setAnalyses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
async function load(statusId = id) {
setLoading(true);
setError(null);
try {
const s = await dailyStatusesApi.get(statusId);
const [tmpl, siblings, pastAnalyses] = await Promise.all([
experimentsApi.getTemplate(s.animal.experiment_id),
dailyStatusesApi.list(s.animal_id),
analysesApi.listForStatus(statusId),
]);
setStatus(s);
setTemplate(tmpl);
setAllStatuses(siblings);
setAnalyses(pastAnalyses);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
async function handleDelete() {
setDeleteLoading(true);
try {
await dailyStatusesApi.delete(status.id);
navigate(`/animals/${status.animal_id}`, { replace: true });
} catch (err) {
setError(err.message);
setShowDelete(false);
} finally {
setDeleteLoading(false);
}
}
if (loading) return <div className="max-w-3xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
if (error) return (
<div className="max-w-3xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}> Back</Button>
</div>
);
const animal = status.animal;
const activeFields = template.filter((f) => f.active);
const currentIdx = allStatuses.findIndex((s) => s.id === status.id);
const prevStatus = currentIdx > 0 ? allStatuses[currentIdx - 1] : null;
const nextStatus = currentIdx < allStatuses.length - 1 ? allStatuses[currentIdx + 1] : null;
return (
<div className="max-w-3xl 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>
<span className="mx-2">/</span>
<Link to={`/animals/${animal.id}`} className="hover:text-blue-600">{animal.animal_name}</Link>
<span className="mx-2">/</span>
<span className="text-gray-900 font-medium">{fmtDate(status.date)}</span>
</nav>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{fmtDate(status.date)}</h1>
<p className="text-sm text-gray-500 mt-0.5">
{animal.animal_name}
{animal.animal_id_string && <> · <span className="font-mono">{animal.animal_id_string}</span></>}
</p>
</div>
<Button variant="secondary" onClick={() => setShowEdit(true)}>Edit entry</Button>
</div>
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Field values */}
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
{activeFields.length === 0 ? (
<p className="text-sm text-gray-400 italic">No template fields configured.</p>
) : (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
{activeFields.map((f) => {
const value = getFieldValue(status, f);
const label = f.unit ? `${f.label} (${f.unit})` : f.label;
return (
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-0.5">{label}</dt>
<dd className="text-gray-800 whitespace-pre-wrap break-words">
{value ?? <span className="text-gray-300 italic"></span>}
</dd>
</div>
);
})}
</dl>
)}
</div>
{/* Analysis summary (pinned metrics) */}
{status.analysis_summary ? (
<AnalysisSummaryCard
summary={status.analysis_summary}
statusId={status.id}
onSummaryUpdated={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: summary }))}
/>
) : (
<div className="mb-4">
<button
type="button"
onClick={() => setShowManualForm(true)}
className="text-xs text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded-lg px-3 py-1.5 transition-colors"
>
Set metrics manually
</button>
</div>
)}
{showManualForm && !status.analysis_summary && (
<ManualMetricsForm
statusId={status.id}
existing={null}
onSaved={(summary) => {
setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }));
setShowManualForm(false);
}}
onClose={() => setShowManualForm(false)}
/>
)}
{/* Past analyses — inline viewer */}
<PastAnalysesViewer
analyses={analyses}
onDelete={(deletedId) => setAnalyses((prev) => prev.filter((x) => x.id !== deletedId))}
/>
{/* CSV Analysis — after past analyses */}
<CsvAnalysis
dailyStatusId={status.id}
animal={status.animal}
onSaved={(saved) => setAnalyses((prev) => [saved, ...prev])}
onSummaryPushed={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }))}
/>
{/* Session file registry */}
<FileDropZone
rows={[{ animal, status }]}
date={String(status.date).slice(0, 10)}
/>
{/* Full modification history for this entry */}
<AuditLogSection tableName="daily_statuses" recordId={status.id} />
{/* Prev / Next navigation */}
<div className="flex justify-between items-center mt-8 mb-6">
<div>
{prevStatus && (
<button type="button" onClick={() => navigate(`/daily-statuses/${prevStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
{fmtDate(prevStatus.date)}
</button>
)}
</div>
<Link to={`/animals/${animal.id}`} className="text-sm text-gray-500 hover:text-gray-800">
All entries
</Link>
<div>
{nextStatus && (
<button type="button" onClick={() => navigate(`/daily-statuses/${nextStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
{fmtDate(nextStatus.date)}
</button>
)}
</div>
</div>
{/* Edit modal */}
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Daily Status" size="full">
<DailyStatusForm
existing={status}
animalId={animal.id}
experimentId={animal.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={(updated) => { setStatus({ ...updated, animal }); setShowEdit(false); }}
onCancel={() => setShowEdit(false)}
onRequestDelete={() => { setShowEdit(false); setShowDelete(true); }}
/>
</Modal>
<ConfirmDialog
isOpen={showDelete}
onClose={() => setShowDelete(false)}
onConfirm={handleDelete}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the entry for ${fmtDate(status.date)}? This cannot be undone.`}
confirmWord="delete"
/>
</div>
);
}