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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - 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

+
+
+
+

Internal workflow platform

+

Request & Approval Manager

+
+
+ + +
+
+ +
+
+
+
+ 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) => ( -
-
- Photo - by {image?.photographer} on Pexels -
-
- ) - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } + 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)} - > -
- - - - -
- - - -
- -
-
- -
- - - - - - Forgot password? - -
- - - - - - -
-

- Don’t have an account yet?{' '} - - New Account - -

- -
-
-
+
+
+
+ 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) => ( + + ))} +
+ +
+

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. +

+
+ + +
+ + + + +
+ + + + +
+ +
+ + + + + Forgot password? + +
+ +
+ + +
+
+
+ +
+

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}

+ )} +
+
+ ))} +
+
- - - - - - - - - - - - - - - - - -