Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9918c7098f |
@ -1,7 +1,5 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -36,6 +34,26 @@ module.exports = class ProjectsDBApi {
|
|||||||
null
|
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
|
status: data.status
|
||||||
||
|
||
|
||||||
null
|
null
|
||||||
@ -80,9 +98,11 @@ module.exports = class ProjectsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
await projects.setMembers(data.members || [], {
|
if (Array.isArray(data.members) && data.members.length) {
|
||||||
|
await projects.setMembers(data.members, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -112,6 +132,26 @@ module.exports = class ProjectsDBApi {
|
|||||||
description: item.description
|
description: item.description
|
||||||
||
|
||
|
||||||
null
|
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
|
status: item.status
|
||||||
@ -180,6 +220,18 @@ module.exports = class ProjectsDBApi {
|
|||||||
if (data.description !== undefined) updatePayload.description = data.description;
|
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;
|
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 });
|
await projects.setMembers(data.members, { transaction });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -320,9 +372,7 @@ module.exports = class ProjectsDBApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
output.members = await projects.getMembers({
|
output.members = [];
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -344,9 +394,6 @@ module.exports = class ProjectsDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
@ -368,13 +415,6 @@ module.exports = class ProjectsDBApi {
|
|||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
{
|
|
||||||
model: db.users,
|
|
||||||
as: 'members',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (filter) {
|
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) {
|
if (filter.repository_url) {
|
||||||
where = {
|
where = {
|
||||||
...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) {
|
if (filter.createdAtRange) {
|
||||||
const [start, end] = filter.createdAtRange;
|
const [start, end] = filter.createdAtRange;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const projects = sequelize.define(
|
const projects = sequelize.define(
|
||||||
'projects',
|
'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: {
|
status: {
|
||||||
|
|||||||
@ -34,6 +34,8 @@ const Conversations = db.conversations;
|
|||||||
|
|
||||||
const Messages = db.messages;
|
const Messages = db.messages;
|
||||||
|
|
||||||
|
const Permissions = db.permissions;
|
||||||
|
|
||||||
const AppModules = db.app_modules;
|
const AppModules = db.app_modules;
|
||||||
|
|
||||||
const RolePermissionRules = db.role_permission_rules;
|
const RolePermissionRules = db.role_permission_rules;
|
||||||
@ -2446,7 +2448,7 @@ const AuditLogsData = [
|
|||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
up: async (queryInterface, Sequelize) => {
|
up: async () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -2680,7 +2682,7 @@ module.exports = {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
down: async (queryInterface) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -91,10 +91,7 @@ router.use(checkCrudPermissions('projects'));
|
|||||||
* description: Some server error
|
* description: Some server error
|
||||||
*/
|
*/
|
||||||
router.post('/', wrapAsync(async (req, res) => {
|
router.post('/', wrapAsync(async (req, res) => {
|
||||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
const payload = await ProjectsService.create(req.body.data, req.currentUser);
|
||||||
const link = new URL(referer);
|
|
||||||
await ProjectsService.create(req.body.data, req.currentUser, true, link.host);
|
|
||||||
const payload = true;
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -304,7 +301,7 @@ router.get('/', wrapAsync(async (req, res) => {
|
|||||||
req.query, { currentUser }
|
req.query, { currentUser }
|
||||||
);
|
);
|
||||||
if (filetype && filetype === 'csv') {
|
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',
|
'start_at','end_at',
|
||||||
|
|||||||
@ -1,22 +1,143 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const ProjectsDBApi = require('../db/api/projects');
|
const ProjectsDBApi = require('../db/api/projects');
|
||||||
const processFile = require("../middlewares/upload");
|
const processFile = require('../middlewares/upload');
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
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 {
|
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) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await ProjectsDBApi.create(
|
const payload = ProjectsService.validateMealPayload(data);
|
||||||
data,
|
|
||||||
|
const createdProject = await ProjectsDBApi.create(
|
||||||
|
payload,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
@ -24,13 +145,14 @@ module.exports = class ProjectsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
return createdProject.get({ plain: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -38,7 +160,7 @@ module.exports = class ProjectsService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
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) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
@ -49,13 +171,15 @@ module.exports = class ProjectsService {
|
|||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
});
|
||||||
|
|
||||||
await ProjectsDBApi.bulkImport(results, {
|
const preparedRows = results.map((item) => ProjectsService.validateMealPayload(item));
|
||||||
|
|
||||||
|
await ProjectsDBApi.bulkImport(preparedRows, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
validate: true,
|
validate: true,
|
||||||
currentUser: req.currentUser
|
currentUser: req.currentUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@ -68,20 +192,20 @@ module.exports = class ProjectsService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let projects = await ProjectsDBApi.findBy(
|
const projects = await ProjectsDBApi.findBy(
|
||||||
{id},
|
{ id },
|
||||||
{transaction},
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!projects) {
|
if (!projects) {
|
||||||
throw new ValidationError(
|
raiseValidationError('Initiative was not found.');
|
||||||
'projectsNotFound',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = ProjectsService.validateMealPayload(data, { isUpdate: true });
|
||||||
|
|
||||||
const updatedProjects = await ProjectsDBApi.update(
|
const updatedProjects = await ProjectsDBApi.update(
|
||||||
id,
|
id,
|
||||||
data,
|
payload,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
@ -90,12 +214,11 @@ module.exports = class ProjectsService {
|
|||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedProjects;
|
return updatedProjects;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -131,8 +254,4 @@ module.exports = class ProjectsService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/meal-command-center',
|
||||||
|
icon: icon.mdiChartTimelineVariant,
|
||||||
|
label: 'MEAL Command Center',
|
||||||
|
permissions: 'READ_PROJECTS',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
|
|||||||
@ -1,166 +1,192 @@
|
|||||||
|
import * as icon from '@mdi/js';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import React, { ReactElement } from 'react';
|
||||||
import BaseButton from '../components/BaseButton';
|
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 BaseButtons from '../components/BaseButtons';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
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 workflowCards = [
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
{
|
||||||
src: undefined,
|
title: 'Create the initiative',
|
||||||
photographer: undefined,
|
description: 'Capture the programme, assign MERL/MEL/M&E framing, and define the main outcome in one structured intake.',
|
||||||
photographer_url: undefined,
|
},
|
||||||
})
|
{
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
title: 'Review the portfolio',
|
||||||
const [contentType, setContentType] = useState('video');
|
description: 'Scan active items, spotlight what needs attention, and move straight from summary to detailed admin records.',
|
||||||
const [contentPosition, setContentPosition] = useState('right');
|
},
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
{
|
||||||
|
title: 'Keep learning visible',
|
||||||
|
description: 'Anchor every record with cadence, evidence status, and a next-action prompt so learning never gets buried.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const title = 'App Preview'
|
export default function Home() {
|
||||||
|
|
||||||
// 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 (
|
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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('MEAL Operating System')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className='min-h-screen bg-[#F5F8FC] text-slate-900'>
|
||||||
<div
|
<header className='sticky top-0 z-20 border-b border-white/70 bg-[#F5F8FC]/90 backdrop-blur'>
|
||||||
className={`flex ${
|
<div className='mx-auto flex max-w-7xl items-center justify-between px-6 py-4'>
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div>
|
||||||
} min-h-screen w-full`}
|
<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 & M&E workflows</div>
|
||||||
{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>
|
</div>
|
||||||
|
<BaseButtons type='justify-start' noWrap>
|
||||||
<BaseButtons>
|
<BaseButton color='whiteDark' href='/dashboard' label='Admin interface' />
|
||||||
<BaseButton
|
<BaseButton color='info' href='/login' label='Login' />
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</CardBox>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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&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&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>
|
||||||
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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&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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
713
frontend/src/pages/meal-command-center.tsx
Normal file
713
frontend/src/pages/meal-command-center.tsx
Normal 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(/ /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&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&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;
|
||||||
Loading…
x
Reference in New Issue
Block a user