feat(frontend): React UI — pages, forms, audit log section, Tailwind, Vite build
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Animal Experiments Database</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+9016
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "experiments-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.44.1",
|
||||||
|
"@testing-library/jest-dom": "^6.4.6",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"@vitest/coverage-v8": "^1.6.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"jsdom": "^24.1.0",
|
||||||
|
"postcss": "^8.4.39",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"vite": "^4.5.3",
|
||||||
|
"vitest": "^1.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import ExperimentDetail from './pages/ExperimentDetail';
|
||||||
|
import AnimalDetail from './pages/AnimalDetail';
|
||||||
|
|
||||||
|
function Navbar() {
|
||||||
|
return (
|
||||||
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||||
|
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 hover:text-blue-700 transition-colors">
|
||||||
|
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
|
</svg>
|
||||||
|
Animal Experiments DB
|
||||||
|
</Link>
|
||||||
|
<nav className="text-sm text-gray-500">
|
||||||
|
<Link to="/" className="hover:text-blue-600 transition-colors">Dashboard</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-20 text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-300 mb-4">404</h1>
|
||||||
|
<p className="text-gray-500 mb-6">Page not found.</p>
|
||||||
|
<Link to="/" className="text-blue-600 hover:underline">← Return to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<main>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/experiments/:id" element={<ExperimentDetail />} />
|
||||||
|
<Route path="/animals/:id" element={<AnimalDetail />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor — normalize errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
const message =
|
||||||
|
err.response?.data?.error ||
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
'An unexpected error occurred';
|
||||||
|
const details = err.response?.data?.details || null;
|
||||||
|
return Promise.reject({ message, details, status: err.response?.status });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
||||||
|
// ── Experiments ────────────────────────────────────────────────────────────────
|
||||||
|
export const experimentsApi = {
|
||||||
|
list: () => api.get('/experiments').then((r) => r.data),
|
||||||
|
get: (id) => api.get(`/experiments/${id}`).then((r) => r.data),
|
||||||
|
create: (data) => api.post('/experiments', data).then((r) => r.data),
|
||||||
|
update: (id, data) => api.put(`/experiments/${id}`, data).then((r) => r.data),
|
||||||
|
delete: (id) => api.delete(`/experiments/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Animals ───────────────────────────────────────────────────────────────────
|
||||||
|
export const animalsApi = {
|
||||||
|
list: (experimentId) =>
|
||||||
|
api.get('/animals', { params: { experimentId } }).then((r) => r.data),
|
||||||
|
get: (id) => api.get(`/animals/${id}`).then((r) => r.data),
|
||||||
|
create: (data) => api.post('/animals', data).then((r) => r.data),
|
||||||
|
update: (id, data) => api.put(`/animals/${id}`, data).then((r) => r.data),
|
||||||
|
delete: (id) => api.delete(`/animals/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Daily Statuses ─────────────────────────────────────────────────────────────
|
||||||
|
export const dailyStatusesApi = {
|
||||||
|
list: (animalId) =>
|
||||||
|
api.get('/daily-statuses', { params: { animalId } }).then((r) => r.data),
|
||||||
|
get: (id) => api.get(`/daily-statuses/${id}`).then((r) => r.data),
|
||||||
|
create: (data) => api.post('/daily-statuses', data).then((r) => r.data),
|
||||||
|
update: (id, data) => api.put(`/daily-statuses/${id}`, data).then((r) => r.data),
|
||||||
|
delete: (id) => api.delete(`/daily-statuses/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Audit Logs ────────────────────────────────────────────────────────────────
|
||||||
|
export const auditLogsApi = {
|
||||||
|
list: (params) => api.get('/audit-logs', { params }).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import FormField, { Input } from './ui/FormField';
|
||||||
|
import Alert from './ui/Alert';
|
||||||
|
import { animalsApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function AnimalForm({ existing, experimentId, onSuccess, onCancel }) {
|
||||||
|
const [fields, setFields] = useState({
|
||||||
|
animal_id_string: existing?.animal_id_string ?? '',
|
||||||
|
animal_name: existing?.animal_name ?? '',
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [apiError, setApiError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFields({
|
||||||
|
animal_id_string: existing?.animal_id_string ?? '',
|
||||||
|
animal_name: existing?.animal_name ?? '',
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setApiError(null);
|
||||||
|
}, [existing]);
|
||||||
|
|
||||||
|
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const e = {};
|
||||||
|
if (!fields.animal_id_string.trim()) e.animal_id_string = 'Animal ID is required';
|
||||||
|
if (!fields.animal_name.trim()) e.animal_name = 'Animal name is required';
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
animal_id_string: fields.animal_id_string.trim(),
|
||||||
|
animal_name: fields.animal_name.trim(),
|
||||||
|
};
|
||||||
|
const result = existing
|
||||||
|
? await animalsApi.update(existing.id, payload)
|
||||||
|
: await animalsApi.create({ ...payload, experiment_id: experimentId });
|
||||||
|
onSuccess(result);
|
||||||
|
} catch (err) {
|
||||||
|
setApiError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
|
{apiError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={apiError.message}
|
||||||
|
details={apiError.details}
|
||||||
|
onDismiss={() => setApiError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField label="Animal ID" name="animal_id_string" error={errors.animal_id_string} required>
|
||||||
|
<Input
|
||||||
|
name="animal_id_string"
|
||||||
|
value={fields.animal_id_string}
|
||||||
|
onChange={set('animal_id_string')}
|
||||||
|
placeholder="e.g. M-001"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Animal Name" name="animal_name" error={errors.animal_name} required>
|
||||||
|
<Input
|
||||||
|
name="animal_name"
|
||||||
|
value={fields.animal_name}
|
||||||
|
onChange={set('animal_name')}
|
||||||
|
placeholder="e.g. Whiskers"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={loading}>
|
||||||
|
{existing ? 'Save Changes' : 'Add Animal'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { auditLogsApi } from '../api/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
function DiffView({ changes }) {
|
||||||
|
const { before, after } = changes || {};
|
||||||
|
if (!before && after) {
|
||||||
|
return <span className="text-green-700 text-xs">Created: {JSON.stringify(after, null, 2)}</span>;
|
||||||
|
}
|
||||||
|
if (before && !after) {
|
||||||
|
return <span className="text-red-700 text-xs">Deleted record</span>;
|
||||||
|
}
|
||||||
|
if (before && after) {
|
||||||
|
const keys = Object.keys({ ...before, ...after }).filter(
|
||||||
|
(k) => JSON.stringify(before[k]) !== JSON.stringify(after[k])
|
||||||
|
);
|
||||||
|
if (keys.length === 0) return <span className="text-gray-400 text-xs">No field changes detected</span>;
|
||||||
|
return (
|
||||||
|
<dl className="text-xs space-y-1">
|
||||||
|
{keys.map((k) => (
|
||||||
|
<div key={k} className="grid grid-cols-3 gap-2">
|
||||||
|
<dt className="font-medium text-gray-600">{k}</dt>
|
||||||
|
<dd className="text-red-600 line-through col-span-1 truncate">{String(before[k] ?? '')}</dd>
|
||||||
|
<dd className="text-green-700 col-span-1 truncate">{String(after[k] ?? '')}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_BADGE = {
|
||||||
|
CREATE: 'bg-green-100 text-green-800',
|
||||||
|
UPDATE: 'bg-yellow-100 text-yellow-800',
|
||||||
|
DELETE: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuditLogSection({ tableName, recordId }) {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
auditLogsApi
|
||||||
|
.list({ tableName, recordId, limit: 50 })
|
||||||
|
.then(({ logs, total }) => {
|
||||||
|
setLogs(logs);
|
||||||
|
setTotal(total);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [tableName, recordId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Modification History" className="mt-8">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Modification History
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="text-xs font-normal text-gray-500">({total} entries)</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-gray-500">Loading history…</p>}
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
{!loading && !error && logs.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400 italic">No modification history yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div key={log.id} className="bg-white">
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
|
||||||
|
onClick={() => setExpanded(expanded === log.id ? null : log.id)}
|
||||||
|
aria-expanded={expanded === log.id}
|
||||||
|
>
|
||||||
|
<span className={`shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${ACTION_BADGE[log.action] || 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{log.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 shrink-0">
|
||||||
|
{format(new Date(log.timestamp), 'yyyy-MM-dd HH:mm:ss')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 truncate flex-1">
|
||||||
|
{log.table_name} / {log.record_id}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${expanded === log.id ? 'rotate-180' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{expanded === log.id && (
|
||||||
|
<div className="px-4 pb-3 bg-gray-50 border-t border-gray-100">
|
||||||
|
<DiffView changes={log.changes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import FormField, { Input, Textarea } from './ui/FormField';
|
||||||
|
import Alert from './ui/Alert';
|
||||||
|
import { dailyStatusesApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function DailyStatusForm({ existing, animalId, onSuccess, onCancel }) {
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
const [fields, setFields] = useState({
|
||||||
|
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
|
||||||
|
experiment_description: existing?.experiment_description ?? '',
|
||||||
|
vitals: existing?.vitals ?? '',
|
||||||
|
treatment: existing?.treatment ?? '',
|
||||||
|
notes: existing?.notes ?? '',
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [apiError, setApiError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFields({
|
||||||
|
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
|
||||||
|
experiment_description: existing?.experiment_description ?? '',
|
||||||
|
vitals: existing?.vitals ?? '',
|
||||||
|
treatment: existing?.treatment ?? '',
|
||||||
|
notes: existing?.notes ?? '',
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setApiError(null);
|
||||||
|
}, [existing]);
|
||||||
|
|
||||||
|
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const e = {};
|
||||||
|
if (!fields.date) {
|
||||||
|
e.date = 'Date is required';
|
||||||
|
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) {
|
||||||
|
e.date = 'Date must be in YYYY-MM-DD format';
|
||||||
|
} else {
|
||||||
|
const d = new Date(fields.date);
|
||||||
|
if (isNaN(d.getTime())) e.date = 'Invalid date';
|
||||||
|
}
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
date: fields.date,
|
||||||
|
experiment_description: fields.experiment_description || null,
|
||||||
|
vitals: fields.vitals || null,
|
||||||
|
treatment: fields.treatment || null,
|
||||||
|
notes: fields.notes || null,
|
||||||
|
};
|
||||||
|
const result = existing
|
||||||
|
? await dailyStatusesApi.update(existing.id, payload)
|
||||||
|
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
|
||||||
|
onSuccess(result);
|
||||||
|
} catch (err) {
|
||||||
|
setApiError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
|
{apiError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={apiError.message}
|
||||||
|
details={apiError.details}
|
||||||
|
onDismiss={() => setApiError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField label="Date" name="date" error={errors.date} required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
name="date"
|
||||||
|
value={fields.date}
|
||||||
|
onChange={set('date')}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Experiment Description" name="experiment_description">
|
||||||
|
<Textarea
|
||||||
|
name="experiment_description"
|
||||||
|
value={fields.experiment_description}
|
||||||
|
onChange={set('experiment_description')}
|
||||||
|
placeholder="Describe the experimental conditions…"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Vitals" name="vitals">
|
||||||
|
<Input
|
||||||
|
name="vitals"
|
||||||
|
value={fields.vitals}
|
||||||
|
onChange={set('vitals')}
|
||||||
|
placeholder="e.g. HR 72bpm, Temp 37.2°C, Weight 280g"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Treatment" name="treatment">
|
||||||
|
<Input
|
||||||
|
name="treatment"
|
||||||
|
value={fields.treatment}
|
||||||
|
onChange={set('treatment')}
|
||||||
|
placeholder="e.g. Drug X — 10mg/kg oral"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Notes" name="notes">
|
||||||
|
<Textarea
|
||||||
|
name="notes"
|
||||||
|
value={fields.notes}
|
||||||
|
onChange={set('notes')}
|
||||||
|
placeholder="Any additional observations…"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={loading}>
|
||||||
|
{existing ? 'Save Changes' : 'Log Status'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import FormField, { Input } from './ui/FormField';
|
||||||
|
import Alert from './ui/Alert';
|
||||||
|
import { experimentsApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function ExperimentForm({ existing, onSuccess, onCancel }) {
|
||||||
|
const [title, setTitle] = useState(existing?.title ?? '');
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [apiError, setApiError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(existing?.title ?? '');
|
||||||
|
setErrors({});
|
||||||
|
setApiError(null);
|
||||||
|
}, [existing]);
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const e = {};
|
||||||
|
if (!title.trim()) e.title = 'Title is required';
|
||||||
|
else if (title.trim().length > 255) e.title = 'Title must be 255 characters or fewer';
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const result = existing
|
||||||
|
? await experimentsApi.update(existing.id, { title: title.trim() })
|
||||||
|
: await experimentsApi.create({ title: title.trim() });
|
||||||
|
onSuccess(result);
|
||||||
|
} catch (err) {
|
||||||
|
setApiError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
|
{apiError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={apiError.message}
|
||||||
|
details={apiError.details}
|
||||||
|
onDismiss={() => setApiError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField label="Experiment Title" name="title" error={errors.title} required>
|
||||||
|
<Input
|
||||||
|
name="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Drug Efficacy Study – Q3"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={loading}>
|
||||||
|
{existing ? 'Save Changes' : 'Create Experiment'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
error: 'bg-red-50 border-red-200 text-red-800',
|
||||||
|
success: 'bg-green-50 border-green-200 text-green-800',
|
||||||
|
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||||
|
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Alert({ type = 'error', message, details, onDismiss }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return (
|
||||||
|
<div role="alert" className={`rounded-md border px-4 py-3 text-sm ${styles[type]}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{message}</p>
|
||||||
|
{details && (
|
||||||
|
<ul className="mt-1 list-disc list-inside space-y-0.5 text-xs opacity-80">
|
||||||
|
{details.map((d, i) => (
|
||||||
|
<li key={i}>{d.field ? `${d.field}: ${d.message}` : d.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button onClick={onDismiss} className="shrink-0 opacity-60 hover:opacity-100" aria-label="Dismiss">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-blue-600 hover:bg-blue-700 text-white border-transparent',
|
||||||
|
secondary: 'bg-white hover:bg-gray-50 text-gray-700 border-gray-300',
|
||||||
|
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
|
||||||
|
ghost: 'bg-transparent hover:bg-gray-100 text-gray-600 border-transparent',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-6 py-3 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
children,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
onClick,
|
||||||
|
type = 'button',
|
||||||
|
className = '',
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5 rounded-md border font-medium
|
||||||
|
transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
${variants[variant]} ${sizes[size]} ${className}
|
||||||
|
`}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
|
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||||
|
<p className="text-sm text-gray-600 mb-6">{message}</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={onConfirm} loading={loading}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function FormField({ label, name, error, required, children }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={name} className="text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1" aria-hidden="true">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ id, name, type = 'text', value, onChange, placeholder, required, disabled, className = '', ...rest }) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id || name}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({ id, name, value, onChange, placeholder, rows = 3, disabled, className = '' }) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={id || name}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed resize-y
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function Modal({ isOpen, onClose, title, children, size = 'md' }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => document.removeEventListener('keydown', handleKey);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
>
|
||||||
|
<div className={`bg-white rounded-xl shadow-2xl w-full mx-4 ${widths[size]} max-h-[90vh] flex flex-col`}>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 rounded p-1 transition-colors"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900 antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_URL || 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./tests/setup.js'],
|
||||||
|
css: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user