merge: daily record template feature into main
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add template field to experiments (JSON array of field definitions)
|
||||||
|
ALTER TABLE "experiments" ADD COLUMN "template" JSONB NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
-- Add custom_fields to daily_statuses (stores values for non-builtin fields)
|
||||||
|
ALTER TABLE "daily_statuses" ADD COLUMN "custom_fields" JSONB;
|
||||||
@@ -12,6 +12,7 @@ model Experiment {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
|
template Json @default("[]")
|
||||||
animals Animal[]
|
animals Animal[]
|
||||||
|
|
||||||
@@map("experiments")
|
@@map("experiments")
|
||||||
@@ -36,6 +37,7 @@ model DailyStatus {
|
|||||||
vitals String?
|
vitals String?
|
||||||
treatment String?
|
treatment String?
|
||||||
notes String?
|
notes String?
|
||||||
|
custom_fields Json?
|
||||||
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
|
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("daily_statuses")
|
@@map("daily_statuses")
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* The default daily-record template applied to every new experiment
|
||||||
|
* (and returned as a fallback when an experiment's template is empty).
|
||||||
|
*
|
||||||
|
* `builtin: true` — value lives in the named column of daily_statuses
|
||||||
|
* `builtin: false` — value lives in daily_statuses.custom_fields JSON
|
||||||
|
* `active: false` — field is hidden (not deleted; data preserved)
|
||||||
|
*/
|
||||||
|
const DEFAULT_TEMPLATE = [
|
||||||
|
{ key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
|
||||||
|
{ key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
|
||||||
|
{ key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
|
||||||
|
{ key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective template for an experiment.
|
||||||
|
* If the stored template is empty (new experiment), return the default.
|
||||||
|
*/
|
||||||
|
function resolveTemplate(stored) {
|
||||||
|
if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_TEMPLATE;
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { DEFAULT_TEMPLATE, resolveTemplate };
|
||||||
@@ -11,6 +11,7 @@ const statusValidation = [
|
|||||||
body('vitals').optional().isString(),
|
body('vitals').optional().isString(),
|
||||||
body('treatment').optional().isString(),
|
body('treatment').optional().isString(),
|
||||||
body('notes').optional().isString(),
|
body('notes').optional().isString(),
|
||||||
|
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusUpdateValidation = [
|
const statusUpdateValidation = [
|
||||||
@@ -19,9 +20,10 @@ const statusUpdateValidation = [
|
|||||||
body('vitals').optional().isString(),
|
body('vitals').optional().isString(),
|
||||||
body('treatment').optional().isString(),
|
body('treatment').optional().isString(),
|
||||||
body('notes').optional().isString(),
|
body('notes').optional().isString(),
|
||||||
|
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// GET /api/daily-statuses?animalId=<uuid> — list for animal
|
// GET /api/daily-statuses?animalId=<uuid>
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
[query('animalId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animalId must be a valid UUID')],
|
[query('animalId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animalId must be a valid UUID')],
|
||||||
@@ -35,13 +37,11 @@ router.get(
|
|||||||
include: { animal: { select: { animal_name: true, animal_id_string: true } } },
|
include: { animal: { select: { animal_name: true, animal_id_string: true } } },
|
||||||
});
|
});
|
||||||
res.json(statuses);
|
res.json(statuses);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET /api/daily-statuses/:id — get one
|
// GET /api/daily-statuses/:id
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const status = await prisma.dailyStatus.findUnique({
|
const status = await prisma.dailyStatus.findUnique({
|
||||||
@@ -50,15 +50,13 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
if (!status) return res.status(404).json({ error: 'Daily status not found' });
|
if (!status) return res.status(404).json({ error: 'Daily status not found' });
|
||||||
res.json(status);
|
res.json(status);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/daily-statuses — create
|
// POST /api/daily-statuses
|
||||||
router.post('/', statusValidation, validateRequest, async (req, res, next) => {
|
router.post('/', statusValidation, validateRequest, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { animal_id, date, experiment_description, vitals, treatment, notes } = req.body;
|
const { animal_id, date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
|
||||||
const status = await prisma.dailyStatus.create({
|
const status = await prisma.dailyStatus.create({
|
||||||
data: {
|
data: {
|
||||||
animal_id,
|
animal_id,
|
||||||
@@ -67,25 +65,17 @@ router.post('/', statusValidation, validateRequest, async (req, res, next) => {
|
|||||||
vitals,
|
vitals,
|
||||||
treatment,
|
treatment,
|
||||||
notes,
|
notes,
|
||||||
|
custom_fields: custom_fields ?? {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: { table_name: 'daily_statuses', record_id: status.id, action: 'CREATE', changes: { before: null, after: status } },
|
||||||
table_name: 'daily_statuses',
|
|
||||||
record_id: status.id,
|
|
||||||
action: 'CREATE',
|
|
||||||
changes: { before: null, after: status },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(status);
|
res.status(201).json(status);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/daily-statuses/:id — update
|
// PUT /api/daily-statuses/:id
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
auditMiddleware('daily_statuses', prisma.dailyStatus),
|
auditMiddleware('daily_statuses', prisma.dailyStatus),
|
||||||
@@ -93,26 +83,22 @@ router.put(
|
|||||||
validateRequest,
|
validateRequest,
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { date, experiment_description, vitals, treatment, notes } = req.body;
|
const { date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
|
||||||
const data = {};
|
const data = {};
|
||||||
if (date !== undefined) data.date = new Date(date);
|
if (date !== undefined) data.date = new Date(date);
|
||||||
if (experiment_description !== undefined) data.experiment_description = experiment_description;
|
if (experiment_description !== undefined) data.experiment_description = experiment_description;
|
||||||
if (vitals !== undefined) data.vitals = vitals;
|
if (vitals !== undefined) data.vitals = vitals;
|
||||||
if (treatment !== undefined) data.treatment = treatment;
|
if (treatment !== undefined) data.treatment = treatment;
|
||||||
if (notes !== undefined) data.notes = notes;
|
if (notes !== undefined) data.notes = notes;
|
||||||
|
if (custom_fields !== undefined) data.custom_fields = custom_fields;
|
||||||
|
|
||||||
const status = await prisma.dailyStatus.update({
|
const status = await prisma.dailyStatus.update({ where: { id: req.params.id }, data });
|
||||||
where: { id: req.params.id },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
res.json(status);
|
res.json(status);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /api/daily-statuses/:id — delete
|
// DELETE /api/daily-statuses/:id
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
auditMiddleware('daily_statuses', prisma.dailyStatus),
|
auditMiddleware('daily_statuses', prisma.dailyStatus),
|
||||||
@@ -120,9 +106,7 @@ router.delete(
|
|||||||
try {
|
try {
|
||||||
await prisma.dailyStatus.delete({ where: { id: req.params.id } });
|
await prisma.dailyStatus.delete({ where: { id: req.params.id } });
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,49 @@ const { body } = require('express-validator');
|
|||||||
const prisma = require('../lib/prisma');
|
const prisma = require('../lib/prisma');
|
||||||
const auditMiddleware = require('../middleware/auditMiddleware');
|
const auditMiddleware = require('../middleware/auditMiddleware');
|
||||||
const { validateRequest } = require('../middleware/errorHandler');
|
const { validateRequest } = require('../middleware/errorHandler');
|
||||||
|
const { resolveTemplate, DEFAULT_TEMPLATE } = require('../lib/defaultTemplate');
|
||||||
|
|
||||||
const experimentValidation = [
|
const experimentValidation = [
|
||||||
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
|
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// GET /api/experiments — list all
|
// ── Template validation helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BUILTIN_KEYS = new Set(DEFAULT_TEMPLATE.map((f) => f.key));
|
||||||
|
const VALID_TYPES = new Set(['text', 'textarea']);
|
||||||
|
|
||||||
|
function validateTemplate(template) {
|
||||||
|
if (!Array.isArray(template)) return 'template must be an array';
|
||||||
|
if (template.length > 50) return 'template may have at most 50 fields';
|
||||||
|
|
||||||
|
const keys = new Set();
|
||||||
|
for (const field of template) {
|
||||||
|
if (!field.key || typeof field.key !== 'string') return 'each field must have a string key';
|
||||||
|
if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`;
|
||||||
|
if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`;
|
||||||
|
if (keys.has(field.key)) return `duplicate key "${field.key}"`;
|
||||||
|
keys.add(field.key);
|
||||||
|
|
||||||
|
if (!field.label || typeof field.label !== 'string' || !field.label.trim())
|
||||||
|
return `field "${field.key}" must have a non-empty label`;
|
||||||
|
if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`;
|
||||||
|
|
||||||
|
if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`;
|
||||||
|
|
||||||
|
if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`;
|
||||||
|
|
||||||
|
// Builtin keys cannot change their builtin status
|
||||||
|
if (BUILTIN_KEYS.has(field.key) && field.builtin !== true)
|
||||||
|
return `field "${field.key}" is a builtin field and cannot be made non-builtin`;
|
||||||
|
if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false)
|
||||||
|
return `custom field "${field.key}" must have builtin: false`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Experiment CRUD ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/experiments
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const experiments = await prisma.experiment.findMany({
|
const experiments = await prisma.experiment.findMany({
|
||||||
@@ -16,12 +53,10 @@ router.get('/', async (req, res, next) => {
|
|||||||
include: { _count: { select: { animals: true } } },
|
include: { _count: { select: { animals: true } } },
|
||||||
});
|
});
|
||||||
res.json(experiments);
|
res.json(experiments);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/experiments/:id — get one with animals
|
// GET /api/experiments/:id
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const experiment = await prisma.experiment.findUnique({
|
const experiment = await prisma.experiment.findUnique({
|
||||||
@@ -29,35 +64,26 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
include: { animals: true },
|
include: { animals: true },
|
||||||
});
|
});
|
||||||
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
||||||
|
// Always return resolved template (never raw [])
|
||||||
|
experiment.template = resolveTemplate(experiment.template);
|
||||||
res.json(experiment);
|
res.json(experiment);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/experiments — create
|
// POST /api/experiments
|
||||||
router.post('/', experimentValidation, validateRequest, async (req, res, next) => {
|
router.post('/', experimentValidation, validateRequest, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { title } = req.body;
|
const { title } = req.body;
|
||||||
const experiment = await prisma.experiment.create({ data: { title } });
|
const experiment = await prisma.experiment.create({ data: { title } });
|
||||||
|
|
||||||
// Write CREATE audit log
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: { table_name: 'experiments', record_id: experiment.id, action: 'CREATE', changes: { before: null, after: experiment } },
|
||||||
table_name: 'experiments',
|
|
||||||
record_id: experiment.id,
|
|
||||||
action: 'CREATE',
|
|
||||||
changes: { before: null, after: experiment },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
experiment.template = resolveTemplate(experiment.template);
|
||||||
res.status(201).json(experiment);
|
res.status(201).json(experiment);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/experiments/:id — update
|
// PUT /api/experiments/:id
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
auditMiddleware('experiments', prisma.experiment),
|
auditMiddleware('experiments', prisma.experiment),
|
||||||
@@ -66,18 +92,14 @@ router.put(
|
|||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { title } = req.body;
|
const { title } = req.body;
|
||||||
const experiment = await prisma.experiment.update({
|
const experiment = await prisma.experiment.update({ where: { id: req.params.id }, data: { title } });
|
||||||
where: { id: req.params.id },
|
experiment.template = resolveTemplate(experiment.template);
|
||||||
data: { title },
|
|
||||||
});
|
|
||||||
res.json(experiment);
|
res.json(experiment);
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /api/experiments/:id — delete
|
// DELETE /api/experiments/:id
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
auditMiddleware('experiments', prisma.experiment),
|
auditMiddleware('experiments', prisma.experiment),
|
||||||
@@ -85,10 +107,51 @@ router.delete(
|
|||||||
try {
|
try {
|
||||||
await prisma.experiment.delete({ where: { id: req.params.id } });
|
await prisma.experiment.delete({ where: { id: req.params.id } });
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (err) {
|
} catch (err) { next(err); }
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Template endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/experiments/:id/template
|
||||||
|
router.get('/:id/template', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const experiment = await prisma.experiment.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: { id: true, template: true },
|
||||||
|
});
|
||||||
|
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
||||||
|
res.json(resolveTemplate(experiment.template));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/experiments/:id/template
|
||||||
|
router.put('/:id/template', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
||||||
|
|
||||||
|
const template = req.body;
|
||||||
|
const validationError = validateTemplate(template);
|
||||||
|
if (validationError) return res.status(422).json({ error: validationError });
|
||||||
|
|
||||||
|
const before = resolveTemplate(experiment.template);
|
||||||
|
const updated = await prisma.experiment.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { template },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
table_name: 'experiments',
|
||||||
|
record_id: experiment.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
changes: { before: { template: before }, after: { template } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(template);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const experimentsApi = {
|
|||||||
create: (data) => api.post('/experiments', data).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),
|
update: (id, data) => api.put(`/experiments/${id}`, data).then((r) => r.data),
|
||||||
delete: (id) => api.delete(`/experiments/${id}`),
|
delete: (id) => api.delete(`/experiments/${id}`),
|
||||||
|
getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data),
|
||||||
|
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Animals ───────────────────────────────────────────────────────────────────
|
// ── Animals ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,42 +5,48 @@ import FormField, { Input, Textarea } from './ui/FormField';
|
|||||||
import Alert from './ui/Alert';
|
import Alert from './ui/Alert';
|
||||||
import { dailyStatusesApi } from '../api/client';
|
import { dailyStatusesApi } from '../api/client';
|
||||||
|
|
||||||
export default function DailyStatusForm({ existing, animalId, onSuccess, onCancel }) {
|
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||||
|
|
||||||
|
/** Extract the value for a field from a status record */
|
||||||
|
function getValue(status, field) {
|
||||||
|
if (!status) return '';
|
||||||
|
if (field.builtin) return status[field.key] ?? '';
|
||||||
|
return status.custom_fields?.[field.key] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) {
|
||||||
const today = format(new Date(), 'yyyy-MM-dd');
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
const [fields, setFields] = useState({
|
|
||||||
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
|
// Active fields from template (date is always first and handled separately)
|
||||||
experiment_description: existing?.experiment_description ?? '',
|
const activeFields = template.filter((f) => f.active);
|
||||||
vitals: existing?.vitals ?? '',
|
|
||||||
treatment: existing?.treatment ?? '',
|
function buildInitialValues() {
|
||||||
notes: existing?.notes ?? '',
|
const vals = { date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today };
|
||||||
});
|
for (const f of activeFields) {
|
||||||
|
vals[f.key] = getValue(existing, f);
|
||||||
|
}
|
||||||
|
return vals;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [values, setValues] = useState(buildInitialValues);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [apiError, setApiError] = useState(null);
|
const [apiError, setApiError] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFields({
|
setValues(buildInitialValues());
|
||||||
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({});
|
setErrors({});
|
||||||
setApiError(null);
|
setApiError(null);
|
||||||
}, [existing]);
|
}, [existing, template]);
|
||||||
|
|
||||||
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
|
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
const e = {};
|
const e = {};
|
||||||
if (!fields.date) {
|
if (!values.date) {
|
||||||
e.date = 'Date is required';
|
e.date = 'Date is required';
|
||||||
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) {
|
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(values.date) || isNaN(new Date(values.date).getTime())) {
|
||||||
e.date = 'Date must be in YYYY-MM-DD format';
|
e.date = 'Date must be a valid YYYY-MM-DD date';
|
||||||
} else {
|
|
||||||
const d = new Date(fields.date);
|
|
||||||
if (isNaN(d.getTime())) e.date = 'Invalid date';
|
|
||||||
}
|
}
|
||||||
setErrors(e);
|
setErrors(e);
|
||||||
return Object.keys(e).length === 0;
|
return Object.keys(e).length === 0;
|
||||||
@@ -52,16 +58,26 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setApiError(null);
|
setApiError(null);
|
||||||
try {
|
try {
|
||||||
const payload = {
|
// Separate builtin values from custom_fields
|
||||||
date: fields.date,
|
const builtin = {};
|
||||||
experiment_description: fields.experiment_description || null,
|
const custom = {};
|
||||||
vitals: fields.vitals || null,
|
for (const f of activeFields) {
|
||||||
treatment: fields.treatment || null,
|
const val = values[f.key] || null;
|
||||||
notes: fields.notes || null,
|
if (f.builtin) builtin[f.key] = val;
|
||||||
};
|
else custom[f.key] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with any pre-existing custom_fields not in current active template
|
||||||
|
// (so we don't wipe data for fields that were hidden after the entry was made)
|
||||||
|
const existingCustom = existing?.custom_fields ?? {};
|
||||||
|
const mergedCustom = { ...existingCustom, ...custom };
|
||||||
|
|
||||||
|
const payload = { date: values.date, ...builtin, custom_fields: mergedCustom };
|
||||||
|
|
||||||
const result = existing
|
const result = existing
|
||||||
? await dailyStatusesApi.update(existing.id, payload)
|
? await dailyStatusesApi.update(existing.id, payload)
|
||||||
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
|
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
|
||||||
|
|
||||||
onSuccess(result);
|
onSuccess(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setApiError(err);
|
setApiError(err);
|
||||||
@@ -73,66 +89,44 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
{apiError && (
|
{apiError && (
|
||||||
<Alert
|
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
|
||||||
type="error"
|
)}
|
||||||
message={apiError.message}
|
|
||||||
details={apiError.details}
|
{/* Date — always present */}
|
||||||
onDismiss={() => setApiError(null)}
|
<FormField label="Date" name="date" error={errors.date} required>
|
||||||
|
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{/* Dynamic fields from template */}
|
||||||
|
{activeFields.map((field) => (
|
||||||
|
<FormField key={field.key} label={field.label} name={field.key}>
|
||||||
|
{field.type === 'textarea' ? (
|
||||||
|
<Textarea
|
||||||
|
name={field.key}
|
||||||
|
value={values[field.key] ?? ''}
|
||||||
|
onChange={set(field.key)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
name={field.key}
|
||||||
|
value={values[field.key] ?? ''}
|
||||||
|
onChange={set(field.key)}
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<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>
|
</FormField>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{activeFields.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400 italic text-center py-2">
|
||||||
|
No fields configured. Edit the experiment's Record Template to add fields.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||||
Cancel
|
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={loading}>
|
|
||||||
{existing ? 'Save Changes' : 'Log Status'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { experimentsApi } from '../api/client';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import Alert from './ui/Alert';
|
||||||
|
|
||||||
|
// Slugify a label into a valid field key
|
||||||
|
function toKey(label) {
|
||||||
|
return label
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
|
||||||
|
const [labelDraft, setLabelDraft] = useState(field.label);
|
||||||
|
const [labelError, setLabelError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { setLabelDraft(field.label); }, [field.label]);
|
||||||
|
|
||||||
|
function commitLabel() {
|
||||||
|
const trimmed = labelDraft.trim();
|
||||||
|
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
|
||||||
|
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
|
||||||
|
|
||||||
|
// For custom fields, also ensure the derived key is unique
|
||||||
|
if (!field.builtin) {
|
||||||
|
const newKey = toKey(trimmed);
|
||||||
|
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
|
||||||
|
const duplicate = allKeys.find((k) => k !== field.key && k === newKey);
|
||||||
|
if (duplicate) { setLabelError('A field with this name already exists'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
setLabelError('');
|
||||||
|
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${field.active ? 'bg-white border-gray-200' : 'bg-gray-50 border-gray-100 opacity-60'}`}>
|
||||||
|
{/* Drag handle placeholder (visual only) */}
|
||||||
|
<span className="text-gray-300 cursor-default select-none text-lg leading-none">⠿</span>
|
||||||
|
|
||||||
|
{/* Label input */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
aria-label={`Field label for ${field.key}`}
|
||||||
|
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 ${labelError ? 'border-red-400' : 'border-gray-300'} ${!field.active ? 'bg-gray-100' : 'bg-white'}`}
|
||||||
|
value={labelDraft}
|
||||||
|
disabled={!field.active}
|
||||||
|
onChange={(e) => { setLabelDraft(e.target.value); setLabelError(''); }}
|
||||||
|
onBlur={commitLabel}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
|
||||||
|
/>
|
||||||
|
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5 font-mono">key: {field.key}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type badge */}
|
||||||
|
<select
|
||||||
|
aria-label={`Field type for ${field.key}`}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
||||||
|
value={field.type}
|
||||||
|
disabled={!field.active}
|
||||||
|
onChange={(e) => onChange({ ...field, type: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="text">Short text</option>
|
||||||
|
<option value="textarea">Long text</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Toggle active */}
|
||||||
|
<button
|
||||||
|
title={field.active ? 'Hide field' : 'Show field'}
|
||||||
|
aria-label={field.active ? `Hide field ${field.label}` : `Show field ${field.label}`}
|
||||||
|
onClick={() => onToggle(field.key)}
|
||||||
|
className={`text-sm px-2 py-1 rounded border transition-colors ${
|
||||||
|
field.active
|
||||||
|
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
|
||||||
|
: 'border-green-200 text-green-600 hover:bg-green-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{field.active ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Remove — only for custom fields */}
|
||||||
|
{!field.builtin && (
|
||||||
|
<button
|
||||||
|
title="Remove field permanently"
|
||||||
|
aria-label={`Remove field ${field.label}`}
|
||||||
|
onClick={() => onRemove(field.key)}
|
||||||
|
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TemplateEditor({ experimentId, onClose }) {
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [newLabel, setNewLabel] = useState('');
|
||||||
|
const [newType, setNewType] = useState('text');
|
||||||
|
const [addError, setAddError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
experimentsApi.getTemplate(experimentId)
|
||||||
|
.then(setFields)
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [experimentId]);
|
||||||
|
|
||||||
|
function updateField(updated) {
|
||||||
|
setFields((prev) => prev.map((f) => f.key === updated.key ? updated : f));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleField(key) {
|
||||||
|
setFields((prev) => prev.map((f) => f.key === key ? { ...f, active: !f.active } : f));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(key) {
|
||||||
|
setFields((prev) => prev.filter((f) => f.key !== key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addField() {
|
||||||
|
const trimmed = newLabel.trim();
|
||||||
|
if (!trimmed) { setAddError('Field name is required'); return; }
|
||||||
|
const key = toKey(trimmed);
|
||||||
|
if (!key) { setAddError('Name must contain at least one alphanumeric character'); return; }
|
||||||
|
if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
|
||||||
|
setAddError('');
|
||||||
|
setFields((prev) => [...prev, { key, label: trimmed, type: newType, builtin: false, active: true }]);
|
||||||
|
setNewLabel('');
|
||||||
|
setNewType('text');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await experimentsApi.updateTemplate(experimentId, fields);
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => { setSuccess(false); onClose(fields); }, 800);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allKeys = fields.map((f) => f.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loading && <p className="text-sm text-gray-400">Loading template…</p>}
|
||||||
|
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||||
|
{success && <Alert type="success" message="Template saved!" />}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
{/* Field list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fields.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
|
||||||
|
)}
|
||||||
|
{fields.map((field) => (
|
||||||
|
<FieldRow
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
onChange={updateField}
|
||||||
|
onToggle={toggleField}
|
||||||
|
onRemove={removeField}
|
||||||
|
allKeys={allKeys}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add new field */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Add New Field</p>
|
||||||
|
<div className="flex gap-2 items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
aria-label="New field name"
|
||||||
|
className={`w-full text-sm rounded border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 ${addError ? 'border-red-400' : 'border-gray-300'}`}
|
||||||
|
placeholder="Field name, e.g. Blood Pressure"
|
||||||
|
value={newLabel}
|
||||||
|
onChange={(e) => { setNewLabel(e.target.value); setAddError(''); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addField(); } }}
|
||||||
|
/>
|
||||||
|
{addError && <p className="text-xs text-red-500 mt-0.5">{addError}</p>}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
aria-label="New field type"
|
||||||
|
className="text-sm border border-gray-300 rounded px-2 py-2 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||||
|
value={newType}
|
||||||
|
onChange={(e) => setNewType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="text">Short text</option>
|
||||||
|
<option value="textarea">Long text</option>
|
||||||
|
</select>
|
||||||
|
<Button size="sm" onClick={addField} type="button">+ Add</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info note */}
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
<strong>Hide</strong> removes a builtin field from forms while preserving its data.
|
||||||
|
<strong className="ml-1">✕</strong> permanently removes a custom field (data in existing records is not deleted).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 border-t pt-4">
|
||||||
|
<Button variant="secondary" onClick={() => onClose(null)} disabled={saving}>Cancel</Button>
|
||||||
|
<Button onClick={save} loading={saving}>Save Template</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||||
import { animalsApi, dailyStatusesApi } from '../api/client';
|
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
@@ -9,12 +9,20 @@ import DailyStatusForm from '../components/DailyStatusForm';
|
|||||||
import Alert from '../components/ui/Alert';
|
import Alert from '../components/ui/Alert';
|
||||||
import AuditLogSection from '../components/AuditLogSection';
|
import AuditLogSection from '../components/AuditLogSection';
|
||||||
|
|
||||||
|
/** Render the value for a template field from a status record */
|
||||||
|
function getFieldValue(status, field) {
|
||||||
|
if (field.builtin) return status[field.key];
|
||||||
|
return status.custom_fields?.[field.key];
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnimalDetail() {
|
export default function AnimalDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const [animal, setAnimal] = useState(null);
|
const [animal, setAnimal] = useState(null);
|
||||||
const [statuses, setStatuses] = useState([]);
|
const [statuses, setStatuses] = useState([]);
|
||||||
|
const [template, setTemplate] = useState(location.state?.template ?? []);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [successMsg, setSuccessMsg] = useState(null);
|
const [successMsg, setSuccessMsg] = useState(null);
|
||||||
@@ -34,6 +42,12 @@ export default function AnimalDetail() {
|
|||||||
]);
|
]);
|
||||||
setAnimal(animalData);
|
setAnimal(animalData);
|
||||||
setStatuses(statusData);
|
setStatuses(statusData);
|
||||||
|
|
||||||
|
// Fetch template if not passed via navigation state
|
||||||
|
if (!location.state?.template) {
|
||||||
|
const tmpl = await experimentsApi.getTemplate(animalData.experiment_id);
|
||||||
|
setTemplate(tmpl);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -70,7 +84,7 @@ export default function AnimalDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading…</div>;
|
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
<Alert type="error" message={error} />
|
<Alert type="error" message={error} />
|
||||||
@@ -78,15 +92,15 @@ export default function AnimalDetail() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activeFields = template.filter((f) => f.active);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="text-sm text-gray-500 mb-4">
|
<nav className="text-sm text-gray-500 mb-4">
|
||||||
<Link to="/" className="hover:text-blue-600">Experiments</Link>
|
<Link to="/" className="hover:text-blue-600">Experiments</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">
|
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
|
||||||
Experiment
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<span className="text-gray-900 font-medium">{animal.animal_name}</span>
|
<span className="text-gray-900 font-medium">{animal.animal_name}</span>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -124,22 +138,40 @@ export default function AnimalDetail() {
|
|||||||
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
|
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeFields.length > 0 ? (
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
{activeFields
|
||||||
|
.map((f) => ({ field: f, value: getFieldValue(s, f) }))
|
||||||
|
.filter(({ value }) => value)
|
||||||
|
.map(({ field, value }) => (
|
||||||
|
<div key={field.key} className={field.type === 'textarea' ? 'sm:col-span-2' : ''}>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{field.label}</dt>
|
||||||
|
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Fallback: show raw builtin fields if template is empty */}
|
||||||
|
{activeFields.length === 0 && (
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
{[
|
{[
|
||||||
['Experiment Description', s.experiment_description],
|
['Experiment Description', s.experiment_description],
|
||||||
['Vitals', s.vitals],
|
['Vitals', s.vitals],
|
||||||
['Treatment', s.treatment],
|
['Treatment', s.treatment],
|
||||||
['Notes', s.notes],
|
['Notes', s.notes],
|
||||||
]
|
].filter(([, v]) => v).map(([label, value]) => (
|
||||||
.filter(([, v]) => v)
|
<div key={label}>
|
||||||
.map(([label, value]) => (
|
|
||||||
<div key={label} className="flex flex-col">
|
|
||||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt>
|
<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>
|
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
{!s.experiment_description && !s.vitals && !s.treatment && !s.notes && (
|
)}
|
||||||
|
|
||||||
|
{activeFields.length > 0 &&
|
||||||
|
activeFields.every((f) => !getFieldValue(s, f)) && (
|
||||||
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
|
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -147,16 +179,22 @@ export default function AnimalDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AuditLogSection tableName="daily_statuses" recordId={undefined} />
|
<AuditLogSection tableName="daily_statuses" />
|
||||||
|
|
||||||
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
|
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
|
||||||
<DailyStatusForm animalId={id} onSuccess={handleStatusSaved} onCancel={() => setShowAddStatus(false)} />
|
<DailyStatusForm
|
||||||
|
animalId={id}
|
||||||
|
template={template}
|
||||||
|
onSuccess={handleStatusSaved}
|
||||||
|
onCancel={() => setShowAddStatus(false)}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
|
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
|
||||||
<DailyStatusForm
|
<DailyStatusForm
|
||||||
existing={editStatus}
|
existing={editStatus}
|
||||||
animalId={id}
|
animalId={id}
|
||||||
|
template={template}
|
||||||
onSuccess={handleStatusSaved}
|
onSuccess={handleStatusSaved}
|
||||||
onCancel={() => setEditStatus(null)}
|
onCancel={() => setEditStatus(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Button from '../components/ui/Button';
|
|||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import AnimalForm from '../components/AnimalForm';
|
import AnimalForm from '../components/AnimalForm';
|
||||||
|
import TemplateEditor from '../components/TemplateEditor';
|
||||||
import Alert from '../components/ui/Alert';
|
import Alert from '../components/ui/Alert';
|
||||||
import AuditLogSection from '../components/AuditLogSection';
|
import AuditLogSection from '../components/AuditLogSection';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export default function ExperimentDetail() {
|
|||||||
const [editAnimal, setEditAnimal] = useState(null);
|
const [editAnimal, setEditAnimal] = useState(null);
|
||||||
const [deleteAnimal, setDeleteAnimal] = useState(null);
|
const [deleteAnimal, setDeleteAnimal] = useState(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [showTemplate, setShowTemplate] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -69,7 +71,16 @@ export default function ExperimentDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading…</div>;
|
function handleTemplateSaved(updatedTemplate) {
|
||||||
|
setShowTemplate(false);
|
||||||
|
if (updatedTemplate) {
|
||||||
|
// Update local experiment state so everything downstream re-renders
|
||||||
|
setExperiment((prev) => ({ ...prev, template: updatedTemplate }));
|
||||||
|
flash('Record template updated.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
<Alert type="error" message={error} />
|
<Alert type="error" message={error} />
|
||||||
@@ -93,12 +104,44 @@ export default function ExperimentDetail() {
|
|||||||
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
|
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setShowTemplate(true)}>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
Record Template
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{successMsg && <Alert type="success" message={successMsg} />}
|
{successMsg && <Alert type="success" message={successMsg} />}
|
||||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||||
|
|
||||||
|
{/* Template summary strip */}
|
||||||
|
{experiment.template && experiment.template.length > 0 && (
|
||||||
|
<div className="mb-5 flex flex-wrap gap-1.5">
|
||||||
|
{experiment.template.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f.key}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||||
|
f.active ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-gray-100 border-gray-200 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.active ? null : <span title="hidden">◌</span>}
|
||||||
|
{f.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTemplate(true)}
|
||||||
|
className="text-xs text-gray-400 hover:text-blue-600 underline underline-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{animals.length === 0 ? (
|
{animals.length === 0 ? (
|
||||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
<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>
|
<p className="text-gray-400 mb-3">No animals enrolled in this experiment yet.</p>
|
||||||
@@ -110,7 +153,7 @@ export default function ExperimentDetail() {
|
|||||||
<div
|
<div
|
||||||
key={animal.id}
|
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"
|
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}`)}
|
onClick={() => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
||||||
@@ -129,19 +172,19 @@ export default function ExperimentDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AuditLogSection tableName="animals" recordId={undefined} />
|
<AuditLogSection tableName="animals" />
|
||||||
|
|
||||||
|
{/* Template editor modal */}
|
||||||
|
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="lg">
|
||||||
|
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
|
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
|
||||||
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
|
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
|
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
|
||||||
<AnimalForm
|
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
|
||||||
existing={editAnimal}
|
|
||||||
experimentId={id}
|
|
||||||
onSuccess={handleAnimalSaved}
|
|
||||||
onCancel={() => setEditAnimal(null)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user