Ver 1.0
This commit is contained in:
parent
81a041b87c
commit
9918c7098f
@ -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;
|
||||
|
||||
@ -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) {
|
||||
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: {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 & M&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&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 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&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>;
|
||||
};
|
||||
|
||||
|
||||
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