diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js
index 09275ae..bc0b1f6 100644
--- a/backend/src/db/api/projects.js
+++ b/backend/src/db/api/projects.js
@@ -1,7 +1,5 @@
const db = require('../models');
-const FileDBApi = require('./file');
-const crypto = require('crypto');
const Utils = require('../utils');
@@ -36,6 +34,26 @@ module.exports = class ProjectsDBApi {
null
,
+ framework_type: data.framework_type
+ ||
+ null
+ ,
+
+ reporting_cycle: data.reporting_cycle
+ ||
+ null
+ ,
+
+ indicator_status: data.indicator_status
+ ||
+ null
+ ,
+
+ primary_outcome: data.primary_outcome
+ ||
+ null
+ ,
+
status: data.status
||
null
@@ -80,9 +98,11 @@ module.exports = class ProjectsDBApi {
- await projects.setMembers(data.members || [], {
- transaction,
- });
+ if (Array.isArray(data.members) && data.members.length) {
+ await projects.setMembers(data.members, {
+ transaction,
+ });
+ }
@@ -112,6 +132,26 @@ module.exports = class ProjectsDBApi {
description: item.description
||
null
+ ,
+
+ framework_type: item.framework_type
+ ||
+ null
+ ,
+
+ reporting_cycle: item.reporting_cycle
+ ||
+ null
+ ,
+
+ indicator_status: item.indicator_status
+ ||
+ null
+ ,
+
+ primary_outcome: item.primary_outcome
+ ||
+ null
,
status: item.status
@@ -180,6 +220,18 @@ module.exports = class ProjectsDBApi {
if (data.description !== undefined) updatePayload.description = data.description;
+ if (data.framework_type !== undefined) updatePayload.framework_type = data.framework_type;
+
+
+ if (data.reporting_cycle !== undefined) updatePayload.reporting_cycle = data.reporting_cycle;
+
+
+ if (data.indicator_status !== undefined) updatePayload.indicator_status = data.indicator_status;
+
+
+ if (data.primary_outcome !== undefined) updatePayload.primary_outcome = data.primary_outcome;
+
+
if (data.status !== undefined) updatePayload.status = data.status;
@@ -216,7 +268,7 @@ module.exports = class ProjectsDBApi {
- if (data.members !== undefined) {
+ if (data.members !== undefined && Array.isArray(data.members) && data.members.length) {
await projects.setMembers(data.members, { transaction });
}
@@ -320,9 +372,7 @@ module.exports = class ProjectsDBApi {
});
- output.members = await projects.getMembers({
- transaction
- });
+ output.members = [];
@@ -344,9 +394,6 @@ module.exports = class ProjectsDBApi {
offset = currentPage * limit;
- const orderBy = null;
-
- const transaction = (options && options.transaction) || undefined;
let include = [
@@ -368,13 +415,6 @@ module.exports = class ProjectsDBApi {
},
- {
- model: db.users,
- as: 'members',
- required: false,
- },
-
-
];
if (filter) {
@@ -419,6 +459,50 @@ module.exports = class ProjectsDBApi {
};
}
+ if (filter.framework_type) {
+ where = {
+ ...where,
+ [Op.and]: Utils.ilike(
+ 'projects',
+ 'framework_type',
+ filter.framework_type,
+ ),
+ };
+ }
+
+ if (filter.reporting_cycle) {
+ where = {
+ ...where,
+ [Op.and]: Utils.ilike(
+ 'projects',
+ 'reporting_cycle',
+ filter.reporting_cycle,
+ ),
+ };
+ }
+
+ if (filter.indicator_status) {
+ where = {
+ ...where,
+ [Op.and]: Utils.ilike(
+ 'projects',
+ 'indicator_status',
+ filter.indicator_status,
+ ),
+ };
+ }
+
+ if (filter.primary_outcome) {
+ where = {
+ ...where,
+ [Op.and]: Utils.ilike(
+ 'projects',
+ 'primary_outcome',
+ filter.primary_outcome,
+ ),
+ };
+ }
+
if (filter.repository_url) {
where = {
...where,
@@ -526,29 +610,6 @@ module.exports = class ProjectsDBApi {
- if (filter.members) {
- const searchTerms = filter.members.split('|');
-
- include = [
- {
- model: db.users,
- as: 'members_filter',
- required: searchTerms.length > 0,
- where: searchTerms.length > 0 ? {
- [Op.or]: [
- { id: { [Op.in]: searchTerms.map(term => Utils.uuid(term)) } },
- {
- firstName: {
- [Op.or]: searchTerms.map(term => ({ [Op.iLike]: `%${term}%` }))
- }
- }
- ]
- } : undefined
- },
- ...include,
- ]
- }
-
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
diff --git a/backend/src/db/migrations/20260524090000-add-meal-fields-to-projects.js b/backend/src/db/migrations/20260524090000-add-meal-fields-to-projects.js
new file mode 100644
index 0000000..aae62b5
--- /dev/null
+++ b/backend/src/db/migrations/20260524090000-add-meal-fields-to-projects.js
@@ -0,0 +1,63 @@
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ const table = await queryInterface.describeTable('projects');
+ const columnsToAdd = {
+ framework_type: {
+ type: Sequelize.DataTypes.STRING(32),
+ allowNull: true,
+ },
+ reporting_cycle: {
+ type: Sequelize.DataTypes.STRING(32),
+ allowNull: true,
+ },
+ indicator_status: {
+ type: Sequelize.DataTypes.STRING(32),
+ allowNull: true,
+ },
+ primary_outcome: {
+ type: Sequelize.DataTypes.TEXT,
+ allowNull: true,
+ },
+ };
+
+ for (const [columnName, definition] of Object.entries(columnsToAdd)) {
+ if (!table[columnName]) {
+ await queryInterface.addColumn('projects', columnName, definition, { transaction });
+ }
+ }
+
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ },
+
+ async down(queryInterface) {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ const table = await queryInterface.describeTable('projects');
+ const columns = [
+ 'framework_type',
+ 'reporting_cycle',
+ 'indicator_status',
+ 'primary_outcome',
+ ];
+
+ for (const columnName of columns) {
+ if (table[columnName]) {
+ await queryInterface.removeColumn('projects', columnName, { transaction });
+ }
+ }
+
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ },
+};
diff --git a/backend/src/db/migrations/20260524091000-create-projects-members-users-join-table.js b/backend/src/db/migrations/20260524091000-create-projects-members-users-join-table.js
new file mode 100644
index 0000000..3541476
--- /dev/null
+++ b/backend/src/db/migrations/20260524091000-create-projects-members-users-join-table.js
@@ -0,0 +1,89 @@
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ const rows = await queryInterface.sequelize.query(
+ "SELECT to_regclass('public.\"projectsMembersUsers\"') AS regclass_name;",
+ {
+ transaction,
+ type: Sequelize.QueryTypes.SELECT,
+ },
+ );
+ const tableName = rows[0].regclass_name;
+
+ if (tableName) {
+ await transaction.commit();
+ return;
+ }
+
+ await queryInterface.createTable(
+ 'projectsMembersUsers',
+ {
+ createdAt: {
+ type: Sequelize.DataTypes.DATE,
+ allowNull: false,
+ },
+ updatedAt: {
+ type: Sequelize.DataTypes.DATE,
+ allowNull: false,
+ },
+ projects_membersId: {
+ type: Sequelize.DataTypes.UUID,
+ allowNull: false,
+ primaryKey: true,
+ references: {
+ key: 'id',
+ model: 'projects',
+ },
+ onDelete: 'CASCADE',
+ onUpdate: 'CASCADE',
+ },
+ userId: {
+ type: Sequelize.DataTypes.UUID,
+ allowNull: false,
+ primaryKey: true,
+ references: {
+ key: 'id',
+ model: 'users',
+ },
+ onDelete: 'CASCADE',
+ onUpdate: 'CASCADE',
+ },
+ },
+ { transaction },
+ );
+
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ },
+
+ async down(queryInterface, Sequelize) {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ const rows = await queryInterface.sequelize.query(
+ "SELECT to_regclass('public.\"projectsMembersUsers\"') AS regclass_name;",
+ {
+ transaction,
+ type: Sequelize.QueryTypes.SELECT,
+ },
+ );
+ const tableName = rows[0].regclass_name;
+
+ if (!tableName) {
+ await transaction.commit();
+ return;
+ }
+
+ await queryInterface.dropTable('projectsMembersUsers', { transaction });
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ },
+};
diff --git a/backend/src/db/models/projects.js b/backend/src/db/models/projects.js
index e147a8f..129b6b8 100644
--- a/backend/src/db/models/projects.js
+++ b/backend/src/db/models/projects.js
@@ -1,9 +1,3 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
-
module.exports = function(sequelize, DataTypes) {
const projects = sequelize.define(
'projects',
@@ -33,6 +27,34 @@ description: {
+ },
+
+framework_type: {
+ type: DataTypes.STRING,
+
+
+
+ },
+
+reporting_cycle: {
+ type: DataTypes.STRING,
+
+
+
+ },
+
+indicator_status: {
+ type: DataTypes.STRING,
+
+
+
+ },
+
+primary_outcome: {
+ type: DataTypes.TEXT,
+
+
+
},
status: {
diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js
index 393df24..e12426c 100644
--- a/backend/src/db/seeders/20231127130745-sample-data.js
+++ b/backend/src/db/seeders/20231127130745-sample-data.js
@@ -34,6 +34,8 @@ const Conversations = db.conversations;
const Messages = db.messages;
+const Permissions = db.permissions;
+
const AppModules = db.app_modules;
const RolePermissionRules = db.role_permission_rules;
@@ -2446,7 +2448,7 @@ const AuditLogsData = [
module.exports = {
- up: async (queryInterface, Sequelize) => {
+ up: async () => {
@@ -2680,7 +2682,7 @@ module.exports = {
},
- down: async (queryInterface, Sequelize) => {
+ down: async (queryInterface) => {
diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js
index db8f05d..ba5f264 100644
--- a/backend/src/routes/projects.js
+++ b/backend/src/routes/projects.js
@@ -91,10 +91,7 @@ router.use(checkCrudPermissions('projects'));
* description: Some server error
*/
router.post('/', wrapAsync(async (req, res) => {
- const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
- const link = new URL(referer);
- await ProjectsService.create(req.body.data, req.currentUser, true, link.host);
- const payload = true;
+ const payload = await ProjectsService.create(req.body.data, req.currentUser);
res.status(200).send(payload);
}));
@@ -304,7 +301,7 @@ router.get('/', wrapAsync(async (req, res) => {
req.query, { currentUser }
);
if (filetype && filetype === 'csv') {
- const fields = ['id','name','slug','description','repository_url','frontend_stack','backend_stack',
+ const fields = ['id','name','slug','description','framework_type','reporting_cycle','indicator_status','primary_outcome','repository_url','frontend_stack','backend_stack',
'start_at','end_at',
diff --git a/backend/src/services/projects.js b/backend/src/services/projects.js
index 2e6a714..abbcfd6 100644
--- a/backend/src/services/projects.js
+++ b/backend/src/services/projects.js
@@ -1,22 +1,143 @@
const db = require('../db/models');
const ProjectsDBApi = require('../db/api/projects');
-const processFile = require("../middlewares/upload");
-const ValidationError = require('./notifications/errors/validation');
+const processFile = require('../middlewares/upload');
const csv = require('csv-parser');
-const axios = require('axios');
-const config = require('../config');
const stream = require('stream');
+const FRAMEWORK_TYPES = ['MERL', 'MEL', 'M&E'];
+const REPORTING_CYCLES = ['monthly', 'quarterly', 'semiannual', 'annual'];
+const INDICATOR_STATUSES = ['baseline_due', 'collecting', 'on_track', 'needs_attention'];
+const normalizeString = (value) => {
+ if (value === undefined || value === null) {
+ return value;
+ }
+ const normalized = String(value).trim();
+ return normalized || null;
+};
+const raiseValidationError = (message) => {
+ const error = new Error(message);
+ error.code = 400;
+ throw error;
+};
+
+const buildSlug = (value = '') => value
+ .toLowerCase()
+ .trim()
+ .replace(/&/g, ' and ')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80);
module.exports = class ProjectsService {
+ static validateMealPayload(rawData = {}, { isUpdate = false } = {}) {
+ const data = {
+ ...rawData,
+ };
+
+ const name = normalizeString(data.name);
+
+ if (!isUpdate || data.name !== undefined) {
+ if (!name) {
+ raiseValidationError('Initiative name is required.');
+ }
+
+ data.name = name;
+ }
+
+ if (!isUpdate || data.slug !== undefined || name) {
+ data.slug = normalizeString(data.slug) || (name ? buildSlug(name) : null);
+ }
+
+ const frameworkType = normalizeString(data.framework_type);
+ if (frameworkType !== undefined) {
+ if (frameworkType && !FRAMEWORK_TYPES.includes(frameworkType)) {
+ raiseValidationError('Framework type must be MERL, MEL, or M&E.');
+ }
+
+ data.framework_type = frameworkType;
+ }
+
+ const reportingCycle = normalizeString(data.reporting_cycle);
+ if (reportingCycle !== undefined) {
+ if (reportingCycle && !REPORTING_CYCLES.includes(reportingCycle)) {
+ raiseValidationError('Reporting cycle must be monthly, quarterly, semiannual, or annual.');
+ }
+
+ data.reporting_cycle = reportingCycle;
+ }
+
+ const indicatorStatus = normalizeString(data.indicator_status);
+ if (indicatorStatus !== undefined) {
+ if (indicatorStatus && !INDICATOR_STATUSES.includes(indicatorStatus)) {
+ raiseValidationError('Indicator status must be baseline_due, collecting, on_track, or needs_attention.');
+ }
+
+ data.indicator_status = indicatorStatus;
+ }
+
+ if (!isUpdate || data.primary_outcome !== undefined) {
+ const primaryOutcome = normalizeString(data.primary_outcome);
+
+ if (!primaryOutcome) {
+ raiseValidationError('Primary outcome is required.');
+ }
+
+ data.primary_outcome = primaryOutcome;
+ }
+
+ if (data.description !== undefined) {
+ data.description = normalizeString(data.description);
+ }
+
+ const hasStartAt = data.start_at !== undefined && data.start_at !== null && data.start_at !== '';
+ const hasEndAt = data.end_at !== undefined && data.end_at !== null && data.end_at !== '';
+
+ if (!isUpdate && !hasStartAt) {
+ raiseValidationError('Start date is required.');
+ }
+
+ if (hasStartAt) {
+ const startAt = new Date(data.start_at);
+
+ if (Number.isNaN(startAt.getTime())) {
+ raiseValidationError('Start date is invalid.');
+ }
+ }
+
+ if (hasEndAt) {
+ const endAt = new Date(data.end_at);
+
+ if (Number.isNaN(endAt.getTime())) {
+ raiseValidationError('End date is invalid.');
+ }
+ }
+
+ if (hasStartAt && hasEndAt) {
+ const startAt = new Date(data.start_at);
+ const endAt = new Date(data.end_at);
+
+ if (endAt < startAt) {
+ raiseValidationError('End date must be after the start date.');
+ }
+ }
+
+ if (data.members !== undefined && !Array.isArray(data.members)) {
+ raiseValidationError('Members must be provided as an array.');
+ }
+
+ return data;
+ }
+
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
- await ProjectsDBApi.create(
- data,
+ const payload = ProjectsService.validateMealPayload(data);
+
+ const createdProject = await ProjectsDBApi.create(
+ payload,
{
currentUser,
transaction,
@@ -24,13 +145,14 @@ module.exports = class ProjectsService {
);
await transaction.commit();
+ return createdProject.get({ plain: true });
} catch (error) {
await transaction.rollback();
throw error;
}
- };
+ }
- static async bulkImport(req, res, sendInvitationEmails = true, host) {
+ static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@@ -38,7 +160,7 @@ module.exports = class ProjectsService {
const bufferStream = new stream.PassThrough();
const results = [];
- await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
+ await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
await new Promise((resolve, reject) => {
bufferStream
@@ -49,13 +171,15 @@ module.exports = class ProjectsService {
resolve();
})
.on('error', (error) => reject(error));
- })
+ });
- await ProjectsDBApi.bulkImport(results, {
- transaction,
- ignoreDuplicates: true,
- validate: true,
- currentUser: req.currentUser
+ const preparedRows = results.map((item) => ProjectsService.validateMealPayload(item));
+
+ await ProjectsDBApi.bulkImport(preparedRows, {
+ transaction,
+ ignoreDuplicates: true,
+ validate: true,
+ currentUser: req.currentUser,
});
await transaction.commit();
@@ -68,20 +192,20 @@ module.exports = class ProjectsService {
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
- let projects = await ProjectsDBApi.findBy(
- {id},
- {transaction},
+ const projects = await ProjectsDBApi.findBy(
+ { id },
+ { transaction },
);
if (!projects) {
- throw new ValidationError(
- 'projectsNotFound',
- );
+ raiseValidationError('Initiative was not found.');
}
+ const payload = ProjectsService.validateMealPayload(data, { isUpdate: true });
+
const updatedProjects = await ProjectsDBApi.update(
id,
- data,
+ payload,
{
currentUser,
transaction,
@@ -90,12 +214,11 @@ module.exports = class ProjectsService {
await transaction.commit();
return updatedProjects;
-
} catch (error) {
await transaction.rollback();
throw error;
}
- };
+ }
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
@@ -131,8 +254,4 @@ module.exports = class ProjectsService {
throw error;
}
}
-
-
};
-
-
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 72935e6..fcbd9b9 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index 9d77178..4f16073 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
+ {
+ href: '/meal-command-center',
+ icon: icon.mdiChartTimelineVariant,
+ label: 'MEAL Command Center',
+ permissions: 'READ_PROJECTS',
+ },
{
href: '/users/users-list',
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index f4a7856..7eb5852 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,166 +1,192 @@
-
-import React, { useEffect, useState } from 'react';
-import type { ReactElement } from 'react';
+import * as icon from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
+import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
-import CardBox from '../components/CardBox';
-import SectionFullScreen from '../components/SectionFullScreen';
-import LayoutGuest from '../layouts/Guest';
-import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
+import BaseIcon from '../components/BaseIcon';
import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
+import LayoutGuest from '../layouts/Guest';
+const roleCards = [
+ {
+ title: 'MERL leadership',
+ eyebrow: 'Monitoring, evaluation, research & learning',
+ description: 'Shape programmes around evidence, deeper inquiry, and practical learning loops that drive decisions.',
+ icon: icon.mdiChartTimelineVariant,
+ accent: 'from-[#D8FBF4] to-[#ECFDF9] text-[#0E7C6B]',
+ },
+ {
+ title: 'MEL operations',
+ eyebrow: 'Monitoring, evaluation & learning',
+ description: 'Keep cadence reviews, outcome tracking, and adaptive learning visible without losing delivery speed.',
+ icon: icon.mdiAccountGroup,
+ accent: 'from-[#E8F0FF] to-[#F4F7FF] text-[#1D4ED8]',
+ },
+ {
+ title: 'M&E assurance',
+ eyebrow: 'Monitoring & evaluation',
+ description: 'Clarify the essentials: baseline, data quality, reporting rhythm, and transparent performance signals.',
+ icon: icon.mdiShieldAccountVariantOutline,
+ accent: 'from-[#F2EAFE] to-[#FAF6FF] text-[#7C3AED]',
+ },
+];
-export default function Starter() {
- const [illustrationImage, setIllustrationImage] = useState({
- src: undefined,
- photographer: undefined,
- photographer_url: undefined,
- })
- const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
- const [contentType, setContentType] = useState('video');
- const [contentPosition, setContentPosition] = useState('right');
- const textColor = useAppSelector((state) => state.style.linkColor);
-
- const title = 'App Preview'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
+const workflowCards = [
+ {
+ title: 'Create the initiative',
+ description: 'Capture the programme, assign MERL/MEL/M&E framing, and define the main outcome in one structured intake.',
+ },
+ {
+ title: 'Review the portfolio',
+ description: 'Scan active items, spotlight what needs attention, and move straight from summary to detailed admin records.',
+ },
+ {
+ title: 'Keep learning visible',
+ description: 'Anchor every record with cadence, evidence status, and a next-action prompt so learning never gets buried.',
+ },
+];
+export default function Home() {
return (
-
+ <>
-
{getPageTitle('Starter Page')}
+
{getPageTitle('MEAL Operating System')}
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
-
This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-
For guides and documentation please check
- your local README.md and the Flatlogic documentation
+
+
+
+
+
MEAL OS
+
World-class MERL, MEL & M&E workflows
-
-
-
-
+
+
+
-
-
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
+
+
-
+
+
+
+
+ Public landing page + authenticated MEAL command center
+
+
+ Build a modern MEAL system that feels clear, confident, and ready for real programme reviews.
+
+
+ This first delivery turns the seed app into a branded MEAL experience: a public-facing narrative for your organisation,
+ plus a focused command center where teams can intake initiatives, tag them as MERL, MEL, or M&E, and review evidence health.
+
+
+
+
+
+
+
+
End-to-end
+
1
+
Intake → confirmation → portfolio list → detail review in one flow.
+
+
+
Frameworks
+
3
+
MERL, MEL, and M&E are first-class options throughout the workflow.
+
+
+
Immediate value
+
Now
+
Teams can start structuring initiatives without rebuilding generic CRUD.
+
+
+
+
+
+
+
What ships first
+
MEAL Command Center
+
+ Create an initiative, pick the framework, set the evidence signal, and open the detailed admin record without leaving the flow.
+
+
+ {workflowCards.map((card, index) => (
+
+
Step 0{index + 1}
+
{card.title}
+
{card.description}
+
+ ))}
+
+
+
+
+
+
+
+
+
Role-aligned foundations
+
Designed for how MEAL teams actually work.
+
+ The visual system is clean and modern, but the product value is operational: each framework card below maps to a different way teams organise evidence, learning, and accountability.
+
+
+
+ {roleCards.map((role) => (
+
+
+
+
+
{role.eyebrow}
+
{role.title}
+
{role.description}
+
+ ))}
+
+
+
+
+
+
+
+
Launch path
+
A strong first slice, not just a pretty homepage.
+
+ The landing page is public, the admin interface stays protected, and the new workflow page is discoverable from both the navigation and the public hero. That gives you a credible starting product, not a disconnected mockup.
+
+
+
+
Ready to continue?
+
Jump into the app and shape the next layer.
+
+ Start with the command center, then tell me what should come next: indicator libraries, reporting templates, team assignments, or automated learning summaries.
+
+
+
+
+
+
+
+
+
+
+
+
+ >
);
}
-Starter.getLayout = function getLayout(page: ReactElement) {
+Home.getLayout = function getLayout(page: ReactElement) {
return {page} ;
};
-
diff --git a/frontend/src/pages/meal-command-center.tsx b/frontend/src/pages/meal-command-center.tsx
new file mode 100644
index 0000000..a55c8a0
--- /dev/null
+++ b/frontend/src/pages/meal-command-center.tsx
@@ -0,0 +1,713 @@
+import * as icon from '@mdi/js';
+import axios from 'axios';
+import dayjs from 'dayjs';
+import { Field, Form, Formik, FormikErrors } from 'formik';
+import Head from 'next/head';
+import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
+import BaseButton from '../components/BaseButton';
+import BaseButtons from '../components/BaseButtons';
+import BaseIcon from '../components/BaseIcon';
+import CardBox from '../components/CardBox';
+import FormField from '../components/FormField';
+import LoadingSpinner from '../components/LoadingSpinner';
+import NotificationBar from '../components/NotificationBar';
+import SectionMain from '../components/SectionMain';
+import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
+import { getPageTitle } from '../config';
+import { hasPermission } from '../helpers/userPermissions';
+import LayoutAuthenticated from '../layouts/Authenticated';
+import { useAppSelector } from '../stores/hooks';
+
+type FrameworkType = 'MERL' | 'MEL' | 'M&E';
+type ReportingCycle = 'monthly' | 'quarterly' | 'semiannual' | 'annual';
+type IndicatorStatus = 'baseline_due' | 'collecting' | 'on_track' | 'needs_attention';
+type InitiativeStatus = 'planning' | 'active' | 'paused' | 'archived';
+type FrameworkFilter = 'ALL' | FrameworkType;
+
+type ProjectMember = {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ email?: string;
+};
+
+type MealProject = {
+ id: string;
+ name: string;
+ slug?: string | null;
+ description?: string | null;
+ status?: InitiativeStatus | null;
+ start_at?: string | null;
+ end_at?: string | null;
+ framework_type?: FrameworkType | null;
+ reporting_cycle?: ReportingCycle | null;
+ indicator_status?: IndicatorStatus | null;
+ primary_outcome?: string | null;
+ owner?: ProjectMember | null;
+ members?: ProjectMember[];
+ createdAt?: string;
+};
+
+type MealFormValues = {
+ name: string;
+ description: string;
+ framework_type: FrameworkType;
+ reporting_cycle: ReportingCycle;
+ indicator_status: IndicatorStatus;
+ primary_outcome: string;
+ status: InitiativeStatus;
+ start_at: string;
+ end_at: string;
+ members: string[];
+};
+
+type NoticeState = {
+ color: 'success' | 'danger';
+ text: string;
+ createdId?: string;
+} | null;
+
+const frameworkOptions: FrameworkType[] = ['MERL', 'MEL', 'M&E'];
+const reportingCycleOptions: ReportingCycle[] = ['monthly', 'quarterly', 'semiannual', 'annual'];
+const indicatorOptions: IndicatorStatus[] = ['baseline_due', 'collecting', 'on_track', 'needs_attention'];
+const statusOptions: InitiativeStatus[] = ['planning', 'active', 'paused', 'archived'];
+
+const initialValues: MealFormValues = {
+ name: '',
+ description: '',
+ framework_type: 'MERL',
+ reporting_cycle: 'quarterly',
+ indicator_status: 'baseline_due',
+ primary_outcome: '',
+ status: 'planning',
+ start_at: dayjs().format('YYYY-MM-DD'),
+ end_at: '',
+ members: [],
+};
+
+const FieldError = ({ error }: { error?: string }) =>
+ error ? {error}
: null;
+
+const slugify = (value: string) => value
+ .toLowerCase()
+ .trim()
+ .replace(/&/g, ' and ')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80);
+
+const stripHtml = (value?: string | null) => (value || '')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+const humanize = (value?: string | null) => {
+ if (!value) {
+ return 'Not set';
+ }
+
+ return value
+ .split('_')
+ .join(' ')
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
+};
+
+const formatDate = (value?: string | null) => {
+ if (!value) {
+ return 'Not scheduled';
+ }
+
+ return dayjs(value).format('DD MMM YYYY');
+};
+
+const getOwnerLabel = (owner?: ProjectMember | null) => {
+ if (!owner) {
+ return 'No lead assigned';
+ }
+
+ const fullName = [owner.firstName, owner.lastName].filter(Boolean).join(' ').trim();
+ return fullName || owner.email || 'Lead assigned';
+};
+
+const getSignalClass = (signal?: IndicatorStatus | null) => {
+ switch (signal) {
+ case 'on_track':
+ return 'border-emerald-200 bg-emerald-50 text-emerald-700';
+ case 'collecting':
+ return 'border-sky-200 bg-sky-50 text-sky-700';
+ case 'needs_attention':
+ return 'border-rose-200 bg-rose-50 text-rose-700';
+ case 'baseline_due':
+ default:
+ return 'border-amber-200 bg-amber-50 text-amber-700';
+ }
+};
+
+const getStatusClass = (status?: InitiativeStatus | null) => {
+ switch (status) {
+ case 'active':
+ return 'border-emerald-200 bg-emerald-50 text-emerald-700';
+ case 'paused':
+ return 'border-amber-200 bg-amber-50 text-amber-700';
+ case 'archived':
+ return 'border-slate-200 bg-slate-100 text-slate-600';
+ case 'planning':
+ default:
+ return 'border-indigo-200 bg-indigo-50 text-indigo-700';
+ }
+};
+
+const getFrameworkClass = (framework?: FrameworkType | null) => {
+ switch (framework) {
+ case 'MERL':
+ return 'border-[#C9E6E3] bg-[#F1FBF9] text-[#0E7C6B]';
+ case 'MEL':
+ return 'border-[#CFE2FF] bg-[#F3F8FF] text-[#1D4ED8]';
+ case 'M&E':
+ return 'border-[#E8D5FF] bg-[#F8F3FF] text-[#7C3AED]';
+ default:
+ return 'border-slate-200 bg-slate-50 text-slate-600';
+ }
+};
+
+const getFocusMessage = (project: MealProject) => {
+ if (project.indicator_status === 'needs_attention') {
+ return 'Flag the evidence gap, assign one owner, and schedule a short decision review this week.';
+ }
+
+ if (project.indicator_status === 'baseline_due') {
+ return 'Confirm baseline values before the next reporting cycle so trends stay credible.';
+ }
+
+ if (project.framework_type === 'MERL') {
+ return 'Blend monitoring, evaluation, research, and learning into a single evidence sprint.';
+ }
+
+ if (project.reporting_cycle === 'monthly') {
+ return 'Prepare a monthly pulse with one headline outcome, one risk, and one learning insight.';
+ }
+
+ return 'Keep the next learning review lightweight: outcome signal, evidence note, and one action owner.';
+};
+
+const summarize = (value?: string | null, fallback = 'Add a short initiative brief to anchor the portfolio card.') => {
+ const cleaned = stripHtml(value);
+
+ if (!cleaned) {
+ return fallback;
+ }
+
+ return cleaned.length > 140 ? `${cleaned.slice(0, 137)}...` : cleaned;
+};
+
+const MealCommandCenter = () => {
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const [portfolio, setPortfolio] = useState([]);
+ const [selectedId, setSelectedId] = useState(null);
+ const [activeFramework, setActiveFramework] = useState('ALL');
+ const [isLoading, setIsLoading] = useState(true);
+ const [notice, setNotice] = useState(null);
+
+ const canCreateProjects = hasPermission(currentUser, 'CREATE_PROJECTS');
+
+ const loadPortfolio = useCallback(async (preferredId?: string | null) => {
+ setIsLoading(true);
+
+ try {
+ const { data } = await axios.get('/projects', {
+ params: {
+ limit: 50,
+ page: 0,
+ },
+ });
+
+ const rows = Array.isArray(data?.rows) ? data.rows : [];
+ setPortfolio(rows);
+
+ const nextSelectedId = preferredId && rows.some((item: MealProject) => item.id === preferredId)
+ ? preferredId
+ : rows[0]?.id || null;
+
+ setSelectedId(nextSelectedId);
+ } catch (error) {
+ const message = axios.isAxiosError(error)
+ ? typeof error.response?.data === 'string'
+ ? error.response?.data
+ : error.message
+ : 'Unable to load the MEAL portfolio right now.';
+
+ console.error('MEAL portfolio load failed:', error);
+ setNotice({
+ color: 'danger',
+ text: message || 'Unable to load the MEAL portfolio right now.',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!currentUser) {
+ return;
+ }
+
+ loadPortfolio(selectedId).catch((error) => {
+ console.error('MEAL portfolio bootstrap failed:', error);
+ });
+ }, [currentUser, loadPortfolio]);
+
+ const frameworkCounts = useMemo(
+ () => frameworkOptions.reduce>((accumulator, framework) => {
+ accumulator[framework] = portfolio.filter((item) => item.framework_type === framework).length;
+ return accumulator;
+ }, { MERL: 0, MEL: 0, 'M&E': 0 }),
+ [portfolio],
+ );
+
+ const activeCount = useMemo(
+ () => portfolio.filter((item) => item.status === 'active').length,
+ [portfolio],
+ );
+
+ const attentionCount = useMemo(
+ () => portfolio.filter((item) => item.indicator_status === 'needs_attention').length,
+ [portfolio],
+ );
+
+ const filteredPortfolio = useMemo(
+ () => activeFramework === 'ALL'
+ ? portfolio
+ : portfolio.filter((item) => item.framework_type === activeFramework),
+ [activeFramework, portfolio],
+ );
+
+ const selectedInitiative = useMemo(
+ () => portfolio.find((item) => item.id === selectedId) || null,
+ [portfolio, selectedId],
+ );
+
+ useEffect(() => {
+ if (!filteredPortfolio.length) {
+ if (selectedId !== null) {
+ setSelectedId(null);
+ }
+ return;
+ }
+
+ if (!selectedId || !filteredPortfolio.some((item) => item.id === selectedId)) {
+ setSelectedId(filteredPortfolio[0].id);
+ }
+ }, [filteredPortfolio, selectedId]);
+
+ if (!currentUser) {
+ return (
+ <>
+
+ {getPageTitle('MEAL Command Center')}
+
+
+
+ {''}
+
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ {getPageTitle('MEAL Command Center')}
+
+
+
+
+
+
+
+
+
+
+ MERL / MEL / M&E operating layer
+
+
+ Hello {currentUser.firstName || 'team'}, keep every initiative measurable, reviewable, and learning-ready.
+
+
+ This first iteration turns the existing project register into a purpose-built MEAL portfolio: capture a new initiative,
+ assign the framework, set the reporting cadence, and review evidence health in one focused workspace.
+
+
+
+
+
+
+ {frameworkOptions.map((framework) => (
+
+
{framework}
+
{frameworkCounts[framework]}
+
Framework-tagged initiatives currently in the portfolio.
+
+ ))}
+
+
+
+
+
Portfolio pulse
+
+
+
Active delivery
+
{activeCount}
+
+
+
Needs attention
+
{attentionCount}
+
+
+
Reporting-ready
+
+ {portfolio.filter((item) => ['collecting', 'on_track'].includes(item.indicator_status || '')).length}
+
+
+
+
+ Use this page for the thin-slice workflow: intake, confirmation, review, and a quick jump into the full admin record.
+
+
+
+
+
+ {notice && (
+ : undefined}
+ >
+ {notice.text}
+
+ )}
+
+
+
+
+
+
+
+
+
+
New MEAL initiative intake
+
Capture the minimum structure needed for MERL, MEL, or M&E delivery.
+
+
+
+
+
+ {canCreateProjects ? (
+
{
+ const errors: FormikErrors = {};
+
+ if (!values.name.trim()) {
+ errors.name = 'Give the initiative a clear name.';
+ }
+
+ if (!values.primary_outcome.trim()) {
+ errors.primary_outcome = 'Describe the outcome or result this initiative is meant to improve.';
+ }
+
+ if (!values.start_at) {
+ errors.start_at = 'Pick a start date.';
+ }
+
+ if (values.end_at && values.start_at && dayjs(values.end_at).isBefore(dayjs(values.start_at))) {
+ errors.end_at = 'End date must come after the start date.';
+ }
+
+ return errors;
+ }}
+ onSubmit={async (values, { resetForm, setSubmitting }) => {
+ setNotice(null);
+
+ try {
+ const payload = {
+ ...values,
+ slug: slugify(values.name),
+ owner: currentUser.id,
+ };
+
+ const { data } = await axios.post('/projects', { data: payload });
+
+ await loadPortfolio(data?.id || null);
+ resetForm();
+ setActiveFramework('ALL');
+ setNotice({
+ color: 'success',
+ text: `${values.name} was added to your MEAL portfolio and is ready for review.`,
+ createdId: data?.id,
+ });
+ } catch (error) {
+ const message = axios.isAxiosError(error)
+ ? typeof error.response?.data === 'string'
+ ? error.response?.data
+ : error.message
+ : 'We could not save the initiative just now.';
+
+ console.error('MEAL intake submission failed:', error);
+ setNotice({
+ color: 'danger',
+ text: message || 'We could not save the initiative just now.',
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ }}
+ >
+ {({ errors, isSubmitting, touched }) => (
+
+ )}
+
+ ) : (
+
+ You can review the MEAL portfolio, but you need CREATE_PROJECTS permission to submit new initiatives.
+
+ )}
+
+
+
+
+
+
+
+
Portfolio radar
+
Review the latest initiatives, switch between frameworks, and inspect the evidence signal.
+
+
+ {(['ALL', ...frameworkOptions] as FrameworkFilter[]).map((framework) => (
+ setActiveFramework(framework)}
+ className={`rounded-full border px-4 py-2 text-sm font-medium transition ${activeFramework === framework ? 'border-[#0B5FFF] bg-[#0B5FFF] text-white shadow-sm' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900'}`}
+ >
+ {framework === 'ALL' ? 'All frameworks' : framework}
+
+ ))}
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : filteredPortfolio.length ? (
+ filteredPortfolio.map((initiative) => (
+
setSelectedId(initiative.id)}
+ className={`w-full rounded-3xl border p-5 text-left transition ${selectedId === initiative.id ? 'border-[#0B5FFF] bg-[#F3F8FF] shadow-sm' : 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-sm'}`}
+ >
+
+
+ {initiative.framework_type || 'Framework pending'}
+
+
+ {humanize(initiative.status)}
+
+
+ {humanize(initiative.indicator_status)}
+
+
+ {initiative.name}
+ {summarize(initiative.description)}
+
+
+
Lead
+
{getOwnerLabel(initiative.owner)}
+
+
+
Cadence
+
{humanize(initiative.reporting_cycle)}
+
+
+
+ ))
+ ) : (
+
+
No initiatives found for this view.
+
Try another framework filter, or create the first initiative from the intake panel.
+
+ )}
+
+
+
+ {selectedInitiative ? (
+ <>
+
+
+ {selectedInitiative.framework_type || 'Framework pending'}
+
+
+ {humanize(selectedInitiative.status)}
+
+
+
+
{selectedInitiative.name}
+
{summarize(selectedInitiative.description, 'No context note has been added yet for this initiative.')}
+
+
+
+
Primary outcome
+
+ {selectedInitiative.primary_outcome || 'No outcome statement yet.'}
+
+
+
+
Recommended next move
+
{getFocusMessage(selectedInitiative)}
+
+
+
+
+
+
Lead owner
+
{getOwnerLabel(selectedInitiative.owner)}
+
+
+
Collaborators
+
{selectedInitiative.members?.length || 0} team members
+
+
+
Evidence signal
+
{humanize(selectedInitiative.indicator_status)}
+
+
+
+
+
+
Schedule
+
{formatDate(selectedInitiative.start_at)} → {formatDate(selectedInitiative.end_at)}
+
+
+
Reporting cadence
+
{humanize(selectedInitiative.reporting_cycle)}
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
Select an initiative to inspect it.
+
The detail panel will show the current outcome, evidence signal, cadence, and next recommended move.
+
+
+ )}
+
+
+
+
+
+ >
+ );
+};
+
+MealCommandCenter.getLayout = function getLayout(page: ReactElement) {
+ return (
+
+ {page}
+
+ );
+};
+
+export default MealCommandCenter;