feat(frontend): React UI — pages, forms, audit log section, Tailwind, Vite build
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user