This commit is contained in:
Flatlogic Bot 2026-05-24 06:50:30 +00:00
parent 81a041b87c
commit 9918c7098f
12 changed files with 1329 additions and 233 deletions

View File

@ -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;

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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: {

View File

@ -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) => {

View File

@ -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',

View File

@ -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;
}
}
};

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
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 (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('MEAL Operating System')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your App Preview app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className='min-h-screen bg-[#F5F8FC] text-slate-900'>
<header className='sticky top-0 z-20 border-b border-white/70 bg-[#F5F8FC]/90 backdrop-blur'>
<div className='mx-auto flex max-w-7xl items-center justify-between px-6 py-4'>
<div>
<div className='text-xs font-semibold uppercase tracking-[0.24em] text-[#0B5FFF]'>MEAL OS</div>
<div className='mt-1 text-lg font-semibold text-slate-900'>World-class MERL, MEL &amp; M&amp;E workflows</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<BaseButtons type='justify-start' noWrap>
<BaseButton color='whiteDark' href='/dashboard' label='Admin interface' />
<BaseButton color='info' href='/login' label='Login' />
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
</header>
</div>
<main>
<section className='mx-auto grid max-w-7xl gap-8 px-6 py-14 lg:grid-cols-[1.15fr_0.85fr] lg:items-center'>
<div>
<div className='inline-flex items-center rounded-full border border-[#BFD5FF] bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#0B5FFF] shadow-sm'>
Public landing page + authenticated MEAL command center
</div>
<h1 className='mt-6 max-w-4xl text-5xl font-semibold leading-tight text-slate-950 md:text-6xl'>
Build a modern MEAL system that feels clear, confident, and ready for real programme reviews.
</h1>
<p className='mt-6 max-w-2xl text-lg leading-8 text-slate-600'>
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&amp;E, and review evidence health.
</p>
<BaseButtons type='justify-start' className='mt-8'>
<BaseButton color='info' href='/meal-command-center' label='Open command center' />
<BaseButton color='whiteDark' href='/dashboard' label='Go to admin interface' />
</BaseButtons>
<div className='mt-10 grid gap-4 sm:grid-cols-3'>
<div className='rounded-3xl border border-white bg-white p-5 shadow-sm shadow-slate-200/70'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-slate-400'>End-to-end</p>
<p className='mt-2 text-3xl font-semibold text-slate-900'>1</p>
<p className='mt-2 text-sm leading-6 text-slate-500'>Intake confirmation portfolio list detail review in one flow.</p>
</div>
<div className='rounded-3xl border border-white bg-white p-5 shadow-sm shadow-slate-200/70'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-slate-400'>Frameworks</p>
<p className='mt-2 text-3xl font-semibold text-slate-900'>3</p>
<p className='mt-2 text-sm leading-6 text-slate-500'>MERL, MEL, and M&amp;E are first-class options throughout the workflow.</p>
</div>
<div className='rounded-3xl border border-white bg-white p-5 shadow-sm shadow-slate-200/70'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-slate-400'>Immediate value</p>
<p className='mt-2 text-3xl font-semibold text-slate-900'>Now</p>
<p className='mt-2 text-sm leading-6 text-slate-500'>Teams can start structuring initiatives without rebuilding generic CRUD.</p>
</div>
</div>
</div>
<div className='overflow-hidden rounded-[2rem] border border-[#D9E7F7] bg-gradient-to-br from-[#0B1F3A] via-[#154E75] to-[#15B8A6] p-8 text-white shadow-2xl shadow-slate-200'>
<div className='rounded-3xl border border-white/15 bg-white/10 p-6 backdrop-blur'>
<p className='text-sm uppercase tracking-[0.24em] text-white/70'>What ships first</p>
<h2 className='mt-4 text-3xl font-semibold'>MEAL Command Center</h2>
<p className='mt-4 text-base leading-7 text-white/85'>
Create an initiative, pick the framework, set the evidence signal, and open the detailed admin record without leaving the flow.
</p>
<div className='mt-6 space-y-4'>
{workflowCards.map((card, index) => (
<div key={card.title} className='rounded-2xl bg-white/10 p-4'>
<div className='text-xs font-semibold uppercase tracking-[0.2em] text-white/70'>Step 0{index + 1}</div>
<div className='mt-2 text-lg font-semibold'>{card.title}</div>
<p className='mt-2 text-sm leading-6 text-white/80'>{card.description}</p>
</div>
))}
</div>
</div>
</div>
</section>
<section className='mx-auto max-w-7xl px-6 pb-6'>
<div className='rounded-[2rem] border border-[#D9E7F7] bg-white p-8 shadow-sm shadow-slate-200'>
<div className='max-w-2xl'>
<p className='text-sm font-semibold uppercase tracking-[0.24em] text-[#0B5FFF]'>Role-aligned foundations</p>
<h2 className='mt-3 text-3xl font-semibold text-slate-950'>Designed for how MEAL teams actually work.</h2>
<p className='mt-4 text-base leading-7 text-slate-600'>
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.
</p>
</div>
<div className='mt-8 grid gap-5 lg:grid-cols-3'>
{roleCards.map((role) => (
<div key={role.title} className='rounded-[1.75rem] border border-slate-100 bg-slate-50 p-6'>
<div className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br ${role.accent}`}>
<BaseIcon path={role.icon} size={24} />
</div>
<p className='mt-5 text-xs font-semibold uppercase tracking-[0.2em] text-slate-400'>{role.eyebrow}</p>
<h3 className='mt-2 text-2xl font-semibold text-slate-900'>{role.title}</h3>
<p className='mt-3 text-sm leading-7 text-slate-600'>{role.description}</p>
</div>
))}
</div>
</div>
</section>
<section className='mx-auto max-w-7xl px-6 py-10'>
<div className='grid gap-6 lg:grid-cols-[0.9fr_1.1fr]'>
<div className='rounded-[2rem] border border-[#D9E7F7] bg-[#0F172A] p-8 text-white shadow-xl'>
<p className='text-sm font-semibold uppercase tracking-[0.24em] text-[#93C5FD]'>Launch path</p>
<h2 className='mt-3 text-3xl font-semibold'>A strong first slice, not just a pretty homepage.</h2>
<p className='mt-4 text-base leading-7 text-white/80'>
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.
</p>
</div>
<div className='rounded-[2rem] border border-[#D9E7F7] bg-white p-8 shadow-sm shadow-slate-200'>
<p className='text-sm font-semibold uppercase tracking-[0.24em] text-[#0B5FFF]'>Ready to continue?</p>
<h2 className='mt-3 text-3xl font-semibold text-slate-950'>Jump into the app and shape the next layer.</h2>
<p className='mt-4 text-base leading-7 text-slate-600'>
Start with the command center, then tell me what should come next: indicator libraries, reporting templates, team assignments, or automated learning summaries.
</p>
<BaseButtons type='justify-start' className='mt-6'>
<BaseButton color='info' href='/login' label='Login' />
<BaseButton color='whiteDark' href='/dashboard' label='Admin interface' />
</BaseButtons>
</div>
</div>
</section>
</main>
<footer className='border-t border-slate-200 bg-white/70'>
<div className='mx-auto flex max-w-7xl flex-col gap-3 px-6 py-6 text-sm text-slate-500 md:flex-row md:items-center md:justify-between'>
<p>© 2026 MEAL OS. Evidence-driven delivery for MERL, MEL, and M&amp;E teams.</p>
<div className='flex flex-wrap gap-4'>
<Link href='/privacy-policy' className='hover:text-slate-900'>Privacy Policy</Link>
<Link href='/terms-of-use' className='hover:text-slate-900'>Terms of Use</Link>
<Link href='/login' className='hover:text-slate-900'>Login</Link>
</div>
</div>
</footer>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
Home.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -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 ? <p className='-mt-3 mb-4 text-sm font-medium text-red-600'>{error}</p> : 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(/&nbsp;/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<MealProject[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeFramework, setActiveFramework] = useState<FrameworkFilter>('ALL');
const [isLoading, setIsLoading] = useState(true);
const [notice, setNotice] = useState<NoticeState>(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<Record<FrameworkType, number>>((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 (
<>
<Head>
<title>{getPageTitle('MEAL Command Center')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={icon.mdiChartTimelineVariant} title='MEAL Command Center' main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<LoadingSpinner />
</CardBox>
</SectionMain>
</>
);
}
return (
<>
<Head>
<title>{getPageTitle('MEAL Command Center')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={icon.mdiChartTimelineVariant} title='MEAL Command Center' main>
<BaseButton color='whiteDark' href='/projects/projects-list' label='Open Projects Admin' />
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-3xl border border-[#CDE6EA] bg-gradient-to-br from-[#0B1F3A] via-[#17466C] to-[#15B8A6] text-white shadow-xl shadow-slate-200'>
<div className='grid gap-6 px-6 py-8 lg:grid-cols-[1.4fr_0.9fr] lg:px-8'>
<div>
<div className='mb-4 inline-flex items-center rounded-full border border-white/20 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-white/90'>
MERL / MEL / M&amp;E operating layer
</div>
<h1 className='max-w-3xl text-4xl font-semibold leading-tight md:text-5xl'>
Hello {currentUser.firstName || 'team'}, keep every initiative measurable, reviewable, and learning-ready.
</h1>
<p className='mt-4 max-w-2xl text-base leading-7 text-slate-100/90 md:text-lg'>
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.
</p>
<BaseButtons type='justify-start' className='mt-6'>
<BaseButton color='info' href='#meal-intake' label='Start a new intake' />
<BaseButton color='whiteDark' href='/dashboard' label='Back to overview' />
</BaseButtons>
<div className='mt-8 grid gap-3 sm:grid-cols-3'>
{frameworkOptions.map((framework) => (
<div key={framework} className='rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur'>
<div className='text-xs uppercase tracking-[0.2em] text-white/70'>{framework}</div>
<div className='mt-2 text-3xl font-semibold'>{frameworkCounts[framework]}</div>
<p className='mt-2 text-sm text-white/80'>Framework-tagged initiatives currently in the portfolio.</p>
</div>
))}
</div>
</div>
<div className='rounded-3xl border border-white/15 bg-white/10 p-6 backdrop-blur'>
<p className='text-sm uppercase tracking-[0.22em] text-white/70'>Portfolio pulse</p>
<div className='mt-6 space-y-4'>
<div className='rounded-2xl bg-white/10 p-4'>
<div className='text-sm text-white/70'>Active delivery</div>
<div className='mt-1 text-3xl font-semibold'>{activeCount}</div>
</div>
<div className='rounded-2xl bg-white/10 p-4'>
<div className='text-sm text-white/70'>Needs attention</div>
<div className='mt-1 text-3xl font-semibold'>{attentionCount}</div>
</div>
<div className='rounded-2xl bg-white/10 p-4'>
<div className='text-sm text-white/70'>Reporting-ready</div>
<div className='mt-1 text-3xl font-semibold'>
{portfolio.filter((item) => ['collecting', 'on_track'].includes(item.indicator_status || '')).length}
</div>
</div>
</div>
<p className='mt-6 text-sm leading-6 text-white/85'>
Use this page for the thin-slice workflow: intake, confirmation, review, and a quick jump into the full admin record.
</p>
</div>
</div>
</div>
{notice && (
<NotificationBar
color={notice.color}
icon={notice.color === 'success' ? icon.mdiChartTimelineVariant : icon.mdiShieldAccountVariantOutline}
button={notice.createdId ? <BaseButton color='white' href={`/projects/projects-view/?id=${notice.createdId}`} label='Open detail' /> : undefined}
>
{notice.text}
</NotificationBar>
)}
<div className='grid gap-6 xl:grid-cols-[0.92fr_1.08fr]'>
<CardBox hasComponentLayout className='overflow-hidden'>
<div id='meal-intake' className='border-b border-slate-200 bg-slate-50 px-6 py-5'>
<div className='flex items-center gap-3'>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-[#E8FBF6] text-[#0E7C6B]'>
<BaseIcon path={icon.mdiAccountGroup} size={22} />
</div>
<div>
<h2 className='text-2xl font-semibold text-slate-900'>New MEAL initiative intake</h2>
<p className='text-sm text-slate-500'>Capture the minimum structure needed for MERL, MEL, or M&amp;E delivery.</p>
</div>
</div>
</div>
<div className='px-6 py-6'>
{canCreateProjects ? (
<Formik
initialValues={initialValues}
validate={(values) => {
const errors: FormikErrors<MealFormValues> = {};
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 }) => (
<Form>
<div className='grid gap-4 lg:grid-cols-2'>
<div>
<FormField label='Initiative name'>
<Field name='name' placeholder='e.g. Youth livelihoods quarterly review' />
</FormField>
<FieldError error={touched.name ? errors.name : undefined} />
</div>
<div>
<FormField label='Framework'>
<Field as='select' name='framework_type'>
{frameworkOptions.map((framework) => (
<option key={framework} value={framework}>{framework}</option>
))}
</Field>
</FormField>
</div>
</div>
<div className='grid gap-4 lg:grid-cols-2'>
<FormField label='Delivery stage'>
<Field as='select' name='status'>
{statusOptions.map((status) => (
<option key={status} value={status}>{humanize(status)}</option>
))}
</Field>
</FormField>
<FormField label='Reporting cadence'>
<Field as='select' name='reporting_cycle'>
{reportingCycleOptions.map((cycle) => (
<option key={cycle} value={cycle}>{humanize(cycle)}</option>
))}
</Field>
</FormField>
</div>
<div className='grid gap-4 lg:grid-cols-2'>
<FormField label='Evidence signal'>
<Field as='select' name='indicator_status'>
{indicatorOptions.map((indicator) => (
<option key={indicator} value={indicator}>{humanize(indicator)}</option>
))}
</Field>
</FormField>
</div>
<div className='grid gap-4 lg:grid-cols-2'>
<div>
<FormField label='Start date'>
<Field name='start_at' type='date' />
</FormField>
<FieldError error={touched.start_at ? errors.start_at : undefined} />
</div>
<div>
<FormField label='End date'>
<Field name='end_at' type='date' />
</FormField>
<FieldError error={touched.end_at ? errors.end_at : undefined} />
</div>
</div>
<div>
<FormField label='Primary outcome' hasTextareaHeight>
<Field as='textarea' name='primary_outcome' placeholder='What change, result, or key outcome will this initiative monitor or evaluate?' />
</FormField>
<FieldError error={touched.primary_outcome ? errors.primary_outcome : undefined} />
</div>
<FormField label='Context note' help='Optional: add a quick narrative, hypothesis, or reminder for the next learning review.' hasTextareaHeight>
<Field as='textarea' name='description' placeholder='Add context that helps the team interpret the data when review time comes.' />
</FormField>
<BaseButtons type='justify-start' className='mt-3'>
<BaseButton color='info' disabled={isSubmitting} label={isSubmitting ? 'Saving initiative...' : 'Save initiative'} type='submit' />
<BaseButton color='whiteDark' href='/projects/projects-list' label='Open full project list' />
</BaseButtons>
</Form>
)}
</Formik>
) : (
<div className='rounded-2xl border border-amber-200 bg-amber-50 px-4 py-5 text-sm text-amber-800'>
You can review the MEAL portfolio, but you need <span className='font-semibold'>CREATE_PROJECTS</span> permission to submit new initiatives.
</div>
)}
</div>
</CardBox>
<CardBox hasComponentLayout className='overflow-hidden'>
<div className='border-b border-slate-200 bg-slate-50 px-6 py-5'>
<div className='flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between'>
<div>
<h2 className='text-2xl font-semibold text-slate-900'>Portfolio radar</h2>
<p className='text-sm text-slate-500'>Review the latest initiatives, switch between frameworks, and inspect the evidence signal.</p>
</div>
<div className='flex flex-wrap gap-2'>
{(['ALL', ...frameworkOptions] as FrameworkFilter[]).map((framework) => (
<button
key={framework}
type='button'
onClick={() => 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}
</button>
))}
</div>
</div>
</div>
<div className='grid gap-6 px-6 py-6 lg:grid-cols-[0.92fr_1.08fr]'>
<div className='space-y-4'>
{isLoading ? (
<LoadingSpinner />
) : filteredPortfolio.length ? (
filteredPortfolio.map((initiative) => (
<button
key={initiative.id}
type='button'
onClick={() => 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'}`}
>
<div className='flex flex-wrap items-center gap-2'>
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${getFrameworkClass(initiative.framework_type)}`}>
{initiative.framework_type || 'Framework pending'}
</span>
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${getStatusClass(initiative.status)}`}>
{humanize(initiative.status)}
</span>
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${getSignalClass(initiative.indicator_status)}`}>
{humanize(initiative.indicator_status)}
</span>
</div>
<h3 className='mt-4 text-lg font-semibold text-slate-900'>{initiative.name}</h3>
<p className='mt-2 text-sm leading-6 text-slate-600'>{summarize(initiative.description)}</p>
<div className='mt-4 grid gap-3 text-sm text-slate-500 sm:grid-cols-2'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Lead</p>
<p className='mt-1 font-medium text-slate-700'>{getOwnerLabel(initiative.owner)}</p>
</div>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Cadence</p>
<p className='mt-1 font-medium text-slate-700'>{humanize(initiative.reporting_cycle)}</p>
</div>
</div>
</button>
))
) : (
<div className='rounded-3xl border border-dashed border-slate-300 bg-slate-50 px-6 py-10 text-center'>
<p className='text-lg font-semibold text-slate-800'>No initiatives found for this view.</p>
<p className='mt-2 text-sm text-slate-500'>Try another framework filter, or create the first initiative from the intake panel.</p>
</div>
)}
</div>
<div className='rounded-3xl border border-slate-200 bg-slate-50 p-6'>
{selectedInitiative ? (
<>
<div className='flex flex-wrap items-center gap-2'>
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${getFrameworkClass(selectedInitiative.framework_type)}`}>
{selectedInitiative.framework_type || 'Framework pending'}
</span>
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${getStatusClass(selectedInitiative.status)}`}>
{humanize(selectedInitiative.status)}
</span>
</div>
<h3 className='mt-4 text-2xl font-semibold text-slate-900'>{selectedInitiative.name}</h3>
<p className='mt-3 text-sm leading-7 text-slate-600'>{summarize(selectedInitiative.description, 'No context note has been added yet for this initiative.')}</p>
<div className='mt-6 grid gap-4 md:grid-cols-2'>
<div className='rounded-2xl bg-white p-4 shadow-sm'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Primary outcome</p>
<p className='mt-3 text-sm leading-6 text-slate-700'>
{selectedInitiative.primary_outcome || 'No outcome statement yet.'}
</p>
</div>
<div className='rounded-2xl bg-white p-4 shadow-sm'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Recommended next move</p>
<p className='mt-3 text-sm leading-6 text-slate-700'>{getFocusMessage(selectedInitiative)}</p>
</div>
</div>
<div className='mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3'>
<div className='rounded-2xl border border-slate-200 bg-white p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Lead owner</p>
<p className='mt-2 text-sm font-medium text-slate-700'>{getOwnerLabel(selectedInitiative.owner)}</p>
</div>
<div className='rounded-2xl border border-slate-200 bg-white p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Collaborators</p>
<p className='mt-2 text-sm font-medium text-slate-700'>{selectedInitiative.members?.length || 0} team members</p>
</div>
<div className='rounded-2xl border border-slate-200 bg-white p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Evidence signal</p>
<p className='mt-2 text-sm font-medium text-slate-700'>{humanize(selectedInitiative.indicator_status)}</p>
</div>
</div>
<div className='mt-6 grid gap-4 sm:grid-cols-2'>
<div className='rounded-2xl border border-slate-200 bg-white p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Schedule</p>
<p className='mt-2 text-sm font-medium text-slate-700'>{formatDate(selectedInitiative.start_at)} {formatDate(selectedInitiative.end_at)}</p>
</div>
<div className='rounded-2xl border border-slate-200 bg-white p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Reporting cadence</p>
<p className='mt-2 text-sm font-medium text-slate-700'>{humanize(selectedInitiative.reporting_cycle)}</p>
</div>
</div>
<BaseButtons type='justify-start' className='mt-6'>
<BaseButton color='info' href={`/projects/projects-view/?id=${selectedInitiative.id}`} label='Open full detail' />
<BaseButton color='whiteDark' href={`/projects/projects-edit/?id=${selectedInitiative.id}`} label='Adjust record' />
</BaseButtons>
</>
) : (
<div className='flex h-full min-h-[260px] items-center justify-center rounded-3xl border border-dashed border-slate-300 bg-white px-6 text-center'>
<div>
<p className='text-lg font-semibold text-slate-800'>Select an initiative to inspect it.</p>
<p className='mt-2 text-sm text-slate-500'>The detail panel will show the current outcome, evidence signal, cadence, and next recommended move.</p>
</div>
</div>
)}
</div>
</div>
</CardBox>
</div>
</SectionMain>
</>
);
};
MealCommandCenter.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='READ_PROJECTS'>
{page}
</LayoutAuthenticated>
);
};
export default MealCommandCenter;