Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2214f4b56 |
@ -1,7 +1,5 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
|
||||
@ -327,20 +325,56 @@ module.exports = class RequestsDBApi {
|
||||
|
||||
|
||||
|
||||
output.request_approvals_request = await requests.getRequest_approvals_request({
|
||||
const requestApprovals = await requests.getRequest_approvals_request({
|
||||
transaction
|
||||
});
|
||||
output.request_approvals_request = await Promise.all(
|
||||
requestApprovals.map(async (requestApproval) => {
|
||||
const requestApprovalOutput = requestApproval.get({ plain: true });
|
||||
requestApprovalOutput.decided_by = await requestApproval.getDecided_by({
|
||||
transaction,
|
||||
});
|
||||
|
||||
return requestApprovalOutput;
|
||||
}),
|
||||
);
|
||||
|
||||
output.documents_request = await requests.getDocuments_request({
|
||||
const documents = await requests.getDocuments_request({
|
||||
transaction
|
||||
});
|
||||
output.documents_request = await Promise.all(
|
||||
documents.map(async (document) => {
|
||||
const documentOutput = document.get({ plain: true });
|
||||
documentOutput.file_blob = await document.getFile_blob({
|
||||
transaction,
|
||||
});
|
||||
documentOutput.uploaded_by_user = await document.getUploaded_by_user({
|
||||
transaction,
|
||||
});
|
||||
|
||||
return documentOutput;
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
output.audit_events_related_request = await requests.getAudit_events_related_request({
|
||||
const auditEvents = await requests.getAudit_events_related_request({
|
||||
transaction
|
||||
});
|
||||
output.audit_events_related_request = await Promise.all(
|
||||
auditEvents.map(async (auditEvent) => {
|
||||
const auditEventOutput = auditEvent.get({ plain: true });
|
||||
auditEventOutput.actor = await auditEvent.getActor({
|
||||
transaction,
|
||||
});
|
||||
auditEventOutput.from_department = await auditEvent.getFrom_department({
|
||||
transaction,
|
||||
});
|
||||
auditEventOutput.to_department = await auditEvent.getTo_department({
|
||||
transaction,
|
||||
});
|
||||
|
||||
return auditEventOutput;
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
|
||||
@ -379,10 +413,6 @@ module.exports = class RequestsDBApi {
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
|
||||
{
|
||||
|
||||
@ -0,0 +1,278 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const config = require('../../config');
|
||||
|
||||
const DEMO_USERS = {
|
||||
admin: {
|
||||
id: '1a6f9899-51d4-4ec4-9950-f77b92f21801',
|
||||
email: 'admin',
|
||||
password: 'Tw3nde2025',
|
||||
firstName: 'Admin',
|
||||
lastName: 'Override',
|
||||
roleName: 'Administrator',
|
||||
},
|
||||
supervisor: {
|
||||
id: '6851ac05-451c-4c83-a24e-cccaec32a0e2',
|
||||
email: 'supervisor',
|
||||
password: 'password',
|
||||
firstName: 'Department',
|
||||
lastName: 'Supervisor',
|
||||
roleName: 'Department Supervisor',
|
||||
},
|
||||
director: {
|
||||
id: 'a2bc709e-4a3c-4c8c-8401-d7ea15b1a803',
|
||||
email: 'director',
|
||||
password: 'password',
|
||||
firstName: 'Executive',
|
||||
lastName: 'Director',
|
||||
roleName: 'Director',
|
||||
},
|
||||
requester: {
|
||||
id: 'e8bca47c-9b02-4586-a28d-5f9b6890d804',
|
||||
email: 'user',
|
||||
password: 'password',
|
||||
firstName: 'Request',
|
||||
lastName: 'User',
|
||||
roleName: 'Requester',
|
||||
},
|
||||
};
|
||||
|
||||
const DEMO_DEPARTMENTS = [
|
||||
{
|
||||
id: 'a7b67e85-6945-4d8e-bb6d-dac8d2af3201',
|
||||
name: 'Operations',
|
||||
code: 'OPS',
|
||||
description: 'Everyday operational and administrative requests.',
|
||||
requiresDirectorApproval: false,
|
||||
},
|
||||
{
|
||||
id: '3beff5d8-b228-4c60-a6f8-7f11f92c4602',
|
||||
name: 'Finance',
|
||||
code: 'FIN',
|
||||
description: 'Budget, payment, and finance-related approvals.',
|
||||
requiresDirectorApproval: true,
|
||||
},
|
||||
{
|
||||
id: '9fbeaf07-e468-4222-a014-25879928f303',
|
||||
name: 'Human Resources',
|
||||
code: 'HR',
|
||||
description: 'Human resources and staffing-related approvals.',
|
||||
requiresDirectorApproval: true,
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const roles = await queryInterface.sequelize.query(
|
||||
'SELECT id, name FROM "roles" WHERE name IN (:roleNames);',
|
||||
{
|
||||
replacements: {
|
||||
roleNames: Object.values(DEMO_USERS).map((user) => user.roleName),
|
||||
},
|
||||
type: Sequelize.QueryTypes.SELECT,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
const roleMap = roles.reduce((accumulator, role) => {
|
||||
accumulator[role.name] = role.id;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const missingRoles = Object.values(DEMO_USERS)
|
||||
.map((user) => user.roleName)
|
||||
.filter((roleName) => !roleMap[roleName]);
|
||||
|
||||
if (missingRoles.length) {
|
||||
throw new Error(`Missing roles required for workflow demo accounts: ${missingRoles.join(', ')}`);
|
||||
}
|
||||
|
||||
for (const user of Object.values(DEMO_USERS)) {
|
||||
const hashedPassword = await bcrypt.hash(user.password, config.bcrypt.saltRounds);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`
|
||||
INSERT INTO "users" (
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"disabled",
|
||||
"password",
|
||||
"emailVerified",
|
||||
"provider",
|
||||
"app_roleId",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
SELECT
|
||||
:id,
|
||||
:firstName,
|
||||
:lastName,
|
||||
:email,
|
||||
false,
|
||||
:password,
|
||||
true,
|
||||
:provider,
|
||||
:roleId,
|
||||
NOW(),
|
||||
NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "users" WHERE "email" = :email
|
||||
);
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
password: hashedPassword,
|
||||
provider: config.providers.LOCAL,
|
||||
roleId: roleMap[user.roleName],
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`
|
||||
UPDATE "users"
|
||||
SET
|
||||
"firstName" = :firstName,
|
||||
"lastName" = :lastName,
|
||||
"password" = :password,
|
||||
"emailVerified" = true,
|
||||
"disabled" = false,
|
||||
"provider" = :provider,
|
||||
"app_roleId" = :roleId,
|
||||
"updatedAt" = NOW()
|
||||
WHERE "email" = :email;
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
password: hashedPassword,
|
||||
provider: config.providers.LOCAL,
|
||||
roleId: roleMap[user.roleName],
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const people = await queryInterface.sequelize.query(
|
||||
'SELECT id, email FROM "users" WHERE email IN (:emails);',
|
||||
{
|
||||
replacements: {
|
||||
emails: Object.values(DEMO_USERS).map((user) => user.email),
|
||||
},
|
||||
type: Sequelize.QueryTypes.SELECT,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
const peopleMap = people.reduce((accumulator, person) => {
|
||||
accumulator[person.email] = person.id;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
for (const department of DEMO_DEPARTMENTS) {
|
||||
await queryInterface.sequelize.query(
|
||||
`
|
||||
INSERT INTO "departments" (
|
||||
"id",
|
||||
"name",
|
||||
"code",
|
||||
"description",
|
||||
"requires_director_approval",
|
||||
"supervisor_userId",
|
||||
"director_userId",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
SELECT
|
||||
:id,
|
||||
:name,
|
||||
:code,
|
||||
:description,
|
||||
:requiresDirectorApproval,
|
||||
:supervisorUserId,
|
||||
:directorUserId,
|
||||
NOW(),
|
||||
NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "departments" WHERE LOWER("code") = LOWER(:code)
|
||||
);
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
...department,
|
||||
supervisorUserId: peopleMap.supervisor,
|
||||
directorUserId: peopleMap.director,
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`
|
||||
UPDATE "departments"
|
||||
SET
|
||||
"name" = :name,
|
||||
"description" = :description,
|
||||
"requires_director_approval" = :requiresDirectorApproval,
|
||||
"supervisor_userId" = :supervisorUserId,
|
||||
"director_userId" = :directorUserId,
|
||||
"updatedAt" = NOW()
|
||||
WHERE LOWER("code") = LOWER(:code);
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
...department,
|
||||
supervisorUserId: peopleMap.supervisor,
|
||||
directorUserId: peopleMap.director,
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
await queryInterface.bulkDelete(
|
||||
'departments',
|
||||
{
|
||||
id: DEMO_DEPARTMENTS.map((department) => department.id),
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await queryInterface.bulkDelete(
|
||||
'users',
|
||||
{
|
||||
id: Object.values(DEMO_USERS).map((user) => user.id),
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -2,6 +2,7 @@
|
||||
const express = require('express');
|
||||
|
||||
const RequestsService = require('../services/requests');
|
||||
const RequestWorkflowService = require('../services/requestWorkflow');
|
||||
const RequestsDBApi = require('../db/api/requests');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
@ -396,6 +397,54 @@ router.get('/autocomplete', async (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
});
|
||||
|
||||
router.get('/workflow/summary', wrapAsync(async (req, res) => {
|
||||
const payload = await RequestWorkflowService.getSummary(req.currentUser);
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.post('/workflow/request', wrapAsync(async (req, res) => {
|
||||
const payload = await RequestWorkflowService.createRequest(
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
req,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.put('/:id/workflow-decision', wrapAsync(async (req, res) => {
|
||||
const payload = await RequestWorkflowService.decideRequest(
|
||||
req.params.id,
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
req,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.put('/:id/workflow-restart', wrapAsync(async (req, res) => {
|
||||
const payload = await RequestWorkflowService.restartRequest(
|
||||
req.params.id,
|
||||
req.currentUser,
|
||||
req,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.put('/:id/workflow-document-event', wrapAsync(async (req, res) => {
|
||||
const payload = await RequestWorkflowService.logDocumentEvent(
|
||||
req.params.id,
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
req,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/requests/{id}:
|
||||
|
||||
850
backend/src/services/requestWorkflow.js
Normal file
850
backend/src/services/requestWorkflow.js
Normal file
@ -0,0 +1,850 @@
|
||||
const db = require('../db/models');
|
||||
const RequestsDBApi = require('../db/api/requests');
|
||||
const FileDBApi = require('../db/api/file');
|
||||
|
||||
const { Op } = db.Sequelize;
|
||||
|
||||
const ACTIVE_STATUSES = ['draft', 'submitted', 'in_review', 'restarted'];
|
||||
const ADMIN_ROLES = ['administrator', 'systemowner'];
|
||||
|
||||
function normalizeRoleName(name = '') {
|
||||
return String(name).toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function isAdminLike(currentUser) {
|
||||
return ADMIN_ROLES.includes(normalizeRoleName(currentUser?.app_role?.name));
|
||||
}
|
||||
|
||||
function isDirector(currentUser) {
|
||||
return normalizeRoleName(currentUser?.app_role?.name) === 'director';
|
||||
}
|
||||
|
||||
function isSupervisor(currentUser) {
|
||||
return normalizeRoleName(currentUser?.app_role?.name) === 'departmentsupervisor';
|
||||
}
|
||||
|
||||
function isElevated(currentUser) {
|
||||
return isAdminLike(currentUser) || isDirector(currentUser) || isSupervisor(currentUser);
|
||||
}
|
||||
|
||||
function createRequestError(message) {
|
||||
const error = new Error(message);
|
||||
error.code = 400;
|
||||
return error;
|
||||
}
|
||||
|
||||
function getClientMeta(req) {
|
||||
const forwardedFor = req?.headers?.['x-forwarded-for'];
|
||||
const ipAddress = forwardedFor
|
||||
? String(forwardedFor).split(',')[0].trim()
|
||||
: req?.ip || req?.connection?.remoteAddress || null;
|
||||
|
||||
return {
|
||||
ipAddress,
|
||||
userAgent: req?.headers?.['user-agent'] || null,
|
||||
};
|
||||
}
|
||||
|
||||
function departmentNeedsDirector(department) {
|
||||
const haystack = `${department?.name || ''} ${department?.code || ''}`.toLowerCase();
|
||||
|
||||
return Boolean(department?.requires_director_approval) ||
|
||||
haystack.includes('finance') ||
|
||||
haystack.includes('human resources') ||
|
||||
haystack.includes('human resource') ||
|
||||
/\bhr\b/.test(haystack);
|
||||
}
|
||||
|
||||
async function createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action,
|
||||
entityType,
|
||||
entityKey,
|
||||
summary,
|
||||
details,
|
||||
relatedRequestId,
|
||||
relatedDocumentId,
|
||||
fromDepartmentId,
|
||||
toDepartmentId,
|
||||
}) {
|
||||
const { ipAddress, userAgent } = getClientMeta(req);
|
||||
|
||||
return db.audit_events.create(
|
||||
{
|
||||
entity_type: entityType,
|
||||
entity_key: entityKey || relatedRequestId || relatedDocumentId || currentUser?.id || null,
|
||||
action,
|
||||
summary,
|
||||
details: details || null,
|
||||
event_at: new Date(),
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
actorId: currentUser?.id || null,
|
||||
related_requestId: relatedRequestId || null,
|
||||
related_documentId: relatedDocumentId || null,
|
||||
from_departmentId: fromDepartmentId || null,
|
||||
to_departmentId: toDepartmentId || null,
|
||||
createdById: currentUser?.id || null,
|
||||
updatedById: currentUser?.id || null,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
async function generateRunningNumber(model, prefix, transaction) {
|
||||
const count = await model.count({ transaction });
|
||||
return `${prefix}-${String(count + 1).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async function getRequestForWorkflow(id, transaction) {
|
||||
return db.requests.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: db.users,
|
||||
as: 'requester',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'origin_department',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'assigned_department',
|
||||
},
|
||||
],
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
async function getPendingApproval(requestId, transaction) {
|
||||
return db.request_approvals.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
decision: 'pending',
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: db.requests,
|
||||
as: 'request',
|
||||
include: [
|
||||
{
|
||||
model: db.users,
|
||||
as: 'requester',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'origin_department',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'assigned_department',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
order: [['createdAt', 'ASC']],
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
function canSupervisorHandleRequest(request, currentUser) {
|
||||
if (!isSupervisor(currentUser)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const supervisorIds = [
|
||||
request?.origin_department?.supervisor_userId,
|
||||
request?.assigned_department?.supervisor_userId,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!supervisorIds.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return supervisorIds.includes(currentUser.id);
|
||||
}
|
||||
|
||||
function canDecideApproval(approval, currentUser) {
|
||||
if (!approval?.request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAdminLike(currentUser)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (approval.step === 'director') {
|
||||
return isDirector(currentUser);
|
||||
}
|
||||
|
||||
if (approval.step === 'supervisor_hod') {
|
||||
return canSupervisorHandleRequest(approval.request, currentUser);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapSequelizeRow(row) {
|
||||
return row?.get ? row.get({ plain: true }) : row;
|
||||
}
|
||||
|
||||
module.exports = class RequestWorkflowService {
|
||||
static async getSummary(currentUser) {
|
||||
const requestScope = isElevated(currentUser)
|
||||
? {}
|
||||
: {
|
||||
requesterId: currentUser.id,
|
||||
};
|
||||
|
||||
const departments = await db.departments.findAll({
|
||||
order: [
|
||||
['requires_director_approval', 'DESC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
});
|
||||
|
||||
const pendingApprovalRows = await db.request_approvals.findAll({
|
||||
where: {
|
||||
decision: 'pending',
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: db.requests,
|
||||
as: 'request',
|
||||
include: [
|
||||
{
|
||||
model: db.users,
|
||||
as: 'requester',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'origin_department',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'assigned_department',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['createdAt', 'ASC'],
|
||||
],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const pendingApprovals = pendingApprovalRows
|
||||
.filter((approval) => canDecideApproval(approval, currentUser))
|
||||
.slice(0, 8)
|
||||
.map((approval) => mapSequelizeRow(approval));
|
||||
|
||||
const recentRequestRows = await db.requests.findAll({
|
||||
where: requestScope,
|
||||
include: [
|
||||
{
|
||||
model: db.users,
|
||||
as: 'requester',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'origin_department',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'assigned_department',
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['updatedAt', 'DESC'],
|
||||
],
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
const recentRequests = recentRequestRows.map((request) => mapSequelizeRow(request));
|
||||
const recentRequestIds = recentRequests.map((request) => request.id);
|
||||
|
||||
const auditWhere = isElevated(currentUser)
|
||||
? {}
|
||||
: {
|
||||
[Op.or]: [
|
||||
{
|
||||
actorId: currentUser.id,
|
||||
},
|
||||
...(recentRequestIds.length
|
||||
? [
|
||||
{
|
||||
related_requestId: {
|
||||
[Op.in]: recentRequestIds,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
const recentAuditRows = await db.audit_events.findAll({
|
||||
where: auditWhere,
|
||||
include: [
|
||||
{
|
||||
model: db.users,
|
||||
as: 'actor',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'from_department',
|
||||
},
|
||||
{
|
||||
model: db.departments,
|
||||
as: 'to_department',
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['event_at', 'DESC'],
|
||||
['createdAt', 'DESC'],
|
||||
],
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
const [activeCount, approvedCount, rejectedCount] = await Promise.all([
|
||||
db.requests.count({
|
||||
where: {
|
||||
...requestScope,
|
||||
status: {
|
||||
[Op.in]: ACTIVE_STATUSES,
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.requests.count({
|
||||
where: {
|
||||
...requestScope,
|
||||
status: 'approved',
|
||||
},
|
||||
}),
|
||||
db.requests.count({
|
||||
where: {
|
||||
...requestScope,
|
||||
status: 'rejected',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const mappedDepartments = departments.map((department) => mapSequelizeRow(department));
|
||||
const featuredDepartments = mappedDepartments.filter((department) => {
|
||||
const code = String(department.code || '').toUpperCase();
|
||||
return ['OPS', 'FIN', 'HR'].includes(code);
|
||||
});
|
||||
|
||||
return {
|
||||
role: currentUser?.app_role?.name || 'User',
|
||||
canApprove: isElevated(currentUser),
|
||||
canOverride: isAdminLike(currentUser),
|
||||
departments: featuredDepartments.length ? featuredDepartments : mappedDepartments,
|
||||
stats: {
|
||||
active: activeCount,
|
||||
pendingApprovals: pendingApprovals.length,
|
||||
approved: approvedCount,
|
||||
rejected: rejectedCount,
|
||||
},
|
||||
pendingApprovals,
|
||||
recentRequests,
|
||||
recentAudit: recentAuditRows.map((audit) => mapSequelizeRow(audit)),
|
||||
};
|
||||
}
|
||||
|
||||
static async createRequest(data, currentUser, req) {
|
||||
const title = String(data?.title || '').trim();
|
||||
const description = String(data?.description || '').trim();
|
||||
const originDepartmentId = data?.origin_department;
|
||||
const assignedDepartmentId = data?.assigned_department;
|
||||
const attachment = Array.isArray(data?.attachment) ? data.attachment : [];
|
||||
|
||||
if (!title) {
|
||||
throw createRequestError('A request title is required.');
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
throw createRequestError('A request description is required.');
|
||||
}
|
||||
|
||||
if (!originDepartmentId || !assignedDepartmentId) {
|
||||
throw createRequestError('Please choose both the source and target departments.');
|
||||
}
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const [originDepartment, assignedDepartment] = await Promise.all([
|
||||
db.departments.findByPk(originDepartmentId, { transaction }),
|
||||
db.departments.findByPk(assignedDepartmentId, { transaction }),
|
||||
]);
|
||||
|
||||
if (!originDepartment || !assignedDepartment) {
|
||||
throw createRequestError('The selected department could not be found.');
|
||||
}
|
||||
|
||||
const requestNumber = await generateRunningNumber(
|
||||
db.requests,
|
||||
`REQ-${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, '0')}`,
|
||||
transaction,
|
||||
);
|
||||
|
||||
const requiresDirector = Boolean(data?.requires_director) || departmentNeedsDirector(assignedDepartment);
|
||||
|
||||
const request = await db.requests.create(
|
||||
{
|
||||
request_number: requestNumber,
|
||||
title,
|
||||
description,
|
||||
priority: data?.priority || 'medium',
|
||||
status: 'submitted',
|
||||
requires_director: requiresDirector,
|
||||
submitted_at: new Date(),
|
||||
restart_count: 0,
|
||||
rejection_reason: null,
|
||||
requesterId: currentUser.id,
|
||||
origin_departmentId: originDepartment.id,
|
||||
assigned_departmentId: assignedDepartment.id,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await db.request_approvals.create(
|
||||
{
|
||||
step: 'supervisor_hod',
|
||||
decision: 'pending',
|
||||
comment: String(data?.submission_note || '').trim() || null,
|
||||
requestId: request.id,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action: 'create',
|
||||
entityType: 'request',
|
||||
entityKey: requestNumber,
|
||||
summary: `Created ${requestNumber}`,
|
||||
details: `Submitted request "${title}" for ${assignedDepartment.name}.`,
|
||||
relatedRequestId: request.id,
|
||||
fromDepartmentId: originDepartment.id,
|
||||
toDepartmentId: assignedDepartment.id,
|
||||
});
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action: 'submit',
|
||||
entityType: 'request',
|
||||
entityKey: requestNumber,
|
||||
summary: `${requestNumber} entered the approval queue`,
|
||||
details: requiresDirector
|
||||
? 'The request will continue to director approval after supervisor review.'
|
||||
: 'The request is waiting for supervisor review.',
|
||||
relatedRequestId: request.id,
|
||||
fromDepartmentId: originDepartment.id,
|
||||
toDepartmentId: assignedDepartment.id,
|
||||
});
|
||||
|
||||
if (attachment.length) {
|
||||
const file = attachment[0];
|
||||
const documentNumber = await generateRunningNumber(
|
||||
db.documents,
|
||||
`DOC-${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, '0')}`,
|
||||
transaction,
|
||||
);
|
||||
|
||||
const document = await db.documents.create(
|
||||
{
|
||||
document_number: documentNumber,
|
||||
title: data?.document_title || `${title} Attachment`,
|
||||
document_type: 'request_attachment',
|
||||
original_filename: file.name || null,
|
||||
file_size_bytes: file.sizeInBytes || null,
|
||||
visibility: 'private',
|
||||
uploaded_at: new Date(),
|
||||
is_active: true,
|
||||
uploaded_by_userId: currentUser.id,
|
||||
requestId: request.id,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
{
|
||||
belongsTo: db.documents.getTableName(),
|
||||
belongsToColumn: 'file_blob',
|
||||
belongsToId: document.id,
|
||||
},
|
||||
attachment,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action: 'upload',
|
||||
entityType: 'document',
|
||||
entityKey: documentNumber,
|
||||
summary: `Uploaded ${file.name || documentNumber}`,
|
||||
details: `Attached the supporting document to ${requestNumber}.`,
|
||||
relatedRequestId: request.id,
|
||||
relatedDocumentId: document.id,
|
||||
toDepartmentId: assignedDepartment.id,
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return RequestsDBApi.findBy({ id: request.id });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async decideRequest(requestId, data, currentUser, req) {
|
||||
const decision = String(data?.decision || '').trim().toLowerCase();
|
||||
const comment = String(data?.comment || '').trim();
|
||||
|
||||
if (!['approved', 'rejected'].includes(decision)) {
|
||||
throw createRequestError('Decision must be either approved or rejected.');
|
||||
}
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const request = await getRequestForWorkflow(requestId, transaction);
|
||||
|
||||
if (!request) {
|
||||
throw createRequestError('Request not found.');
|
||||
}
|
||||
|
||||
const pendingApproval = await getPendingApproval(requestId, transaction);
|
||||
const adminLike = isAdminLike(currentUser);
|
||||
const now = new Date();
|
||||
|
||||
if (pendingApproval && !canDecideApproval(pendingApproval, currentUser)) {
|
||||
throw createRequestError('You do not have permission to decide this request.');
|
||||
}
|
||||
|
||||
if (!pendingApproval && !adminLike) {
|
||||
throw createRequestError('There is no pending approval step for this request.');
|
||||
}
|
||||
|
||||
if (adminLike) {
|
||||
if (pendingApproval) {
|
||||
await pendingApproval.update(
|
||||
{
|
||||
decision: 'overridden',
|
||||
decided_at: now,
|
||||
decided_byId: currentUser.id,
|
||||
comment: comment || 'Decision overridden by administrator.',
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
await db.request_approvals.create(
|
||||
{
|
||||
step: 'admin_override',
|
||||
decision,
|
||||
decided_at: now,
|
||||
comment: comment || null,
|
||||
requestId: request.id,
|
||||
decided_byId: currentUser.id,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await request.update(
|
||||
{
|
||||
status: decision === 'approved' ? 'approved' : 'rejected',
|
||||
rejection_reason: decision === 'rejected' ? comment || 'Rejected by administrator.' : null,
|
||||
finalized_at: now,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action: 'override',
|
||||
entityType: 'request',
|
||||
entityKey: request.request_number,
|
||||
summary: `Administrator overrode ${request.request_number}`,
|
||||
details: comment || `Administrator marked the request as ${decision}.`,
|
||||
relatedRequestId: request.id,
|
||||
fromDepartmentId: request.origin_departmentId,
|
||||
toDepartmentId: request.assigned_departmentId,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
return RequestsDBApi.findBy({ id: request.id });
|
||||
}
|
||||
|
||||
await pendingApproval.update(
|
||||
{
|
||||
decision,
|
||||
decided_at: now,
|
||||
decided_byId: currentUser.id,
|
||||
comment: comment || null,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
if (decision === 'rejected') {
|
||||
await request.update(
|
||||
{
|
||||
status: 'rejected',
|
||||
rejection_reason: comment || 'Request rejected.',
|
||||
finalized_at: now,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action: 'reject',
|
||||
entityType: 'request',
|
||||
entityKey: request.request_number,
|
||||
summary: `${request.request_number} was rejected`,
|
||||
details: comment || 'Approval flow stopped at the current stage.',
|
||||
relatedRequestId: request.id,
|
||||
fromDepartmentId: request.origin_departmentId,
|
||||
toDepartmentId: request.assigned_departmentId,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
return RequestsDBApi.findBy({ id: request.id });
|
||||
}
|
||||
|
||||
const needsDirector = Boolean(request.requires_director) || departmentNeedsDirector(request.assigned_department);
|
||||
|
||||
if (pendingApproval.step === 'supervisor_hod' && needsDirector) {
|
||||
await db.request_approvals.create(
|
||||
{
|
||||
step: 'director',
|
||||
decision: 'pending',
|
||||
requestId: request.id,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await request.update(
|
||||
{
|
||||
status: 'in_review',
|
||||
requires_director: true,
|
||||
rejection_reason: null,
|
||||
finalized_at: null,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action: 'approve',
|
||||
entityType: 'request',
|
||||
entityKey: request.request_number,
|
||||
summary: `Supervisor approved ${request.request_number}`,
|
||||
details: 'The request has been escalated to the director.',
|
||||
relatedRequestId: request.id,
|
||||
fromDepartmentId: request.origin_departmentId,
|
||||
toDepartmentId: request.assigned_departmentId,
|
||||
});
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action: 'assign',
|
||||
entityType: 'request',
|
||||
entityKey: request.request_number,
|
||||
summary: `${request.request_number} moved to director review`,
|
||||
details: 'Director approval is required for the assigned department.',
|
||||
relatedRequestId: request.id,
|
||||
fromDepartmentId: request.origin_departmentId,
|
||||
toDepartmentId: request.assigned_departmentId,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
return RequestsDBApi.findBy({ id: request.id });
|
||||
}
|
||||
|
||||
await request.update(
|
||||
{
|
||||
status: 'approved',
|
||||
rejection_reason: null,
|
||||
finalized_at: now,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action: 'approve',
|
||||
entityType: 'request',
|
||||
entityKey: request.request_number,
|
||||
summary: `${request.request_number} was approved`,
|
||||
details: comment || 'The request completed the approval chain.',
|
||||
relatedRequestId: request.id,
|
||||
fromDepartmentId: request.origin_departmentId,
|
||||
toDepartmentId: request.assigned_departmentId,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
return RequestsDBApi.findBy({ id: request.id });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async restartRequest(requestId, currentUser, req) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const request = await getRequestForWorkflow(requestId, transaction);
|
||||
|
||||
if (!request) {
|
||||
throw createRequestError('Request not found.');
|
||||
}
|
||||
|
||||
const canRestart = isAdminLike(currentUser) || request.requesterId === currentUser.id;
|
||||
|
||||
if (!canRestart) {
|
||||
throw createRequestError('Only the original requester or an administrator can restart this request.');
|
||||
}
|
||||
|
||||
if (request.status !== 'rejected') {
|
||||
throw createRequestError('Only rejected requests can be restarted.');
|
||||
}
|
||||
|
||||
await request.update(
|
||||
{
|
||||
status: 'restarted',
|
||||
submitted_at: new Date(),
|
||||
finalized_at: null,
|
||||
rejection_reason: null,
|
||||
restart_count: (request.restart_count || 0) + 1,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await db.request_approvals.create(
|
||||
{
|
||||
step: 'supervisor_hod',
|
||||
decision: 'pending',
|
||||
requestId: request.id,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action: 'restart',
|
||||
entityType: 'request',
|
||||
entityKey: request.request_number,
|
||||
summary: `${request.request_number} restarted`,
|
||||
details: 'The request was sent back to the beginning of the decision flow.',
|
||||
relatedRequestId: request.id,
|
||||
fromDepartmentId: request.origin_departmentId,
|
||||
toDepartmentId: request.assigned_departmentId,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
return RequestsDBApi.findBy({ id: request.id });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async logDocumentEvent(requestId, data, currentUser, req) {
|
||||
const action = String(data?.action || '').trim().toLowerCase();
|
||||
const documentId = data?.documentId;
|
||||
|
||||
if (!['preview', 'download'].includes(action)) {
|
||||
throw createRequestError('Unsupported document event action.');
|
||||
}
|
||||
|
||||
if (!documentId) {
|
||||
throw createRequestError('A document is required for this event.');
|
||||
}
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const request = await db.requests.findByPk(requestId, { transaction });
|
||||
const document = await db.documents.findOne({
|
||||
where: {
|
||||
id: documentId,
|
||||
requestId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!request || !document) {
|
||||
throw createRequestError('The selected attachment could not be found.');
|
||||
}
|
||||
|
||||
await createAuditEvent({
|
||||
transaction,
|
||||
currentUser,
|
||||
req,
|
||||
action,
|
||||
entityType: 'document',
|
||||
entityKey: document.document_number || document.id,
|
||||
summary: `${action === 'preview' ? 'Previewed' : 'Downloaded'} ${document.original_filename || document.title}`,
|
||||
details: `Activity recorded for ${request.request_number || request.id}.`,
|
||||
relatedRequestId: request.id,
|
||||
relatedDocumentId: document.id,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
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'
|
||||
|
||||
81
frontend/src/helpers/requestWorkflow.ts
Normal file
81
frontend/src/helpers/requestWorkflow.ts
Normal file
@ -0,0 +1,81 @@
|
||||
export const normalizeRoleName = (roleName?: string) =>
|
||||
String(roleName || '')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '');
|
||||
|
||||
export const isAdminLike = (currentUser?: any) => {
|
||||
const normalizedRole = normalizeRoleName(currentUser?.app_role?.name);
|
||||
|
||||
return normalizedRole === 'administrator' || normalizedRole === 'systemowner';
|
||||
};
|
||||
|
||||
export const isDirectorRole = (currentUser?: any) => {
|
||||
return normalizeRoleName(currentUser?.app_role?.name) === 'director';
|
||||
};
|
||||
|
||||
export const isSupervisorRole = (currentUser?: any) => {
|
||||
return normalizeRoleName(currentUser?.app_role?.name) === 'departmentsupervisor';
|
||||
};
|
||||
|
||||
export const getRequestStatusClasses = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'bg-emerald-500/15 text-emerald-700 ring-1 ring-emerald-400/40 dark:text-emerald-300';
|
||||
case 'rejected':
|
||||
return 'bg-rose-500/15 text-rose-700 ring-1 ring-rose-400/40 dark:text-rose-300';
|
||||
case 'in_review':
|
||||
return 'bg-amber-500/15 text-amber-700 ring-1 ring-amber-400/40 dark:text-amber-300';
|
||||
case 'submitted':
|
||||
return 'bg-sky-500/15 text-sky-700 ring-1 ring-sky-400/40 dark:text-sky-300';
|
||||
case 'restarted':
|
||||
return 'bg-violet-500/15 text-violet-700 ring-1 ring-violet-400/40 dark:text-violet-300';
|
||||
default:
|
||||
return 'bg-slate-500/10 text-slate-700 ring-1 ring-slate-400/30 dark:text-slate-300';
|
||||
}
|
||||
};
|
||||
|
||||
export const getPriorityClasses = (priority?: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return 'text-rose-600 dark:text-rose-300';
|
||||
case 'high':
|
||||
return 'text-amber-600 dark:text-amber-300';
|
||||
case 'medium':
|
||||
return 'text-sky-600 dark:text-sky-300';
|
||||
default:
|
||||
return 'text-emerald-600 dark:text-emerald-300';
|
||||
}
|
||||
};
|
||||
|
||||
export const getApprovalStepLabel = (step?: string) => {
|
||||
switch (step) {
|
||||
case 'supervisor_hod':
|
||||
return 'Supervisor review';
|
||||
case 'director':
|
||||
return 'Director review';
|
||||
case 'finance_hr':
|
||||
return 'Finance / HR review';
|
||||
case 'admin_override':
|
||||
return 'Admin override';
|
||||
default:
|
||||
return 'Decision pending';
|
||||
}
|
||||
};
|
||||
|
||||
export const getRoleAccentClasses = (roleName?: string) => {
|
||||
const normalizedRole = normalizeRoleName(roleName);
|
||||
|
||||
if (normalizedRole === 'administrator' || normalizedRole === 'systemowner') {
|
||||
return 'bg-[#ffe169]/20 text-[#7a5a00] ring-1 ring-[#ffd84d]/50';
|
||||
}
|
||||
|
||||
if (normalizedRole === 'director') {
|
||||
return 'bg-[#34d399]/20 text-[#065f46] ring-1 ring-[#34d399]/40';
|
||||
}
|
||||
|
||||
if (normalizedRole === 'departmentsupervisor') {
|
||||
return 'bg-[#2dd4bf]/20 text-[#115e59] ring-1 ring-[#2dd4bf]/40';
|
||||
}
|
||||
|
||||
return 'bg-white/10 text-white/90 ring-1 ring-white/20';
|
||||
};
|
||||
@ -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: '/workflow-center',
|
||||
icon: icon.mdiChartTimelineVariant,
|
||||
label: 'Workflow Center',
|
||||
permissions: 'READ_REQUESTS',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
|
||||
@ -1,161 +1,143 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import type { 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 { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
const featureCards = [
|
||||
{
|
||||
title: 'Request capture',
|
||||
description: 'Submit requests with department routing, urgency, and an attached support document from one polished form.',
|
||||
},
|
||||
{
|
||||
title: 'Decision management',
|
||||
description: 'Move work from requester to supervisor, escalate Finance and HR to the director, and allow admin overrides when needed.',
|
||||
},
|
||||
{
|
||||
title: 'Document traceability',
|
||||
description: 'Preview and download attachments, record who uploaded them, and preserve an audit trail for every movement.',
|
||||
},
|
||||
{
|
||||
title: 'Admin control',
|
||||
description: 'Manage users, departments, and system header/footer content from the existing dashboard and settings area.',
|
||||
},
|
||||
];
|
||||
|
||||
const roleCards = [
|
||||
{
|
||||
role: 'User',
|
||||
summary: 'Creates requests, attaches a document, checks status, and restarts declined requests.',
|
||||
},
|
||||
{
|
||||
role: 'Supervisor',
|
||||
summary: 'Reviews the first decision stage and sends Finance / HR requests onward when approved.',
|
||||
},
|
||||
{
|
||||
role: 'Director / Admin',
|
||||
summary: 'Approves escalated work, overrides final outcomes, and monitors the full audit trail.',
|
||||
},
|
||||
];
|
||||
|
||||
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('image');
|
||||
const [contentPosition, setContentPosition] = useState('background');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'Request & Approval Manager'
|
||||
|
||||
// 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>)
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<div className='min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(255,225,105,0.3),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.22),_transparent_34%),linear-gradient(135deg,_#06281f,_#0b3d2f_55%,_#0a5a44)] text-white'>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Request & Approval Manager')}</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 Request & Approval Manager app!"/>
|
||||
<div className='mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-8 lg:px-10'>
|
||||
<header className='flex flex-col gap-4 rounded-[32px] border border-white/10 bg-white/5 px-6 py-5 backdrop-blur-xl md:flex-row md:items-center md:justify-between'>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-[0.24em] text-white/60'>Internal workflow platform</p>
|
||||
<h1 className='mt-2 text-2xl font-semibold'>Request & Approval Manager</h1>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton href='/login' color='whiteDark' label='Login' />
|
||||
<BaseButton href='/dashboard' color='info' label='Admin interface' />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center '>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 '>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<main className='flex flex-1 flex-col justify-center py-12'>
|
||||
<section className='grid gap-8 lg:grid-cols-[1.15fr_0.85fr] lg:items-center'>
|
||||
<div className='space-y-6'>
|
||||
<div className='inline-flex rounded-full border border-[#ffe169]/40 bg-[#ffe169]/15 px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#ffe169]'>
|
||||
Yellow Emerald design system
|
||||
</div>
|
||||
<div className='space-y-5'>
|
||||
<h2 className='max-w-4xl text-5xl font-semibold leading-tight tracking-tight md:text-6xl'>
|
||||
A modern decision workspace for internal requests, approvals, and document handoffs.
|
||||
</h2>
|
||||
<p className='max-w-3xl text-lg leading-8 text-white/75'>
|
||||
The first MVP slice is already focused on the real workflow: requesters can submit with an attachment, approvers get a queue,
|
||||
admins can override decisions, and the request detail screen now exposes approvals, documents, and audit history together.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<BaseButton href='/login' color='info' label='Open login' />
|
||||
<BaseButton href='/workflow-center' color='whiteDark' label='Open workflow center' />
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
{roleCards.map((card) => (
|
||||
<div key={card.role} className='rounded-[28px] border border-white/10 bg-white/5 p-5 backdrop-blur-xl'>
|
||||
<p className='text-xs uppercase tracking-[0.18em] text-[#ffe169]'>{card.role}</p>
|
||||
<p className='mt-3 text-sm leading-6 text-white/75'>{card.summary}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
<CardBox className='border border-white/10 bg-white/5 text-white shadow-2xl backdrop-blur-xl'>
|
||||
<div className='space-y-5'>
|
||||
<div className='rounded-[28px] border border-white/10 bg-gradient-to-br from-[#ffe169]/20 to-transparent p-5'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-white/60'>Included in this iteration</p>
|
||||
<h3 className='mt-3 text-2xl font-semibold'>Thin slice, end to end</h3>
|
||||
<p className='mt-3 text-sm leading-7 text-white/75'>
|
||||
Create a request, attach a document, approve or reject it, restart on decline, and review the resulting audit trail without rebuilding generic CRUD.
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-3 rounded-[28px] border border-white/10 bg-[#0b3429]/70 p-5'>
|
||||
<p className='text-xs uppercase tracking-[0.18em] text-[#ffe169]'>Admin shortcuts</p>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<BaseButton href='/users/users-list' color='whiteDark' label='Users' />
|
||||
<BaseButton href='/departments/departments-list' color='whiteDark' label='Departments' />
|
||||
<BaseButton href='/document_intakes/document_intakes-list' color='whiteDark' label='Front desk intake' />
|
||||
<BaseButton href='/system_settings/system_settings-list' color='whiteDark' label='Header & footer' />
|
||||
</div>
|
||||
</div>
|
||||
<p className='rounded-[28px] border border-[#34d399]/30 bg-[#34d399]/10 px-4 py-4 text-sm leading-6 text-white/80'>
|
||||
Tip: the login page now includes quick-fill demo credentials for admin, supervisor, director, and user accounts.
|
||||
</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
</section>
|
||||
|
||||
</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>
|
||||
<section className='mt-12 grid gap-5 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{featureCards.map((feature) => (
|
||||
<div key={feature.title} className='rounded-[28px] border border-white/10 bg-white/5 p-6 backdrop-blur-xl'>
|
||||
<p className='text-sm font-semibold text-[#ffe169]'>{feature.title}</p>
|
||||
<p className='mt-3 text-sm leading-7 text-white/75'>{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className='flex flex-col gap-3 border-t border-white/10 pt-6 text-sm text-white/60 md:flex-row md:items-center md:justify-between'>
|
||||
<p>© 2026 Request & Approval Manager. Built for server-side approval workflows.</p>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<Link className='transition hover:text-white' href='/login'>
|
||||
Login
|
||||
</Link>
|
||||
<Link className='transition hover:text-white' href='/dashboard'>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link className='transition hover:text-white' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -163,4 +145,3 @@ export default function Starter() {
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -1,273 +1,218 @@
|
||||
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import BaseIcon from "../components/BaseIcon";
|
||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import React, { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import FormField from '../components/FormField';
|
||||
import Link from 'next/link';
|
||||
import { ToastContainer, toast } from 'react-toastify';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import FormCheckRadio from '../components/FormCheckRadio';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { useRouter } from 'next/router';
|
||||
import FormField from '../components/FormField';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import Link from 'next/link';
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||
|
||||
const demoAccounts = [
|
||||
{
|
||||
role: 'Admin',
|
||||
login: 'admin',
|
||||
password: 'Tw3nde2025',
|
||||
description: 'Can override or reject final decisions and manage the system.',
|
||||
},
|
||||
{
|
||||
role: 'Supervisor',
|
||||
login: 'supervisor',
|
||||
password: 'password',
|
||||
description: 'Can approve or reject requests at the first decision stage.',
|
||||
},
|
||||
{
|
||||
role: 'Director',
|
||||
login: 'director',
|
||||
password: 'password',
|
||||
description: 'Can approve escalated Finance and HR requests across departments.',
|
||||
},
|
||||
{
|
||||
role: 'User',
|
||||
login: 'user',
|
||||
password: 'password',
|
||||
description: 'Can create requests, attach a document, and restart rejected items.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||
const notify = (type, msg) => toast(msg, { type });
|
||||
const [ illustrationImage, setIllustrationImage ] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('background');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
|
||||
password: '806bd392',
|
||||
remember: true })
|
||||
const { currentUser, isFetching, errorMessage, token, notify } = useAppSelector((state) => state.auth);
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [initialValues, setInitialValues] = React.useState({
|
||||
email: 'admin',
|
||||
password: 'Tw3nde2025',
|
||||
remember: true,
|
||||
});
|
||||
|
||||
const title = 'Request & Approval Manager'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect( () => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage()
|
||||
const video = await getPexelsVideo()
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
dispatch(findMe());
|
||||
}
|
||||
}, [token, dispatch]);
|
||||
// Redirect to dashboard if user is logged in
|
||||
}, [dispatch, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.id) {
|
||||
router.push('/dashboard');
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
}, [currentUser?.id, router]);
|
||||
// Show error message if there is one
|
||||
}, [currentUser?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMessage){
|
||||
notify('error', errorMessage)
|
||||
if (errorMessage) {
|
||||
toast(errorMessage, { type: 'error' });
|
||||
}
|
||||
}, [errorMessage]);
|
||||
|
||||
}, [errorMessage])
|
||||
// Show notification if there is one
|
||||
useEffect(() => {
|
||||
if (notifyState?.showNotification) {
|
||||
notify('success', notifyState?.textNotification)
|
||||
dispatch(resetAction());
|
||||
}
|
||||
}, [notifyState?.showNotification])
|
||||
if (notify?.showNotification) {
|
||||
toast(notify.textNotification, { type: 'success' });
|
||||
dispatch(resetAction());
|
||||
}
|
||||
}, [dispatch, notify?.showNotification, notify?.textNotification]);
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
const applyDemoLogin = (login: string, password: string) => {
|
||||
setInitialValues({
|
||||
email: login,
|
||||
password,
|
||||
remember: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (value) => {
|
||||
const {remember, ...rest} = value
|
||||
await dispatch(loginUser(rest));
|
||||
};
|
||||
|
||||
const setLogin = (target: HTMLElement) => {
|
||||
setInitialValues(prev => ({
|
||||
...prev,
|
||||
email : target.innerText.trim(),
|
||||
password: target.dataset.password ?? '',
|
||||
}));
|
||||
};
|
||||
|
||||
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 handleSubmit = async (values) => {
|
||||
const { remember, ...credentials } = values;
|
||||
await dispatch(loginUser(credentials));
|
||||
};
|
||||
|
||||
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('Login')}</title>
|
||||
</Head>
|
||||
<div className='min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(255,225,105,0.26),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.22),_transparent_34%),linear-gradient(135deg,_#06281f,_#0b3d2f_55%,_#0a5a44)] text-white'>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</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 id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||
|
||||
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
||||
|
||||
<div className='flex flex-row justify-between'>
|
||||
<div>
|
||||
|
||||
<p className='mb-2'>Use{' '}
|
||||
<code className={`cursor-pointer ${textColor} `}
|
||||
data-password="806bd392"
|
||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>806bd392</code>{' / '}
|
||||
to login as Admin</p>
|
||||
<p>Use <code
|
||||
className={`cursor-pointer ${textColor} `}
|
||||
data-password="618e272f4df4"
|
||||
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
||||
<code className={`${textColor}`}>618e272f4df4</code>{' / '}
|
||||
to login as User</p>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w='w-16'
|
||||
h='h-16'
|
||||
size={48}
|
||||
path={mdiInformation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
enableReinitialize
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<FormField
|
||||
label='Login'
|
||||
help='Please enter your login'>
|
||||
<Field name='email' />
|
||||
</FormField>
|
||||
|
||||
<div className='relative'>
|
||||
<FormField
|
||||
label='Password'
|
||||
help='Please enter your password'>
|
||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
||||
</FormField>
|
||||
<div
|
||||
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
|
||||
onClick={togglePasswordVisibility}
|
||||
>
|
||||
<BaseIcon
|
||||
className='text-gray-500 hover:text-gray-700'
|
||||
size={20}
|
||||
path={showPassword ? mdiEyeOff : mdiEye}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-between'}>
|
||||
<FormCheckRadio type='checkbox' label='Remember'>
|
||||
<Field type='checkbox' name='remember' />
|
||||
</FormCheckRadio>
|
||||
|
||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
className={'w-full'}
|
||||
type='submit'
|
||||
label={isFetching ? 'Loading...' : 'Login'}
|
||||
color='info'
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</BaseButtons>
|
||||
<br />
|
||||
<p className={'text-center'}>
|
||||
Don’t have an account yet?{' '}
|
||||
<Link className={`${textColor}`} href={'/register'}>
|
||||
New Account
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
</div>
|
||||
<div className='mx-auto grid min-h-screen max-w-7xl gap-8 px-6 py-8 lg:grid-cols-[1.08fr_0.92fr] lg:items-center lg:px-10'>
|
||||
<section className='space-y-8'>
|
||||
<div className='inline-flex rounded-full border border-[#ffe169]/40 bg-[#ffe169]/15 px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#ffe169]'>
|
||||
Yellow Emerald login
|
||||
</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>
|
||||
<ToastContainer />
|
||||
<div className='space-y-5'>
|
||||
<h1 className='text-5xl font-semibold leading-tight tracking-tight md:text-6xl'>
|
||||
Login to the request and approval command center.
|
||||
</h1>
|
||||
<p className='max-w-3xl text-lg leading-8 text-white/75'>
|
||||
This glassmorphic sign-in experience is tuned for the internal workflow: requester submission, supervisor review,
|
||||
director escalation, and administrator override — all against SQL-backed data, not browser-only state.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
{demoAccounts.map((account) => (
|
||||
<button
|
||||
key={account.role}
|
||||
type='button'
|
||||
onClick={() => applyDemoLogin(account.login, account.password)}
|
||||
className='rounded-[28px] border border-white/10 bg-white/5 p-5 text-left transition duration-150 hover:bg-white/10'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#ffe169]'>{account.role}</p>
|
||||
<span className='rounded-full bg-white/10 px-3 py-1 text-xs text-white/70'>Use demo</span>
|
||||
</div>
|
||||
<p className='mt-3 text-base font-semibold'>{account.login}</p>
|
||||
<p className='mt-1 text-xs uppercase tracking-[0.18em] text-white/50'>{account.password}</p>
|
||||
<p className='mt-4 text-sm leading-6 text-white/70'>{account.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='rounded-[28px] border border-white/10 bg-white/5 p-6 text-sm leading-7 text-white/75 backdrop-blur-xl'>
|
||||
<p className='font-semibold text-white'>What this role-aware build already covers</p>
|
||||
<ul className='mt-3 space-y-2'>
|
||||
<li>• Workflow Center for request creation, approval queue, restart handling, and audit visibility.</li>
|
||||
<li>• Request detail page with document preview/download plus movement logging.</li>
|
||||
<li>• Direct links into users, departments, front desk intake, and header/footer settings.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CardBox className='border border-white/10 bg-white/10 text-white shadow-2xl backdrop-blur-2xl'>
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<p className='text-sm uppercase tracking-[0.22em] text-white/60'>Secure access</p>
|
||||
<h2 className='mt-3 text-3xl font-semibold'>Welcome back</h2>
|
||||
<p className='mt-3 text-sm leading-7 text-white/70'>
|
||||
Choose a role above to prefill the credentials or enter them manually below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Formik initialValues={initialValues} enableReinitialize onSubmit={handleSubmit}>
|
||||
<Form>
|
||||
<FormField label='Login' labelFor='email' help='Use one of the demo usernames above or your own account.'>
|
||||
<Field id='email' name='email' placeholder='admin' />
|
||||
</FormField>
|
||||
|
||||
<div className='relative'>
|
||||
<FormField label='Password' labelFor='password' help='The password stays hidden until you choose to reveal it.'>
|
||||
<Field id='password' name='password' type={showPassword ? 'text' : 'password'} placeholder='Enter your password' />
|
||||
</FormField>
|
||||
<button
|
||||
type='button'
|
||||
className='absolute right-4 top-[54px] text-white/50 transition hover:text-white'
|
||||
onClick={() => setShowPassword((currentValue) => !currentValue)}
|
||||
>
|
||||
<BaseIcon size={20} path={showPassword ? mdiEyeOff : mdiEye} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center justify-between gap-3 text-sm text-white/70'>
|
||||
<FormCheckRadio type='checkbox' label='Remember'>
|
||||
<Field type='checkbox' name='remember' />
|
||||
</FormCheckRadio>
|
||||
<Link className='transition hover:text-white' href='/forgot'>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 flex flex-wrap gap-3'>
|
||||
<BaseButton
|
||||
className='flex-1'
|
||||
type='submit'
|
||||
color='info'
|
||||
label={isFetching ? 'Logging in...' : 'Login to dashboard'}
|
||||
disabled={isFetching}
|
||||
/>
|
||||
<BaseButton href='/' color='whiteDark' label='Back home' />
|
||||
</div>
|
||||
</Form>
|
||||
</Formik>
|
||||
|
||||
<div className='rounded-[28px] border border-[#34d399]/30 bg-[#34d399]/10 p-5 text-sm leading-7 text-white/75'>
|
||||
<p className='font-semibold text-white'>Need a guided start?</p>
|
||||
<p className='mt-1'>Log in as <span className='font-semibold text-[#ffe169]'>user</span> to submit a request, then switch to <span className='font-semibold text-[#ffe169]'>supervisor</span> or <span className='font-semibold text-[#ffe169]'>director</span> to move it through the approval chain.</p>
|
||||
</div>
|
||||
|
||||
<p className='text-center text-sm text-white/60'>
|
||||
Don’t have an account yet?{' '}
|
||||
<Link className='font-semibold text-[#ffe169] transition hover:text-white' href='/register'>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<div className='border-t border-white/10 px-6 py-6 text-center text-sm text-white/60 lg:px-10'>
|
||||
<p>
|
||||
© 2026 Request & Approval Manager • <Link className='transition hover:text-white' href='/privacy-policy/'>Privacy Policy</Link>
|
||||
</p>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
811
frontend/src/pages/workflow-center.tsx
Normal file
811
frontend/src/pages/workflow-center.tsx
Normal file
@ -0,0 +1,811 @@
|
||||
import {
|
||||
mdiChartTimelineVariant,
|
||||
mdiClipboardTextClock,
|
||||
mdiCogOutline,
|
||||
mdiDownload,
|
||||
mdiFileDocumentEditOutline,
|
||||
mdiRestart,
|
||||
mdiUpload,
|
||||
mdiViewDashboardOutline,
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import CardBox from '../components/CardBox';
|
||||
import CardBoxModal from '../components/CardBoxModal';
|
||||
import FormField from '../components/FormField';
|
||||
import FormFilePicker from '../components/FormFilePicker';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import { hasPermission } from '../helpers/userPermissions';
|
||||
import {
|
||||
getApprovalStepLabel,
|
||||
getPriorityClasses,
|
||||
getRequestStatusClasses,
|
||||
getRoleAccentClasses,
|
||||
isAdminLike,
|
||||
isDirectorRole,
|
||||
isSupervisorRole,
|
||||
} from '../helpers/requestWorkflow';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
|
||||
interface WorkflowDepartment {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
requires_director_approval?: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowRequest {
|
||||
id: string;
|
||||
request_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
submitted_at?: string;
|
||||
updatedAt?: string;
|
||||
requester?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
origin_department?: WorkflowDepartment;
|
||||
assigned_department?: WorkflowDepartment;
|
||||
}
|
||||
|
||||
interface WorkflowApproval {
|
||||
id: string;
|
||||
step: string;
|
||||
requestId?: string;
|
||||
request?: WorkflowRequest;
|
||||
}
|
||||
|
||||
interface WorkflowAuditItem {
|
||||
id: string;
|
||||
action: string;
|
||||
summary: string;
|
||||
details?: string;
|
||||
event_at?: string;
|
||||
actor?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
from_department?: WorkflowDepartment;
|
||||
to_department?: WorkflowDepartment;
|
||||
}
|
||||
|
||||
interface WorkflowSummary {
|
||||
role: string;
|
||||
canApprove: boolean;
|
||||
canOverride: boolean;
|
||||
departments: WorkflowDepartment[];
|
||||
stats: {
|
||||
active: number;
|
||||
pendingApprovals: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
};
|
||||
pendingApprovals: WorkflowApproval[];
|
||||
recentRequests: WorkflowRequest[];
|
||||
recentAudit: WorkflowAuditItem[];
|
||||
}
|
||||
|
||||
const initialRequestValues = {
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
origin_department: '',
|
||||
assigned_department: '',
|
||||
submission_note: '',
|
||||
attachment: [],
|
||||
};
|
||||
|
||||
const WorkflowCenterPage = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [summary, setSummary] = React.useState<WorkflowSummary | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [pageError, setPageError] = React.useState('');
|
||||
const [feedback, setFeedback] = React.useState<{
|
||||
type: 'success' | 'error';
|
||||
text: string;
|
||||
requestId?: string;
|
||||
} | null>(null);
|
||||
const [decisionState, setDecisionState] = React.useState<{
|
||||
requestId: string;
|
||||
action: 'approved' | 'rejected';
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const [decisionComment, setDecisionComment] = React.useState('');
|
||||
const [busyRequestId, setBusyRequestId] = React.useState('');
|
||||
|
||||
const loadSummary = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setPageError('');
|
||||
|
||||
try {
|
||||
const response = await axios.get('/requests/workflow/summary');
|
||||
setSummary(response.data);
|
||||
} catch (error) {
|
||||
const errorMessage = axios.isAxiosError(error)
|
||||
? String(error.response?.data || error.message)
|
||||
: 'Unable to load your request workflow right now.';
|
||||
setPageError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentUser?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadSummary();
|
||||
}, [currentUser?.id, loadSummary]);
|
||||
|
||||
const closeDecisionModal = () => {
|
||||
setDecisionState(null);
|
||||
setDecisionComment('');
|
||||
};
|
||||
|
||||
const openDecisionModal = (
|
||||
requestId: string,
|
||||
action: 'approved' | 'rejected',
|
||||
title: string,
|
||||
) => {
|
||||
setDecisionState({ requestId, action, title });
|
||||
setDecisionComment('');
|
||||
};
|
||||
|
||||
const submitDecision = async () => {
|
||||
if (!decisionState) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyRequestId(decisionState.requestId);
|
||||
setFeedback(null);
|
||||
|
||||
try {
|
||||
await axios.put(`/requests/${decisionState.requestId}/workflow-decision`, {
|
||||
data: {
|
||||
decision: decisionState.action,
|
||||
comment: decisionComment,
|
||||
},
|
||||
});
|
||||
|
||||
setFeedback({
|
||||
type: 'success',
|
||||
text: `${decisionState.title} was ${decisionState.action}.`,
|
||||
requestId: decisionState.requestId,
|
||||
});
|
||||
closeDecisionModal();
|
||||
await loadSummary();
|
||||
} catch (error) {
|
||||
const errorMessage = axios.isAxiosError(error)
|
||||
? String(error.response?.data || error.message)
|
||||
: 'Unable to apply that decision right now.';
|
||||
setFeedback({
|
||||
type: 'error',
|
||||
text: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setBusyRequestId('');
|
||||
}
|
||||
};
|
||||
|
||||
const restartRequest = async (requestId: string, requestNumber: string) => {
|
||||
setBusyRequestId(requestId);
|
||||
setFeedback(null);
|
||||
|
||||
try {
|
||||
await axios.put(`/requests/${requestId}/workflow-restart`, {});
|
||||
setFeedback({
|
||||
type: 'success',
|
||||
text: `${requestNumber} was restarted and moved back into review.`,
|
||||
requestId,
|
||||
});
|
||||
await loadSummary();
|
||||
} catch (error) {
|
||||
const errorMessage = axios.isAxiosError(error)
|
||||
? String(error.response?.data || error.message)
|
||||
: 'Unable to restart the request.';
|
||||
setFeedback({
|
||||
type: 'error',
|
||||
text: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setBusyRequestId('');
|
||||
}
|
||||
};
|
||||
|
||||
const pendingApprovalMap = React.useMemo(() => {
|
||||
return new Map(
|
||||
(summary?.pendingApprovals || []).map((approval) => [approval.request?.id, approval]),
|
||||
);
|
||||
}, [summary?.pendingApprovals]);
|
||||
|
||||
const canApproveRequests = Boolean(summary?.canApprove);
|
||||
const canOverrideRequests = Boolean(summary?.canOverride);
|
||||
const isRequesterOnly = !isAdminLike(currentUser) && !isDirectorRole(currentUser) && !isSupervisorRole(currentUser);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: isRequesterOnly ? 'Open requests' : 'Active requests',
|
||||
value: summary?.stats.active ?? 0,
|
||||
tone: 'from-[#ffe169]/30 via-[#ffe169]/10 to-transparent',
|
||||
},
|
||||
{
|
||||
label: 'Pending decisions',
|
||||
value: summary?.stats.pendingApprovals ?? 0,
|
||||
tone: 'from-[#34d399]/25 via-[#34d399]/10 to-transparent',
|
||||
},
|
||||
{
|
||||
label: 'Approved',
|
||||
value: summary?.stats.approved ?? 0,
|
||||
tone: 'from-[#6ee7b7]/30 via-[#6ee7b7]/10 to-transparent',
|
||||
},
|
||||
{
|
||||
label: 'Rejected',
|
||||
value: summary?.stats.rejected ?? 0,
|
||||
tone: 'from-[#fb7185]/25 via-[#fb7185]/10 to-transparent',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Workflow Center')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiChartTimelineVariant}
|
||||
title='Workflow Center'
|
||||
main
|
||||
>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton href='/dashboard' color='info' icon={mdiViewDashboardOutline} label='Dashboard' />
|
||||
<BaseButton href='/requests/requests-list' color='whiteDark' icon={mdiFileDocumentEditOutline} label='All requests' />
|
||||
</div>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className='mb-6 overflow-hidden border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(255,225,105,0.24),_transparent_38%),linear-gradient(135deg,_#0c3d2f,_#0f5e47_55%,_#0a2e24)] text-white shadow-2xl'>
|
||||
<div className='grid gap-8 lg:grid-cols-[1.45fr_0.85fr]'>
|
||||
<div className='space-y-4'>
|
||||
<div className='inline-flex items-center rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white/80'>
|
||||
Yellow Emerald workflow
|
||||
</div>
|
||||
<div className='space-y-3'>
|
||||
<h2 className='text-3xl font-semibold tracking-tight md:text-4xl'>
|
||||
Submit requests, route decisions, and keep every document traceable.
|
||||
</h2>
|
||||
<p className='max-w-3xl text-sm leading-7 text-white/75 md:text-base'>
|
||||
This first iteration turns the generated CRUD into a real approval flow: requesters can submit with an attachment,
|
||||
supervisors or directors can decide from a shared queue, administrators can override, and the audit trail follows the request.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3 text-sm text-white/80'>
|
||||
<span className={`rounded-full px-3 py-1 ${getRoleAccentClasses(summary?.role)}`}>
|
||||
Signed in as {summary?.role || currentUser?.app_role?.name || 'User'}
|
||||
</span>
|
||||
<span className='rounded-full bg-white/10 px-3 py-1 ring-1 ring-white/10'>
|
||||
Director review triggers automatically for Finance / HR requests
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-1'>
|
||||
{statCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={`rounded-3xl border border-white/10 bg-gradient-to-br ${card.tone} p-4 backdrop-blur-sm`}
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.18em] text-white/60'>{card.label}</p>
|
||||
<p className='mt-3 text-3xl font-semibold'>{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
{feedback && (
|
||||
<CardBox
|
||||
className={`mb-6 border ${
|
||||
feedback.type === 'success'
|
||||
? 'border-emerald-400/40 bg-emerald-500/10'
|
||||
: 'border-rose-400/40 bg-rose-500/10'
|
||||
}`}
|
||||
>
|
||||
<div className='flex flex-col gap-3 md:flex-row md:items-center md:justify-between'>
|
||||
<p className='text-sm font-medium'>{feedback.text}</p>
|
||||
{feedback.requestId && (
|
||||
<BaseButton
|
||||
href={`/requests/requests-view/?id=${feedback.requestId}`}
|
||||
color='info'
|
||||
label='View request'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
{pageError && (
|
||||
<CardBox className='mb-6 border border-rose-400/40 bg-rose-500/10'>
|
||||
<p className='text-sm text-rose-100'>{pageError}</p>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.15fr_0.85fr]'>
|
||||
<CardBox className='border border-white/10'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<h3 className='text-2xl font-semibold'>Create a new request</h3>
|
||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
|
||||
Capture the request, choose a department, and upload one supporting document to start the approval trail.
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-[#ffe169]/20 p-3 text-[#7a5a00] ring-1 ring-[#ffd84d]/40'>
|
||||
<svg className='h-5 w-5' viewBox='0 0 24 24' fill='currentColor' aria-hidden='true'>
|
||||
<path d='M12 2a5 5 0 0 1 5 5v1h.25A3.75 3.75 0 0 1 21 11.75v6.5A3.75 3.75 0 0 1 17.25 22h-10.5A3.75 3.75 0 0 1 3 18.25v-6.5A3.75 3.75 0 0 1 6.75 8H7V7a5 5 0 0 1 5-5Zm3 5a3 3 0 0 0-6 0v1h6V7Zm-3 4.25a1.25 1.25 0 0 0-1.25 1.25v2.5a1.25 1.25 0 1 0 2.5 0v-2.5A1.25 1.25 0 0 0 12 11.25Z' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<Formik
|
||||
initialValues={initialRequestValues}
|
||||
onSubmit={async (values, { resetForm, setSubmitting }) => {
|
||||
setFeedback(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/requests/workflow/request', {
|
||||
data: values,
|
||||
});
|
||||
|
||||
setFeedback({
|
||||
type: 'success',
|
||||
text: `${response.data.request_number} was submitted successfully.`,
|
||||
requestId: response.data.id,
|
||||
});
|
||||
|
||||
resetForm();
|
||||
await loadSummary();
|
||||
} catch (error) {
|
||||
const errorMessage = axios.isAxiosError(error)
|
||||
? String(error.response?.data || error.message)
|
||||
: 'Unable to submit the request.';
|
||||
setFeedback({
|
||||
type: 'error',
|
||||
text: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
<FormField label='Request title' labelFor='title' help='This becomes the visible title across the queue.'>
|
||||
<Field id='title' name='title' placeholder='e.g. Budget approval for field operations' />
|
||||
</FormField>
|
||||
<FormField label='Priority' labelFor='priority' help='Set the urgency before routing the decision.'>
|
||||
<Field as='select' id='priority' name='priority'>
|
||||
<option value='low'>Low</option>
|
||||
<option value='medium'>Medium</option>
|
||||
<option value='high'>High</option>
|
||||
<option value='urgent'>Urgent</option>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label='Request details' labelFor='description' hasTextareaHeight help='Give approvers enough context to make a clear decision.'>
|
||||
<Field as='textarea' id='description' name='description' placeholder='Summarize the request, the business reason, and the expected outcome.' />
|
||||
</FormField>
|
||||
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
<FormField label='Origin department' labelFor='origin_department'>
|
||||
<Field as='select' id='origin_department' name='origin_department'>
|
||||
<option value=''>Select a department</option>
|
||||
{(summary?.departments || []).map((department) => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{department.name}{department.code ? ` (${department.code})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</FormField>
|
||||
<FormField label='Target department' labelFor='assigned_department' help='Finance and HR requests automatically trigger director review.'>
|
||||
<Field as='select' id='assigned_department' name='assigned_department'>
|
||||
<option value=''>Select a department</option>
|
||||
{(summary?.departments || []).map((department) => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{department.name}{department.requires_director_approval ? ' • Director approval' : ''}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label='Submission note' labelFor='submission_note' help='Optional note for the first approver.'>
|
||||
<Field id='submission_note' name='submission_note' placeholder='Optional context for the supervisor or director' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Attach document' help='Upload one supporting file now. It will be linked to the request and visible from the detail view.'>
|
||||
<Field
|
||||
label='Upload attachment'
|
||||
color='info'
|
||||
icon={mdiUpload}
|
||||
path='requests/attachment'
|
||||
name='attachment'
|
||||
id='attachment'
|
||||
schema={{
|
||||
size: undefined,
|
||||
formats: undefined,
|
||||
}}
|
||||
component={FormFilePicker}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className='rounded-3xl border border-slate-200/70 bg-slate-50/80 p-4 text-sm leading-6 text-slate-600 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300'>
|
||||
<p className='font-semibold text-slate-900 dark:text-white'>Approval rules in this slice</p>
|
||||
<ul className='mt-2 space-y-2'>
|
||||
<li>• The request enters supervisor review immediately after submission.</li>
|
||||
<li>• Finance and Human Resources automatically escalate to director approval.</li>
|
||||
<li>• If a request is declined, the requester can restart it without losing audit history.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
color='info'
|
||||
label={isSubmitting ? 'Submitting...' : 'Submit request'}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<BaseButton
|
||||
href='/documents/documents-list'
|
||||
color='whiteDark'
|
||||
icon={mdiDownload}
|
||||
label='Document library'
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</CardBox>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<CardBox className='border border-white/10 bg-[linear-gradient(135deg,_rgba(255,225,105,0.14),_rgba(15,94,71,0.05))]'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold'>Quick links</h3>
|
||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
|
||||
Jump straight into the existing admin screens while this workflow page handles the high-value daily flow.
|
||||
</p>
|
||||
</div>
|
||||
<span className='rounded-full bg-[#0f5e47]/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-[#0f5e47] dark:text-emerald-300'>
|
||||
First delivery
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-6 grid gap-3 sm:grid-cols-2'>
|
||||
<BaseButton href='/request_approvals/request_approvals-list' color='whiteDark' label='Approval history' />
|
||||
<BaseButton href='/document_intakes/document_intakes-list' color='whiteDark' label='Front desk intake' />
|
||||
{hasPermission(currentUser, 'READ_SYSTEM_SETTINGS') && (
|
||||
<BaseButton href='/system_settings/system_settings-list' color='whiteDark' icon={mdiCogOutline} label='Header & footer' />
|
||||
)}
|
||||
{hasPermission(currentUser, 'READ_USERS') && (
|
||||
<BaseButton href='/users/users-list' color='whiteDark' label='Users & roles' />
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border border-white/10'>
|
||||
<h3 className='text-xl font-semibold'>What happens next</h3>
|
||||
<div className='mt-4 space-y-4 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
<div className='rounded-2xl bg-slate-50/80 p-4 dark:bg-dark-800'>
|
||||
<p className='font-semibold text-slate-900 dark:text-white'>1. Submission</p>
|
||||
<p className='mt-1'>The requester creates a request, uploads a document, and instantly receives a request number.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-slate-50/80 p-4 dark:bg-dark-800'>
|
||||
<p className='font-semibold text-slate-900 dark:text-white'>2. Decision</p>
|
||||
<p className='mt-1'>Supervisors decide first. Finance and HR requests continue to the director. Administrators can override the final outcome.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-slate-50/80 p-4 dark:bg-dark-800'>
|
||||
<p className='font-semibold text-slate-900 dark:text-white'>3. Traceability</p>
|
||||
<p className='mt-1'>Every upload, approval, rejection, restart, preview, and download is written to the audit trail.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]'>
|
||||
<CardBox className='border border-white/10'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<h3 className='text-2xl font-semibold'>
|
||||
{canApproveRequests ? 'Decision queue' : 'My requests'}
|
||||
</h3>
|
||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
|
||||
{canApproveRequests
|
||||
? 'Approve or reject pending work without leaving the dashboard.'
|
||||
: 'Track your latest submissions and restart any request that was declined.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 space-y-4'>
|
||||
{loading && !summary && (
|
||||
<div className='rounded-3xl border border-slate-200/70 bg-slate-50 p-6 text-sm text-slate-500 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300'>
|
||||
Loading your queue...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && canApproveRequests && !(summary?.pendingApprovals || []).length && (
|
||||
<div className='rounded-3xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-dark-700 dark:text-slate-300'>
|
||||
No pending approvals right now. When new requests reach your stage, they will appear here.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !canApproveRequests && !(summary?.recentRequests || []).length && (
|
||||
<div className='rounded-3xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-dark-700 dark:text-slate-300'>
|
||||
You have not submitted any requests yet. Use the form above to create the first one.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canApproveRequests
|
||||
? (summary?.pendingApprovals || []).map((approval) => {
|
||||
const request = approval.request;
|
||||
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={approval.id} className='rounded-3xl border border-slate-200/70 bg-slate-50/80 p-5 dark:border-dark-700 dark:bg-dark-800'>
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>
|
||||
{request.request_number}
|
||||
</p>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getRequestStatusClasses(request.status)}`}>
|
||||
{request.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className='text-lg font-semibold'>{request.title}</h4>
|
||||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
|
||||
{request.requester?.firstName || 'Unknown requester'} • {request.origin_department?.name || 'No origin'} → {request.assigned_department?.name || 'No target'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-3 text-xs text-slate-500 dark:text-slate-400'>
|
||||
<span className='rounded-full bg-slate-900/5 px-3 py-1 dark:bg-white/5'>
|
||||
{getApprovalStepLabel(approval.step)}
|
||||
</span>
|
||||
<span className={getPriorityClasses(request.priority)}>
|
||||
Priority: {request.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3 lg:justify-end'>
|
||||
<BaseButton
|
||||
href={`/requests/requests-view/?id=${request.id}`}
|
||||
color='whiteDark'
|
||||
label='View detail'
|
||||
/>
|
||||
<BaseButton
|
||||
color='info'
|
||||
label={busyRequestId === request.id ? 'Working...' : 'Approve'}
|
||||
disabled={busyRequestId === request.id}
|
||||
onClick={() => openDecisionModal(request.id, 'approved', request.request_number)}
|
||||
/>
|
||||
<BaseButton
|
||||
color='danger'
|
||||
label={busyRequestId === request.id ? 'Working...' : 'Reject'}
|
||||
disabled={busyRequestId === request.id}
|
||||
onClick={() => openDecisionModal(request.id, 'rejected', request.request_number)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: (summary?.recentRequests || []).map((request) => (
|
||||
<div key={request.id} className='rounded-3xl border border-slate-200/70 bg-slate-50/80 p-5 dark:border-dark-700 dark:bg-dark-800'>
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>
|
||||
{request.request_number}
|
||||
</p>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getRequestStatusClasses(request.status)}`}>
|
||||
{request.status}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className='text-lg font-semibold'>{request.title}</h4>
|
||||
<p className='text-sm text-slate-500 dark:text-slate-300'>
|
||||
{request.origin_department?.name || 'No origin'} → {request.assigned_department?.name || 'No target'}
|
||||
</p>
|
||||
<p className='text-xs text-slate-400'>
|
||||
Updated {request.updatedAt ? dayjs(request.updatedAt).format('MMM D, YYYY HH:mm') : 'recently'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3 lg:justify-end'>
|
||||
<BaseButton href={`/requests/requests-view/?id=${request.id}`} color='whiteDark' label='View detail' />
|
||||
{request.status === 'rejected' && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
icon={mdiRestart}
|
||||
label={busyRequestId === request.id ? 'Restarting...' : 'Restart'}
|
||||
disabled={busyRequestId === request.id}
|
||||
onClick={() => restartRequest(request.id, request.request_number)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border border-white/10'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<h3 className='text-2xl font-semibold'>Recent requests</h3>
|
||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
|
||||
Review current outcomes, jump to the request detail screen, and let administrators override final decisions when needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 space-y-4'>
|
||||
{!(summary?.recentRequests || []).length && !loading && (
|
||||
<div className='rounded-3xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-dark-700 dark:text-slate-300'>
|
||||
No requests to show yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(summary?.recentRequests || []).map((request) => {
|
||||
const pendingApproval = pendingApprovalMap.get(request.id);
|
||||
const oppositeDecision = request.status === 'approved' ? 'rejected' : 'approved';
|
||||
const canOverrideThisRequest = canOverrideRequests && ['approved', 'rejected'].includes(request.status);
|
||||
|
||||
return (
|
||||
<div key={request.id} className='rounded-3xl border border-slate-200/70 p-5 dark:border-dark-700'>
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>
|
||||
{request.request_number}
|
||||
</p>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getRequestStatusClasses(request.status)}`}>
|
||||
{request.status}
|
||||
</span>
|
||||
{pendingApproval && (
|
||||
<span className='rounded-full bg-slate-900/5 px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-white/5 dark:text-slate-200'>
|
||||
{getApprovalStepLabel(pendingApproval.step)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className='text-lg font-semibold'>{request.title}</h4>
|
||||
<p className='text-sm text-slate-500 dark:text-slate-300'>
|
||||
{request.requester?.firstName || 'Unknown requester'} • {request.origin_department?.name || 'No origin'} → {request.assigned_department?.name || 'No target'}
|
||||
</p>
|
||||
<p className='text-xs text-slate-400'>
|
||||
Submitted {request.submitted_at ? dayjs(request.submitted_at).format('MMM D, YYYY HH:mm') : 'recently'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3 lg:justify-end'>
|
||||
<BaseButton href={`/requests/requests-view/?id=${request.id}`} color='whiteDark' label='Open detail' />
|
||||
{canOverrideThisRequest && (
|
||||
<BaseButton
|
||||
color={request.status === 'approved' ? 'danger' : 'success'}
|
||||
label={request.status === 'approved' ? 'Override to reject' : 'Override to approve'}
|
||||
disabled={busyRequestId === request.id}
|
||||
onClick={() => openDecisionModal(request.id, oppositeDecision as 'approved' | 'rejected', request.request_number)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<CardBox className='mt-6 border border-white/10'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<h3 className='text-2xl font-semibold'>Audit trail</h3>
|
||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
|
||||
Watch the most recent workflow activity. Preview and download actions from the request detail page also land here.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton href='/audit_events/audit_events-list' color='whiteDark' icon={mdiClipboardTextClock} label='Full audit log' />
|
||||
</div>
|
||||
|
||||
<div className='mt-6 space-y-4'>
|
||||
{!(summary?.recentAudit || []).length && !loading && (
|
||||
<div className='rounded-3xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-dark-700 dark:text-slate-300'>
|
||||
No audit entries yet. Submit or approve a request to start building the trail.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(summary?.recentAudit || []).map((auditItem) => (
|
||||
<div key={auditItem.id} className='rounded-3xl border border-slate-200/70 bg-slate-50/80 p-5 dark:border-dark-700 dark:bg-dark-800'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<span className='rounded-full bg-[#0f5e47]/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-[#0f5e47] dark:text-emerald-300'>
|
||||
{auditItem.action}
|
||||
</span>
|
||||
<span className='text-xs text-slate-400'>
|
||||
{auditItem.event_at ? dayjs(auditItem.event_at).format('MMM D, YYYY HH:mm') : 'Recently'}
|
||||
</span>
|
||||
</div>
|
||||
<p className='mt-3 font-semibold'>{auditItem.summary}</p>
|
||||
{auditItem.details && (
|
||||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>{auditItem.details}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-sm text-slate-500 dark:text-slate-300'>
|
||||
<p>
|
||||
{auditItem.actor?.firstName || 'System'}
|
||||
{auditItem.actor?.lastName ? ` ${auditItem.actor.lastName}` : ''}
|
||||
</p>
|
||||
{(auditItem.from_department?.name || auditItem.to_department?.name) && (
|
||||
<p className='mt-1 text-xs text-slate-400'>
|
||||
{auditItem.from_department?.name || '—'} → {auditItem.to_department?.name || '—'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
|
||||
<CardBoxModal
|
||||
title={decisionState?.action === 'approved' ? 'Approve request' : 'Reject request'}
|
||||
buttonColor={decisionState?.action === 'approved' ? 'info' : 'danger'}
|
||||
buttonLabel={busyRequestId && decisionState ? 'Working...' : 'Confirm'}
|
||||
isActive={Boolean(decisionState)}
|
||||
onConfirm={submitDecision}
|
||||
onCancel={closeDecisionModal}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<p className='text-sm text-slate-500 dark:text-slate-300'>
|
||||
{decisionState?.title
|
||||
? `Add an optional note for ${decisionState.title}.`
|
||||
: 'Add an optional note for this decision.'}
|
||||
</p>
|
||||
<textarea
|
||||
className='h-32 w-full rounded-2xl border border-slate-300 bg-white px-3 py-3 text-sm focus:border-emerald-500 focus:outline-none focus:ring focus:ring-emerald-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white'
|
||||
value={decisionComment}
|
||||
onChange={(event) => setDecisionComment(event.target.value)}
|
||||
placeholder='Explain why this was approved, rejected, or overridden...'
|
||||
/>
|
||||
</div>
|
||||
</CardBoxModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
WorkflowCenterPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated permission='READ_REQUESTS'>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowCenterPage;
|
||||
Loading…
x
Reference in New Issue
Block a user