feat(frontend): React UI — pages, forms, audit log section, Tailwind, Vite build

This commit is contained in:
Experiments DB Dev
2026-04-15 13:18:31 -04:00
parent f1d2449808
commit c32a5d200d
22 changed files with 10356 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { animalsApi, dailyStatusesApi } from '../api/client';
import { format } from 'date-fns';
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 AuditLogSection from '../components/AuditLogSection';
export default function AnimalDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [animal, setAnimal] = useState(null);
const [statuses, setStatuses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
const [showAddStatus, setShowAddStatus] = useState(false);
const [editStatus, setEditStatus] = useState(null);
const [deleteStatus, setDeleteStatus] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
async function load() {
setLoading(true);
setError(null);
try {
const [animalData, statusData] = await Promise.all([
animalsApi.get(id),
dailyStatusesApi.list(id),
]);
setAnimal(animalData);
setStatuses(statusData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
function flash(msg) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function handleStatusSaved() {
setShowAddStatus(false);
setEditStatus(null);
load();
flash(editStatus ? 'Daily status updated.' : 'Daily status logged.');
}
async function handleDeleteStatus() {
setDeleteLoading(true);
try {
await dailyStatusesApi.delete(deleteStatus.id);
setDeleteStatus(null);
load();
flash('Daily status removed.');
} catch (err) {
setError(err.message);
setDeleteStatus(null);
} finally {
setDeleteLoading(false);
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}> Back</Button>
</div>
);
return (
<div className="max-w-5xl 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>
<span className="text-gray-900 font-medium">{animal.animal_name}</span>
</nav>
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{animal.animal_name}</h1>
<p className="text-sm text-gray-500 mt-0.5">
ID: <span className="font-mono">{animal.animal_id_string}</span>
{' · '}
{statuses.length} daily status {statuses.length !== 1 ? 'entries' : 'entry'}
</p>
</div>
<Button onClick={() => setShowAddStatus(true)}>+ Log Daily Status</Button>
</div>
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{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>
<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} className="flex flex-col">
<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>
{!s.experiment_description && !s.vitals && !s.treatment && !s.notes && (
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
)}
</div>
))}
</div>
)}
<AuditLogSection tableName="daily_statuses" recordId={undefined} />
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
<DailyStatusForm animalId={id} onSuccess={handleStatusSaved} onCancel={() => setShowAddStatus(false)} />
</Modal>
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
<DailyStatusForm
existing={editStatus}
animalId={id}
onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)}
/>
</Modal>
<ConfirmDialog
isOpen={!!deleteStatus}
onClose={() => setDeleteStatus(null)}
onConfirm={handleDeleteStatus}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the status entry for ${deleteStatus ? format(new Date(deleteStatus.date), 'MMMM d, yyyy') : ''}?`}
/>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { experimentsApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import ExperimentForm from '../components/ExperimentForm';
import Alert from '../components/ui/Alert';
import { format } from 'date-fns';
import AuditLogSection from '../components/AuditLogSection';
export default function Dashboard() {
const navigate = useNavigate();
const [experiments, setExperiments] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editTarget, setEditTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [successMsg, setSuccessMsg] = useState(null);
async function loadExperiments() {
setLoading(true);
setError(null);
try {
const data = await experimentsApi.list();
setExperiments(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { loadExperiments(); }, []);
function flash(msg) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function handleCreated(exp) {
setShowCreateModal(false);
loadExperiments();
flash(`Experiment "${exp.title}" created.`);
}
function handleUpdated(exp) {
setEditTarget(null);
loadExperiments();
flash(`Experiment "${exp.title}" updated.`);
}
async function handleDelete() {
setDeleteLoading(true);
try {
await experimentsApi.delete(deleteTarget.id);
setDeleteTarget(null);
loadExperiments();
flash('Experiment deleted.');
} catch (err) {
setError(err.message);
setDeleteTarget(null);
} finally {
setDeleteLoading(false);
}
}
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Experiments</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage all animal experimental studies</p>
</div>
<Button onClick={() => setShowCreateModal(true)}>
+ New Experiment
</Button>
</div>
{successMsg && <Alert type="success" message={successMsg} className="mb-4" />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{loading && (
<div className="text-center py-16 text-gray-400">Loading experiments</div>
)}
{!loading && experiments.length === 0 && (
<div className="text-center py-20 border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 text-lg mb-2">No experiments yet</p>
<p className="text-gray-400 text-sm mb-4">Create your first experiment to get started.</p>
<Button onClick={() => setShowCreateModal(true)}>+ New Experiment</Button>
</div>
)}
{!loading && experiments.length > 0 && (
<div className="grid gap-4">
{experiments.map((exp) => (
<div
key={exp.id}
className="bg-white border border-gray-200 rounded-xl p-5 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => navigate(`/experiments/${exp.id}`)}
>
<div>
<h2 className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{exp.title}
</h2>
<div className="text-sm text-gray-500 mt-0.5 flex gap-4">
<span>{exp._count?.animals ?? 0} animal{exp._count?.animals !== 1 ? 's' : ''}</span>
<span>Created {format(new Date(exp.created_at), 'MMM d, yyyy')}</span>
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="secondary"
onClick={() => setEditTarget(exp)}
>
Edit
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(exp)}
>
Delete
</Button>
</div>
</div>
))}
</div>
)}
<AuditLogSection tableName="experiments" />
{/* Create modal */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="New Experiment">
<ExperimentForm onSuccess={handleCreated} onCancel={() => setShowCreateModal(false)} />
</Modal>
{/* Edit modal */}
<Modal isOpen={!!editTarget} onClose={() => setEditTarget(null)} title="Edit Experiment">
<ExperimentForm
existing={editTarget}
onSuccess={handleUpdated}
onCancel={() => setEditTarget(null)}
/>
</Modal>
{/* Delete confirmation */}
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
loading={deleteLoading}
title="Delete Experiment"
message={`Are you sure you want to delete "${deleteTarget?.title}"? All associated animals and daily statuses will be permanently removed.`}
/>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { experimentsApi, animalsApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import AnimalForm from '../components/AnimalForm';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
export default function ExperimentDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [experiment, setExperiment] = useState(null);
const [animals, setAnimals] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
const [showAddAnimal, setShowAddAnimal] = useState(false);
const [editAnimal, setEditAnimal] = useState(null);
const [deleteAnimal, setDeleteAnimal] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
async function load() {
setLoading(true);
setError(null);
try {
const [exp, anims] = await Promise.all([
experimentsApi.get(id),
animalsApi.list(id),
]);
setExperiment(exp);
setAnimals(anims);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
function flash(msg) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function handleAnimalSaved(animal) {
setShowAddAnimal(false);
setEditAnimal(null);
load();
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
}
async function handleDeleteAnimal() {
setDeleteLoading(true);
try {
await animalsApi.delete(deleteAnimal.id);
setDeleteAnimal(null);
load();
flash('Animal removed.');
} catch (err) {
setError(err.message);
setDeleteAnimal(null);
} finally {
setDeleteLoading(false);
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
<Button variant="secondary" className="mt-4" onClick={() => navigate('/')}> Back</Button>
</div>
);
return (
<div className="max-w-5xl 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>
<span className="text-gray-900 font-medium">{experiment.title}</span>
</nav>
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{experiment.title}</h1>
<p className="text-sm text-gray-500 mt-0.5">
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
</p>
</div>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div>
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{animals.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 animals enrolled in this experiment yet.</p>
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
</div>
) : (
<div className="grid gap-3">
{animals.map((animal) => (
<div
key={animal.id}
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => navigate(`/animals/${animal.id}`)}
>
<div>
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{animal.animal_name}
</div>
<div className="text-sm text-gray-500">
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="secondary" onClick={() => setEditAnimal(animal)}>Edit</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteAnimal(animal)}>Remove</Button>
</div>
</div>
))}
</div>
)}
<AuditLogSection tableName="animals" recordId={undefined} />
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
</Modal>
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
<AnimalForm
existing={editAnimal}
experimentId={id}
onSuccess={handleAnimalSaved}
onCancel={() => setEditAnimal(null)}
/>
</Modal>
<ConfirmDialog
isOpen={!!deleteAnimal}
onClose={() => setDeleteAnimal(null)}
onConfirm={handleDeleteAnimal}
loading={deleteLoading}
title="Remove Animal"
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`}
/>
</div>
);
}