From 9918c7098f104ac795556658186ec3f4077bd408 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 24 May 2026 06:50:30 +0000 Subject: [PATCH] Ver 1.0 --- backend/src/db/api/projects.js | 145 ++-- ...60524090000-add-meal-fields-to-projects.js | 63 ++ ...reate-projects-members-users-join-table.js | 89 +++ backend/src/db/models/projects.js | 34 +- .../db/seeders/20231127130745-sample-data.js | 6 +- backend/src/routes/projects.js | 7 +- backend/src/services/projects.js | 175 ++++- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 6 + frontend/src/pages/index.tsx | 318 ++++---- frontend/src/pages/meal-command-center.tsx | 713 ++++++++++++++++++ 12 files changed, 1329 insertions(+), 233 deletions(-) create mode 100644 backend/src/db/migrations/20260524090000-add-meal-fields-to-projects.js create mode 100644 backend/src/db/migrations/20260524091000-create-projects-members-users-join-table.js create mode 100644 frontend/src/pages/meal-command-center.tsx 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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +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. +

+ + + + +
+
+
+
+ +
+
+

© 2026 MEAL OS. Evidence-driven delivery for MERL, MEL, and M&E teams.

+
+ Privacy Policy + Terms of Use + Login +
+
+
+
+ ); } -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 }) => ( +
+
+
+ + + + +
+ +
+ + + {frameworkOptions.map((framework) => ( + + ))} + + +
+
+ +
+ + + {statusOptions.map((status) => ( + + ))} + + + + + + {reportingCycleOptions.map((cycle) => ( + + ))} + + +
+ +
+ + + {indicatorOptions.map((indicator) => ( + + ))} + + + +
+ +
+
+ + + + +
+ +
+ + + + +
+
+ +
+ + + + +
+ + + + + + + + + +
+ )} +
+ ) : ( +
+ 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) => ( + + ))} +
+
+
+ +
+
+ {isLoading ? ( + + ) : filteredPortfolio.length ? ( + filteredPortfolio.map((initiative) => ( + + )) + ) : ( +
+

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;