dc46af8d31
Clicking any table cell opens an inline input (text or textarea per field type). Enter or blur saves; Escape cancels. Unchanged values skip the API call. Custom fields merge with existing custom_fields; builtin fields use their key directly. Prev/next buttons at the bottom navigate to adjacent days that have data for the selected field, preserving the ?field= param. Day list is fetched via getCalendar and sorted chronologically. Tests: 41 total (added 15 new tests for cell editing and day nav). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
394 lines
15 KiB
React
394 lines
15 KiB
React
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
|
|
import { format } from 'date-fns';
|
|
import {
|
|
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
|
|
CartesianGrid, Tooltip, Legend,
|
|
} from 'recharts';
|
|
import { experimentsApi, dailyStatusesApi } from '../api/client';
|
|
import Button from '../components/ui/Button';
|
|
import Modal from '../components/ui/Modal';
|
|
import DailyStatusForm from '../components/DailyStatusForm';
|
|
import Alert from '../components/ui/Alert';
|
|
|
|
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
|
|
|
function getFieldValue(status, field) {
|
|
if (field.builtin) return status[field.key];
|
|
return status.custom_fields?.[field.fieldId];
|
|
}
|
|
|
|
function cellText(value) {
|
|
if (value === null || value === undefined || String(value).trim() === '') return null;
|
|
return String(value);
|
|
}
|
|
|
|
// ── Inline cell editor ────────────────────────────────────────────────────────
|
|
|
|
function CellEdit({ value, field, saving, onSave, onCancel }) {
|
|
const [val, setVal] = useState(value ?? '');
|
|
const ref = useRef(null);
|
|
|
|
useEffect(() => {
|
|
ref.current?.focus();
|
|
ref.current?.select();
|
|
}, []);
|
|
|
|
function handleKey(e) {
|
|
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
|
if (e.key === 'Enter' && field.type !== 'textarea') { e.preventDefault(); onSave(val); }
|
|
}
|
|
|
|
const cls = 'w-full text-sm border border-blue-400 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white disabled:opacity-60';
|
|
|
|
if (field.type === 'textarea') {
|
|
return (
|
|
<textarea
|
|
ref={ref} value={val} rows={3} disabled={saving}
|
|
onChange={(e) => setVal(e.target.value)}
|
|
onBlur={() => onSave(val)}
|
|
onKeyDown={handleKey}
|
|
className={cls}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<input
|
|
ref={ref} type="text" value={val} disabled={saving}
|
|
onChange={(e) => setVal(e.target.value)}
|
|
onBlur={() => onSave(val)}
|
|
onKeyDown={handleKey}
|
|
className={cls}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
|
|
export default function ExperimentDayView() {
|
|
const { id, date } = useParams();
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
|
|
const [experiment, setExperiment] = useState(null);
|
|
const [statuses, setStatuses] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [editStatus, setEditStatus] = useState(null);
|
|
|
|
// Inline cell editing
|
|
const [editingCell, setEditingCell] = useState(null); // { statusId, fieldId }
|
|
const [cellSaving, setCellSaving] = useState(false);
|
|
const [cellError, setCellError] = useState(null);
|
|
|
|
// Day navigation
|
|
const [dayList, setDayList] = useState([]);
|
|
|
|
const selectedField = searchParams.get('field') ?? null;
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
Promise.all([
|
|
experimentsApi.get(id),
|
|
experimentsApi.getDayStatuses(id, date),
|
|
])
|
|
.then(([exp, s]) => {
|
|
setExperiment(exp);
|
|
setStatuses(s);
|
|
})
|
|
.catch((err) => setError(err.message ?? 'Failed to load'))
|
|
.finally(() => setLoading(false));
|
|
}, [id, date]);
|
|
|
|
// Fetch ordered list of days with data for this field, for prev/next nav
|
|
useEffect(() => {
|
|
if (!id || !selectedField) return;
|
|
experimentsApi.getCalendar(id, selectedField)
|
|
.then(({ days }) => setDayList(Object.keys(days).sort()))
|
|
.catch(() => {});
|
|
}, [id, selectedField]);
|
|
|
|
const dailyTemplate = useMemo(() => {
|
|
if (!experiment) return [];
|
|
return (experiment.template ?? []).filter((f) => f.active);
|
|
}, [experiment]);
|
|
|
|
const animals = useMemo(() => experiment?.animals ?? [], [experiment]);
|
|
|
|
const statusByAnimal = useMemo(() => {
|
|
const map = {};
|
|
statuses.forEach((s) => { map[s.animal_id] = s; });
|
|
return map;
|
|
}, [statuses]);
|
|
|
|
const rows = useMemo(() => {
|
|
return animals
|
|
.map((animal) => ({ animal, status: statusByAnimal[animal.id] }))
|
|
.filter(({ status }) => {
|
|
if (!status) return false;
|
|
if (!selectedField) return true;
|
|
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
|
|
const val = isBuiltin ? status[selectedField] : status.custom_fields?.[selectedField];
|
|
return val !== null && val !== undefined && String(val).trim() !== '';
|
|
});
|
|
}, [animals, statusByAnimal, selectedField]);
|
|
|
|
const barData = useMemo(() => {
|
|
return rows
|
|
.map(({ animal, status }) => {
|
|
const summary = status?.analysis_summary;
|
|
if (!summary || summary.total === 0) return null;
|
|
return {
|
|
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
|
|
total: summary.total,
|
|
success: summary.counts?.Success ?? 0,
|
|
failure: summary.counts?.Failure ?? 0,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}, [rows]);
|
|
|
|
// Prev / next days with data
|
|
const currentDayIdx = dayList.indexOf(date);
|
|
const prevDay = currentDayIdx > 0 ? dayList[currentDayIdx - 1] : null;
|
|
const nextDay = currentDayIdx < dayList.length - 1 ? dayList[currentDayIdx + 1] : null;
|
|
|
|
function dayNavUrl(d) {
|
|
return `/experiments/${id}/day/${d}${selectedField ? `?field=${selectedField}` : ''}`;
|
|
}
|
|
|
|
// Inline cell save
|
|
async function saveCell(status, field, rawValue) {
|
|
const value = rawValue?.trim() || null;
|
|
// Skip save if unchanged
|
|
const current = getFieldValue(status, field);
|
|
const currentNorm = current == null ? null : String(current).trim() || null;
|
|
if (value === currentNorm) { setEditingCell(null); return; }
|
|
|
|
setCellSaving(true);
|
|
setCellError(null);
|
|
try {
|
|
let payload;
|
|
if (field.builtin) {
|
|
payload = { [field.key]: value };
|
|
} else {
|
|
payload = { custom_fields: { ...status.custom_fields, [field.fieldId]: value } };
|
|
}
|
|
const updated = await dailyStatusesApi.update(status.id, payload);
|
|
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
|
|
setEditingCell(null);
|
|
} catch (err) {
|
|
setCellError(err.message ?? 'Save failed');
|
|
} finally {
|
|
setCellSaving(false);
|
|
}
|
|
}
|
|
|
|
const displayDate = date
|
|
? format(new Date(date + 'T12:00:00'), 'MMMM d, yyyy')
|
|
: date;
|
|
|
|
if (loading) {
|
|
return <div className="max-w-5xl mx-auto px-4 py-10 text-center text-gray-400">Loading…</div>;
|
|
}
|
|
if (error) {
|
|
return (
|
|
<div className="max-w-5xl mx-auto px-4 py-10">
|
|
<Alert type="error" message={error} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto px-4 py-6">
|
|
{/* Breadcrumb */}
|
|
<nav className="text-sm text-gray-400 mb-4 flex items-center gap-1.5">
|
|
<Link to="/" className="hover:text-blue-600 transition-colors">Dashboard</Link>
|
|
<span>/</span>
|
|
<Link to={`/experiments/${id}`} className="hover:text-blue-600 transition-colors">
|
|
{experiment?.title ?? id}
|
|
</Link>
|
|
<span>/</span>
|
|
<span className="text-gray-700 font-medium">{displayDate}</span>
|
|
</nav>
|
|
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-xl font-bold text-gray-900">{displayDate}</h1>
|
|
<Button variant="secondary" onClick={() => navigate(`/experiments/${id}`)}>
|
|
← Back to experiment
|
|
</Button>
|
|
</div>
|
|
|
|
{cellError && (
|
|
<Alert type="error" message={cellError} onDismiss={() => setCellError(null)} className="mb-4" />
|
|
)}
|
|
|
|
{/* Status table */}
|
|
{rows.length === 0 ? (
|
|
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl text-gray-400">
|
|
No data recorded for this day.
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-8">
|
|
<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-[140px]">
|
|
Subject
|
|
</th>
|
|
{dailyTemplate.map((f) => {
|
|
const header = f.abbr
|
|
? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr)
|
|
: (f.unit ? `${f.label} (${f.unit})` : f.label);
|
|
return (
|
|
<th
|
|
key={f.fieldId}
|
|
title={f.unit ? `${f.label} (${f.unit})` : f.label}
|
|
className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px]"
|
|
style={{ minWidth: Math.max(80, header.length * 7.5 + 24) }}
|
|
>
|
|
{header}
|
|
</th>
|
|
);
|
|
})}
|
|
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[80px]" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map(({ animal, status }, i) => {
|
|
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
|
|
return (
|
|
<tr key={animal.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
|
|
{/* Subject name → daily status detail */}
|
|
<td
|
|
className={`px-3 py-2 font-medium whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 ${rowBg} ${
|
|
status
|
|
? 'text-blue-600 cursor-pointer hover:text-blue-800 hover:bg-blue-50/40 transition-colors'
|
|
: 'text-gray-500'
|
|
}`}
|
|
onClick={() => status && navigate(`/daily-statuses/${status.id}`)}
|
|
>
|
|
{animal.animal_name ?? animal.animal_id_string ?? animal.id.slice(0, 8)}
|
|
{animal.animal_id_string && animal.animal_name && (
|
|
<span className="ml-1.5 text-xs text-gray-400 font-normal">
|
|
{animal.animal_id_string}
|
|
</span>
|
|
)}
|
|
</td>
|
|
|
|
{/* Field cells — click to edit inline */}
|
|
{dailyTemplate.map((f) => {
|
|
const isEditing = editingCell?.statusId === status?.id && editingCell?.fieldId === f.fieldId;
|
|
const text = status ? cellText(getFieldValue(status, f)) : null;
|
|
return (
|
|
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px] align-top">
|
|
{isEditing ? (
|
|
<CellEdit
|
|
value={text}
|
|
field={f}
|
|
saving={cellSaving}
|
|
onSave={(val) => saveCell(status, f, val)}
|
|
onCancel={() => setEditingCell(null)}
|
|
/>
|
|
) : (
|
|
<span
|
|
className="whitespace-pre-wrap break-words block rounded px-0.5 -mx-0.5 cursor-pointer hover:bg-blue-50 transition-colors"
|
|
title="Click to edit"
|
|
onClick={() => status && setEditingCell({ statusId: status.id, fieldId: f.fieldId })}
|
|
>
|
|
{text ?? <span className="text-gray-300">—</span>}
|
|
</span>
|
|
)}
|
|
</td>
|
|
);
|
|
})}
|
|
|
|
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
|
|
{status && (
|
|
<Button size="sm" variant="secondary" onClick={() => setEditStatus(status)}>
|
|
Edit
|
|
</Button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Session metrics bar chart */}
|
|
{barData.length > 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 mb-8">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h3>
|
|
<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]} minPointSize={2} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
|
|
{/* Prev / next day navigation */}
|
|
{(prevDay || nextDay) && (
|
|
<div className="flex justify-between items-center mt-2 mb-4">
|
|
<div>
|
|
{prevDay && (
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(dayNavUrl(prevDay))}
|
|
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
|
>
|
|
← {format(new Date(prevDay + 'T12:00:00'), 'MMMM d, yyyy')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div>
|
|
{nextDay && (
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(dayNavUrl(nextDay))}
|
|
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
|
>
|
|
{format(new Date(nextDay + 'T12:00:00'), 'MMMM d, yyyy')} →
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit modal (full row) */}
|
|
{editStatus && (
|
|
<Modal
|
|
isOpen={!!editStatus}
|
|
onClose={() => setEditStatus(null)}
|
|
title="Edit daily status"
|
|
size="lg"
|
|
>
|
|
<DailyStatusForm
|
|
existing={editStatus}
|
|
animalId={editStatus.animal_id}
|
|
experimentId={id}
|
|
template={dailyTemplate}
|
|
onSuccess={(updated) => {
|
|
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
|
|
setEditStatus(null);
|
|
}}
|
|
onCancel={() => setEditStatus(null)}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|