diff --git a/backend/src/db/api/requests.js b/backend/src/db/api/requests.js
index 104a231..f8af7ed 100644
--- a/backend/src/db/api/requests.js
+++ b/backend/src/db/api/requests.js
@@ -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 = [
{
diff --git a/backend/src/db/migrations/20260518074500-seed-workflow-demo-accounts.js b/backend/src/db/migrations/20260518074500-seed-workflow-demo-accounts.js
new file mode 100644
index 0000000..693149a
--- /dev/null
+++ b/backend/src/db/migrations/20260518074500-seed-workflow-demo-accounts.js
@@ -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;
+ }
+ },
+};
diff --git a/backend/src/routes/requests.js b/backend/src/routes/requests.js
index fa18866..a0df70a 100644
--- a/backend/src/routes/requests.js
+++ b/backend/src/routes/requests.js
@@ -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}:
diff --git a/backend/src/services/requestWorkflow.js b/backend/src/services/requestWorkflow.js
new file mode 100644
index 0000000..8204221
--- /dev/null
+++ b/backend/src/services/requestWorkflow.js
@@ -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;
+ }
+ }
+};
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 6548433..2ef67e9 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/helpers/requestWorkflow.ts b/frontend/src/helpers/requestWorkflow.ts
new file mode 100644
index 0000000..c6364f5
--- /dev/null
+++ b/frontend/src/helpers/requestWorkflow.ts
@@ -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';
+};
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index b6ccb9e..59b8d79 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -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',
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 5354991..5ce8f7c 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -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) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
-
return (
-
+
-
{getPageTitle('Starter Page')}
+
{getPageTitle('Request & Approval Manager')}
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
-
This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-
For guides and documentation please check
- your local README.md and the Flatlogic documentation
+
+
+
+
+
+
+
+ Yellow Emerald design system
+
+
+
+ A modern decision workspace for internal requests, approvals, and document handoffs.
+
+
+ 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.
+
+
+
+
+
+
+
+ {roleCards.map((card) => (
+
+
{card.role}
+
{card.summary}
+
+ ))}
+
-
-
-
-
-
-
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
+
+
+
+
Included in this iteration
+
Thin slice, end to end
+
+ Create a request, attach a document, approve or reject it, restart on decline, and review the resulting audit trail without rebuilding generic CRUD.
+
+
+
+
Admin shortcuts
+
+
+
+
+
+
+
+
+ Tip: the login page now includes quick-fill demo credentials for admin, supervisor, director, and user accounts.
+
+
+
+
+
+ {featureCards.map((feature) => (
+
+
{feature.title}
+
{feature.description}
+
+ ))}
+
+
+
+
+ © 2026 Request & Approval Manager. Built for server-side approval workflows.
+
+
+ Login
+
+
+ Dashboard
+
+
+ Privacy Policy
+
+
+
+
);
}
@@ -163,4 +145,3 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) {
return {page} ;
};
-
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx
index 072cb24..2c10f08 100644
--- a/frontend/src/pages/login.tsx
+++ b/frontend/src/pages/login.tsx
@@ -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) => (
-
- )
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
+ const handleSubmit = async (values) => {
+ const { remember, ...credentials } = values;
+ await dispatch(loginUser(credentials));
};
return (
-
-
-
{getPageTitle('Login')}
-
+
+
+
{getPageTitle('Login')}
+
-
-
- {contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
- {contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
-
-
-
-
- {title}
-
-
-
-
-
Use{' '}
- setLogin(e.target)}>admin@flatlogic.com{' / '}
- 806bd392{' / '}
- to login as Admin
-
Use setLogin(e.target)}>client@hello.com{' / '}
- 618e272f4df4{' / '}
- to login as User
-
-
-
-
-
-
-
-
- handleSubmit(values)}
- >
-
-
-
-
+
+
+
+ Yellow Emerald login
-
-
-
© 2026 {title} . © All rights reserved
-
- Privacy Policy
-
-
-
+
+
+ Login to the request and approval command center.
+
+
+ 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.
+
+
+
+
+ {demoAccounts.map((account) => (
+
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'
+ >
+
+
{account.role}
+
Use demo
+
+ {account.login}
+ {account.password}
+ {account.description}
+
+ ))}
+
+
+
+
What this role-aware build already covers
+
+ • Workflow Center for request creation, approval queue, restart handling, and audit visibility.
+ • Request detail page with document preview/download plus movement logging.
+ • Direct links into users, departments, front desk intake, and header/footer settings.
+
+
+
+
+
+
+
+
Secure access
+
Welcome back
+
+ Choose a role above to prefill the credentials or enter them manually below.
+
+
+
+
+
+
+
+
+
Need a guided start?
+
Log in as user to submit a request, then switch to supervisor or director to move it through the approval chain.
+
+
+
+ Don’t have an account yet?{' '}
+
+ Create one
+
+
+
+
+
+
+
+ © 2026 Request & Approval Manager • Privacy Policy
+
+
+
+
);
}
diff --git a/frontend/src/pages/requests/requests-view.tsx b/frontend/src/pages/requests/requests-view.tsx
index 29d7300..6588bc3 100644
--- a/frontend/src/pages/requests/requests-view.tsx
+++ b/frontend/src/pages/requests/requests-view.tsx
@@ -1,949 +1,565 @@
+import { mdiChartTimelineVariant, mdiDownload, mdiEye, mdiRestart } from '@mdi/js';
+import axios from 'axios';
+import dayjs from 'dayjs';
+import Head from 'next/head';
import React, { ReactElement, useEffect } from 'react';
-import Head from 'next/head'
-import DatePicker from "react-datepicker";
-import "react-datepicker/dist/react-datepicker.css";
-import dayjs from "dayjs";
-import {useAppDispatch, useAppSelector} from "../../stores/hooks";
-import {useRouter} from "next/router";
-import { fetch } from '../../stores/requests/requestsSlice'
-import {saveFile} from "../../helpers/fileSaver";
+import BaseButton from '../../components/BaseButton';
+import CardBox from '../../components/CardBox';
+import CardBoxModal from '../../components/CardBoxModal';
+import LayoutAuthenticated from '../../layouts/Authenticated';
+import { getPageTitle } from '../../config';
import dataFormatter from '../../helpers/dataFormatter';
-import ImageField from "../../components/ImageField";
-import LayoutAuthenticated from "../../layouts/Authenticated";
-import {getPageTitle} from "../../config";
-import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
-import SectionMain from "../../components/SectionMain";
-import CardBox from "../../components/CardBox";
-import BaseButton from "../../components/BaseButton";
-import BaseDivider from "../../components/BaseDivider";
-import {mdiChartTimelineVariant} from "@mdi/js";
-import {SwitchField} from "../../components/SwitchField";
-import FormField from "../../components/FormField";
-
+import {
+ getApprovalStepLabel,
+ getPriorityClasses,
+ getRequestStatusClasses,
+ isAdminLike,
+ isDirectorRole,
+ isSupervisorRole,
+} from '../../helpers/requestWorkflow';
+import { saveFile } from '../../helpers/fileSaver';
+import SectionMain from '../../components/SectionMain';
+import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
+import { useRouter } from 'next/router';
+import { useAppDispatch, useAppSelector } from '../../stores/hooks';
+import { fetch } from '../../stores/requests/requestsSlice';
const RequestsView = () => {
- const router = useRouter()
- const dispatch = useAppDispatch()
- const { requests } = useAppSelector((state) => state.requests)
-
+ const router = useRouter();
+ const dispatch = useAppDispatch();
+ const { requests, loading } = useAppSelector((state) => state.requests);
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const [feedback, setFeedback] = React.useState<{
+ type: 'success' | 'error';
+ text: string;
+ } | null>(null);
+ const [decisionState, setDecisionState] = React.useState<{
+ action: 'approved' | 'rejected';
+ label: string;
+ } | null>(null);
+ const [decisionComment, setDecisionComment] = React.useState('');
+ const [previewDocument, setPreviewDocument] = React.useState(null);
+ const [isWorking, setIsWorking] = React.useState(false);
- const { id } = router.query;
-
- function removeLastCharacter(str) {
- console.log(str,`str`)
- return str.slice(0, -1);
+ const { id } = router.query;
+ const requestId = Array.isArray(id) ? id[0] : id;
+ const requestData = Array.isArray(requests) ? null : requests;
+
+ useEffect(() => {
+ if (!requestId) {
+ return;
}
- useEffect(() => {
- dispatch(fetch({ id }));
- }, [dispatch, id]);
+ dispatch(fetch({ id: requestId }));
+ }, [dispatch, requestId]);
+ const pendingApproval = React.useMemo(() => {
+ return requestData?.request_approvals_request?.find((approval) => approval.decision === 'pending');
+ }, [requestData]);
- return (
- <>
-
- {getPageTitle('View requests')}
-
-
-
-
-
-
-
+ const canReviewRequest = Boolean(
+ pendingApproval &&
+ (
+ isAdminLike(currentUser) ||
+ (pendingApproval.step === 'director' && isDirectorRole(currentUser)) ||
+ (pendingApproval.step === 'supervisor_hod' && isSupervisorRole(currentUser))
+ ),
+ );
-
-
-
RequestNumber
-
{requests?.request_number}
+ const canOverrideRequest = Boolean(
+ requestData &&
+ isAdminLike(currentUser) &&
+ ['approved', 'rejected'].includes(requestData.status),
+ );
+
+ const canRestartRequest = Boolean(
+ requestData &&
+ requestData.status === 'rejected' &&
+ (isAdminLike(currentUser) || currentUser?.id === requestData?.requester?.id),
+ );
+
+ const refreshRequest = async () => {
+ if (!requestId) {
+ return;
+ }
+
+ await dispatch(fetch({ id: requestId }));
+ };
+
+ const openDecisionModal = (action: 'approved' | 'rejected', label: string) => {
+ setDecisionState({ action, label });
+ setDecisionComment('');
+ };
+
+ const closeDecisionModal = () => {
+ setDecisionState(null);
+ setDecisionComment('');
+ };
+
+ const handleDecision = async () => {
+ if (!requestId || !decisionState) {
+ return;
+ }
+
+ setIsWorking(true);
+ setFeedback(null);
+
+ try {
+ await axios.put(`/requests/${requestId}/workflow-decision`, {
+ data: {
+ decision: decisionState.action,
+ comment: decisionComment,
+ },
+ });
+
+ setFeedback({
+ type: 'success',
+ text: `${requestData?.request_number || 'Request'} was ${decisionState.action}.`,
+ });
+ closeDecisionModal();
+ await refreshRequest();
+ } catch (error) {
+ const errorMessage = axios.isAxiosError(error)
+ ? String(error.response?.data || error.message)
+ : 'Unable to save that decision.';
+ setFeedback({
+ type: 'error',
+ text: errorMessage,
+ });
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ const handleRestart = async () => {
+ if (!requestId) {
+ return;
+ }
+
+ setIsWorking(true);
+ setFeedback(null);
+
+ try {
+ await axios.put(`/requests/${requestId}/workflow-restart`, {});
+ setFeedback({
+ type: 'success',
+ text: `${requestData?.request_number || 'Request'} was restarted and moved back into review.`,
+ });
+ await refreshRequest();
+ } catch (error) {
+ const errorMessage = axios.isAxiosError(error)
+ ? String(error.response?.data || error.message)
+ : 'Unable to restart this request.';
+ setFeedback({
+ type: 'error',
+ text: errorMessage,
+ });
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ const logDocumentEvent = async (documentId: string, action: 'preview' | 'download') => {
+ if (!requestId) {
+ return;
+ }
+
+ try {
+ await axios.put(`/requests/${requestId}/workflow-document-event`, {
+ data: {
+ documentId,
+ action,
+ },
+ });
+ await refreshRequest();
+ } catch (error) {
+ console.error('Unable to record document event', error);
+ }
+ };
+
+ const handlePreview = async (event: React.MouseEvent, document: any) => {
+ event.stopPropagation();
+ await logDocumentEvent(document.id, 'preview');
+ setPreviewDocument(document);
+ };
+
+ const handleDownload = async (event: React.MouseEvent, document: any) => {
+ event.stopPropagation();
+ const file = dataFormatter.filesFormatter(document.file_blob)?.[0];
+
+ if (!file) {
+ return;
+ }
+
+ await logDocumentEvent(document.id, 'download');
+ saveFile(event, file.publicUrl, file.name);
+ };
+
+ const previewFile = previewDocument
+ ? dataFormatter.filesFormatter(previewDocument.file_blob)?.[0]
+ : null;
+ const previewName = previewFile?.name || previewDocument?.original_filename || previewDocument?.title;
+ const isPdfPreview = previewName ? previewName.toLowerCase().endsWith('.pdf') : false;
+ const isImagePreview = previewName ? /\.(png|jpe?g|gif|webp|svg)$/i.test(previewName) : false;
+
+ return (
+ <>
+
+
{getPageTitle('Request detail')}
+
+
+
+
+
+ {requestId && }
+
+
+
+ {feedback && (
+
+ {feedback.text}
+
+ )}
+
+
+
+
+
+
+ {requestData?.request_number || 'Loading request'}
+
+ {requestData?.status && (
+
+ {requestData.status}
+
+ )}
+ {requestData?.priority && (
+
+ Priority: {requestData.priority}
+
+ )}
+
+
+
{requestData?.title || 'Loading request...'}
+
+ {requestData?.description ? (
+
+ ) : (
+ 'This request detail page collects the approval history, document access, and audit movement in one place.'
+ )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Title
-
{requests?.title}
+
+
+
+
Requester
+
+ {requestData?.requester?.firstName || 'Unknown requester'}
+ {requestData?.requester?.lastName ? ` ${requestData.requester.lastName}` : ''}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Description
- {requests.description
- ?
- :
No data
- }
+
+
Route
+
+ {requestData?.origin_department?.name || 'No origin'} → {requestData?.assigned_department?.name || 'No target'}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Priority
-
{requests?.priority ?? 'No data'}
+
+
Lifecycle
+
+ Submitted {requestData?.submitted_at ? dayjs(requestData.submitted_at).format('MMM D, YYYY HH:mm') : '—'}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Status
-
{requests?.status ?? 'No data'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Requester
-
-
-
{requests?.requester?.firstName ?? 'No data'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
OriginDepartment
-
-
-
-
-
-
-
-
-
{requests?.origin_department?.name ?? 'No data'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
AssignedDepartment
-
-
-
-
-
-
-
-
-
{requests?.assigned_department?.name ?? 'No data'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- null}}
- disabled
+
+
+
+
+
+
Workflow actions
+
+ Approvers can decide from here, requesters can restart rejected items, and administrators can override the final outcome.
+
+
+ {canReviewRequest && (
+ <>
+
openDecisionModal('approved', requestData?.request_number || 'request')}
+ />
+ openDecisionModal('rejected', requestData?.request_number || 'request')}
+ />
+ >
+ )}
+ {canOverrideRequest && (
+
+ openDecisionModal(
+ requestData?.status === 'approved' ? 'rejected' : 'approved',
+ requestData?.request_number || 'request',
+ )
+ }
/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {requests.submitted_at ? : No SubmittedAt
}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {requests.finalized_at ? : No FinalizedAt
}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
RestartCount
-
{requests?.restart_count || 'No data'}
+ )}
+ {canRestartRequest && (
+
+ )}
-
+
-
+
+
Request metadata
+
+
+ Finalized
+ {requestData?.finalized_at ? dayjs(requestData.finalized_at).format('MMM D, YYYY HH:mm') : 'Not finalized'}
+
+
+ Restart count
+ {requestData?.restart_count ?? 0}
+
+
+ Director required
+ {requestData?.requires_director ? 'Yes' : 'No'}
+
+
+ {requestData?.rejection_reason && (
+
+
Rejection reason
+
{requestData.rejection_reason}
+
+ )}
+
+
+
+
-
+
+
+ Approval history
+
+ Every decision step is retained, including pending stages and administrator overrides.
+
+
+ {!requestData?.request_approvals_request?.length && !loading && (
+
+ No approvals recorded yet.
+
+ )}
-
+ {requestData?.request_approvals_request?.map((approval) => (
+
+
+
+
+
+ {getApprovalStepLabel(approval.step)}
+
+
+ {approval.decision}
+
+
+
+ {approval.decided_by?.firstName || 'Awaiting decision'}
+ {approval.decided_by?.lastName ? ` ${approval.decided_by.lastName}` : ''}
+ {approval.decided_at ? ` • ${dayjs(approval.decided_at).format('MMM D, YYYY HH:mm')}` : ''}
+
+
+ {approval.comment && (
+
{approval.comment}
+ )}
+
+
+ ))}
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- <>
-
Request_approvals Request
-
-
-
-
-
-
-
-
-
- ApprovalStep
-
-
-
- Decision
-
-
-
-
-
- DecidedAt
-
-
-
- Comment
-
-
-
-
-
- {requests.request_approvals_request && Array.isArray(requests.request_approvals_request) &&
- requests.request_approvals_request.map((item: any) => (
- router.push(`/request_approvals/request_approvals-view/?id=${item.id}`)}>
-
-
-
-
-
- { item.step }
-
-
-
-
-
- { item.decision }
-
-
-
-
-
-
-
- { dataFormatter.dateTimeFormatter(item.decided_at) }
-
-
-
-
-
- { item.comment }
-
-
-
-
- ))}
-
-
+
+ Attached documents
+
+ Preview the uploaded file on a paper-style surface, download it, and capture who uploaded it.
+
+
+
+
+
+ Document
+ Uploaded by
+ Uploaded
+ Actions
+
+
+
+ {requestData?.documents_request?.map((document) => (
+
+
+
+
{document.title || document.original_filename || 'Attachment'}
+
+ {document.document_number || 'Document'} • {document.original_filename || 'File attached'}
+
- {!requests?.request_approvals_request?.length && No data
}
-
- >
-
-
- <>
- Documents Request
-
-
-
-
-
-
-
- DocumentNumber
-
-
-
- Title
-
-
-
- DocumentType
-
-
-
-
-
- OriginalFilename
-
-
-
- MIMEType
-
-
-
- FileSize(Bytes)
-
-
-
- ChecksumSHA-256
-
-
-
-
-
-
-
-
-
- Visibility
-
-
-
- UploadedAt
-
-
-
- Active
-
-
-
-
-
- {requests.documents_request && Array.isArray(requests.documents_request) &&
- requests.documents_request.map((item: any) => (
- router.push(`/documents/documents-view/?id=${item.id}`)}>
-
-
-
- { item.document_number }
-
-
-
-
-
- { item.title }
-
-
-
-
-
- { item.document_type }
-
-
-
-
-
-
-
- { item.original_filename }
-
-
-
-
-
- { item.mime_type }
-
-
-
-
-
- { item.file_size_bytes }
-
-
-
-
-
- { item.checksum_sha256 }
-
-
-
-
-
-
-
-
-
-
-
- { item.visibility }
-
-
-
-
-
- { dataFormatter.dateTimeFormatter(item.uploaded_at) }
-
-
-
-
-
- { dataFormatter.booleanFormatter(item.is_active) }
-
-
-
-
- ))}
-
-
+
+
+ {document.uploaded_by_user?.firstName || 'Unknown'}
+ {document.uploaded_by_user?.lastName ? ` ${document.uploaded_by_user.lastName}` : ''}
+
+
+ {document.uploaded_at ? dayjs(document.uploaded_at).format('MMM D, YYYY HH:mm') : 'Unknown'}
+
+
+
+ handlePreview(event, document)} />
+ handleDownload(event, document)} />
- {!requests?.documents_request?.length && No data
}
-
- >
-
-
-
- <>
- Audit_events RelatedRequest
-
-
-
-
-
-
-
- EntityType
-
-
-
- EntityKey
-
-
-
- Action
-
-
-
-
-
- Summary
-
-
-
- Details
-
-
-
-
-
-
-
-
-
-
-
- EventAt
-
-
-
- IPAddress
-
-
-
- UserAgent
-
-
-
-
-
- {requests.audit_events_related_request && Array.isArray(requests.audit_events_related_request) &&
- requests.audit_events_related_request.map((item: any) => (
- router.push(`/audit_events/audit_events-view/?id=${item.id}`)}>
-
-
-
- { item.entity_type }
-
-
-
-
-
- { item.entity_key }
-
-
-
-
-
- { item.action }
-
-
-
-
-
-
-
- { item.summary }
-
-
-
-
-
- { item.details }
-
-
-
-
-
-
-
-
-
-
-
-
-
- { dataFormatter.dateTimeFormatter(item.event_at) }
-
-
-
-
-
- { item.ip_address }
-
-
-
-
-
- { item.user_agent }
-
-
-
-
- ))}
-
-
-
- {!requests?.audit_events_related_request?.length && No data
}
-
- >
-
-
-
+
+
+ ))}
+
+
+ {!requestData?.documents_request?.length && !loading && (
+
+ No document has been attached to this request yet.
+
+ )}
+
+
+
-
+
+ Audit trail
+
+ Every workflow movement is preserved here, including uploads, approvals, restarts, previews, and downloads.
+
+
+ {!requestData?.audit_events_related_request?.length && !loading && (
+
+ No audit records yet.
+
+ )}
+ {requestData?.audit_events_related_request?.map((auditEvent) => (
+
+
+
+
+
+ {auditEvent.action}
+
+
+ {auditEvent.event_at ? dayjs(auditEvent.event_at).format('MMM D, YYYY HH:mm') : 'Recently'}
+
+
+
{auditEvent.summary}
+ {auditEvent.details && (
+
{auditEvent.details}
+ )}
+
+
+
+ {auditEvent.actor?.firstName || 'System'}
+ {auditEvent.actor?.lastName ? ` ${auditEvent.actor.lastName}` : ''}
+
+ {(auditEvent.from_department?.name || auditEvent.to_department?.name) && (
+
+ {auditEvent.from_department?.name || '—'} → {auditEvent.to_department?.name || '—'}
+
+ )}
+
+
+
+ ))}
+
+
+
- router.push('/requests/requests-list')}
- />
-
-
- >
- );
+
+
+
+ Add an optional note for {decisionState?.label || 'this request'}.
+
+
+
+
+
setPreviewDocument(null)}
+ onCancel={() => setPreviewDocument(null)}
+ >
+
+
+
+
Attachment preview
+
+ {previewDocument?.title || previewDocument?.original_filename || 'Attached document'}
+
+
+ Uploaded by {previewDocument?.uploaded_by_user?.firstName || 'Unknown uploader'}
+ {previewDocument?.uploaded_by_user?.lastName ? ` ${previewDocument.uploaded_by_user.lastName}` : ''}
+ {previewDocument?.uploaded_at ? ` • ${dayjs(previewDocument.uploaded_at).format('MMM D, YYYY HH:mm')}` : ''}
+
+
+
+ {previewFile && isImagePreview && (
+
+ )}
+
+ {previewFile && isPdfPreview && (
+
+ )}
+
+ {previewFile && !isImagePreview && !isPdfPreview && (
+
+ Inline preview is not available for this file type. Use the download button to open the original attachment.
+
+ )}
+
+
+
+ >
+ );
};
RequestsView.getLayout = function getLayout(page: ReactElement) {
- return (
-
- {page}
-
- )
-}
+ return (
+
+ {page}
+
+ );
+};
-export default RequestsView;
\ No newline at end of file
+export default RequestsView;
diff --git a/frontend/src/pages/workflow-center.tsx b/frontend/src/pages/workflow-center.tsx
new file mode 100644
index 0000000..0cabd76
--- /dev/null
+++ b/frontend/src/pages/workflow-center.tsx
@@ -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
(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 (
+ <>
+
+ {getPageTitle('Workflow Center')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Yellow Emerald workflow
+
+
+
+ Submit requests, route decisions, and keep every document traceable.
+
+
+ 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.
+
+
+
+
+ Signed in as {summary?.role || currentUser?.app_role?.name || 'User'}
+
+
+ Director review triggers automatically for Finance / HR requests
+
+
+
+
+ {statCards.map((card) => (
+
+
{card.label}
+
{card.value}
+
+ ))}
+
+
+
+
+ {feedback && (
+
+
+
{feedback.text}
+ {feedback.requestId && (
+
+ )}
+
+
+ )}
+
+ {pageError && (
+
+ {pageError}
+
+ )}
+
+
+
+
+
+
Create a new request
+
+ Capture the request, choose a department, and upload one supporting document to start the approval trail.
+
+
+
+
+
+
+
+ {
+ 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 }) => (
+
+ )}
+
+
+
+
+
+
+
+
Quick links
+
+ Jump straight into the existing admin screens while this workflow page handles the high-value daily flow.
+
+
+
+ First delivery
+
+
+
+
+
+ {hasPermission(currentUser, 'READ_SYSTEM_SETTINGS') && (
+
+ )}
+ {hasPermission(currentUser, 'READ_USERS') && (
+
+ )}
+
+
+
+
+ What happens next
+
+
+
1. Submission
+
The requester creates a request, uploads a document, and instantly receives a request number.
+
+
+
2. Decision
+
Supervisors decide first. Finance and HR requests continue to the director. Administrators can override the final outcome.
+
+
+
3. Traceability
+
Every upload, approval, rejection, restart, preview, and download is written to the audit trail.
+
+
+
+
+
+
+
+
+
+
+
+ {canApproveRequests ? 'Decision queue' : 'My requests'}
+
+
+ {canApproveRequests
+ ? 'Approve or reject pending work without leaving the dashboard.'
+ : 'Track your latest submissions and restart any request that was declined.'}
+
+
+
+
+
+ {loading && !summary && (
+
+ Loading your queue...
+
+ )}
+
+ {!loading && canApproveRequests && !(summary?.pendingApprovals || []).length && (
+
+ No pending approvals right now. When new requests reach your stage, they will appear here.
+
+ )}
+
+ {!loading && !canApproveRequests && !(summary?.recentRequests || []).length && (
+
+ You have not submitted any requests yet. Use the form above to create the first one.
+
+ )}
+
+ {canApproveRequests
+ ? (summary?.pendingApprovals || []).map((approval) => {
+ const request = approval.request;
+
+ if (!request) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {request.request_number}
+
+
+ {request.status}
+
+
+
+
{request.title}
+
+ {request.requester?.firstName || 'Unknown requester'} • {request.origin_department?.name || 'No origin'} → {request.assigned_department?.name || 'No target'}
+
+
+
+
+ {getApprovalStepLabel(approval.step)}
+
+
+ Priority: {request.priority}
+
+
+
+
+
+ openDecisionModal(request.id, 'approved', request.request_number)}
+ />
+ openDecisionModal(request.id, 'rejected', request.request_number)}
+ />
+
+
+
+ );
+ })
+ : (summary?.recentRequests || []).map((request) => (
+
+
+
+
+
+ {request.request_number}
+
+
+ {request.status}
+
+
+
{request.title}
+
+ {request.origin_department?.name || 'No origin'} → {request.assigned_department?.name || 'No target'}
+
+
+ Updated {request.updatedAt ? dayjs(request.updatedAt).format('MMM D, YYYY HH:mm') : 'recently'}
+
+
+
+
+ {request.status === 'rejected' && (
+ restartRequest(request.id, request.request_number)}
+ />
+ )}
+
+
+
+ ))}
+
+
+
+
+
+
+
Recent requests
+
+ Review current outcomes, jump to the request detail screen, and let administrators override final decisions when needed.
+
+
+
+
+
+ {!(summary?.recentRequests || []).length && !loading && (
+
+ No requests to show yet.
+
+ )}
+
+ {(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 (
+
+
+
+
+
+ {request.request_number}
+
+
+ {request.status}
+
+ {pendingApproval && (
+
+ {getApprovalStepLabel(pendingApproval.step)}
+
+ )}
+
+
{request.title}
+
+ {request.requester?.firstName || 'Unknown requester'} • {request.origin_department?.name || 'No origin'} → {request.assigned_department?.name || 'No target'}
+
+
+ Submitted {request.submitted_at ? dayjs(request.submitted_at).format('MMM D, YYYY HH:mm') : 'recently'}
+
+
+
+
+ {canOverrideThisRequest && (
+ openDecisionModal(request.id, oppositeDecision as 'approved' | 'rejected', request.request_number)}
+ />
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
Audit trail
+
+ Watch the most recent workflow activity. Preview and download actions from the request detail page also land here.
+
+
+
+
+
+
+ {!(summary?.recentAudit || []).length && !loading && (
+
+ No audit entries yet. Submit or approve a request to start building the trail.
+
+ )}
+
+ {(summary?.recentAudit || []).map((auditItem) => (
+
+
+
+
+
+ {auditItem.action}
+
+
+ {auditItem.event_at ? dayjs(auditItem.event_at).format('MMM D, YYYY HH:mm') : 'Recently'}
+
+
+
{auditItem.summary}
+ {auditItem.details && (
+
{auditItem.details}
+ )}
+
+
+
+ {auditItem.actor?.firstName || 'System'}
+ {auditItem.actor?.lastName ? ` ${auditItem.actor.lastName}` : ''}
+
+ {(auditItem.from_department?.name || auditItem.to_department?.name) && (
+
+ {auditItem.from_department?.name || '—'} → {auditItem.to_department?.name || '—'}
+
+ )}
+
+
+
+ ))}
+
+
+
+
+
+
+
+ {decisionState?.title
+ ? `Add an optional note for ${decisionState.title}.`
+ : 'Add an optional note for this decision.'}
+
+
+
+ >
+ );
+};
+
+WorkflowCenterPage.getLayout = function getLayout(page: ReactElement) {
+ return (
+
+ {page}
+
+ );
+};
+
+export default WorkflowCenterPage;