765 lines
30 KiB
React
765 lines
30 KiB
React
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>
|
||
);
|
||
}
|