Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
d2214f4b56 MOSHAV1 2026-05-18 08:00:15 +00:00
12 changed files with 2965 additions and 1320 deletions

View File

@ -1,7 +1,5 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
@ -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 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;
}),
);
const documents = await requests.getDocuments_request({
output.documents_request = await requests.getDocuments_request({
transaction 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;
}),
);
const auditEvents = await requests.getAudit_events_related_request({
output.audit_events_related_request = await requests.getAudit_events_related_request({
transaction 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; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
{ {

View File

@ -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;
}
},
};

View File

@ -2,6 +2,7 @@
const express = require('express'); const express = require('express');
const RequestsService = require('../services/requests'); const RequestsService = require('../services/requests');
const RequestWorkflowService = require('../services/requestWorkflow');
const RequestsDBApi = require('../db/api/requests'); const RequestsDBApi = require('../db/api/requests');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
@ -396,6 +397,54 @@ router.get('/autocomplete', async (req, res) => {
res.status(200).send(payload); 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 * @swagger
* /api/requests/{id}: * /api/requests/{id}:

View File

@ -0,0 +1,850 @@
const db = require('../db/models');
const RequestsDBApi = require('../db/api/requests');
const FileDBApi = require('../db/api/file');
const { Op } = db.Sequelize;
const ACTIVE_STATUSES = ['draft', 'submitted', 'in_review', 'restarted'];
const ADMIN_ROLES = ['administrator', 'systemowner'];
function normalizeRoleName(name = '') {
return String(name).toLowerCase().replace(/\s+/g, '');
}
function isAdminLike(currentUser) {
return ADMIN_ROLES.includes(normalizeRoleName(currentUser?.app_role?.name));
}
function isDirector(currentUser) {
return normalizeRoleName(currentUser?.app_role?.name) === 'director';
}
function isSupervisor(currentUser) {
return normalizeRoleName(currentUser?.app_role?.name) === 'departmentsupervisor';
}
function isElevated(currentUser) {
return isAdminLike(currentUser) || isDirector(currentUser) || isSupervisor(currentUser);
}
function createRequestError(message) {
const error = new Error(message);
error.code = 400;
return error;
}
function getClientMeta(req) {
const forwardedFor = req?.headers?.['x-forwarded-for'];
const ipAddress = forwardedFor
? String(forwardedFor).split(',')[0].trim()
: req?.ip || req?.connection?.remoteAddress || null;
return {
ipAddress,
userAgent: req?.headers?.['user-agent'] || null,
};
}
function departmentNeedsDirector(department) {
const haystack = `${department?.name || ''} ${department?.code || ''}`.toLowerCase();
return Boolean(department?.requires_director_approval) ||
haystack.includes('finance') ||
haystack.includes('human resources') ||
haystack.includes('human resource') ||
/\bhr\b/.test(haystack);
}
async function createAuditEvent({
transaction,
currentUser,
req,
action,
entityType,
entityKey,
summary,
details,
relatedRequestId,
relatedDocumentId,
fromDepartmentId,
toDepartmentId,
}) {
const { ipAddress, userAgent } = getClientMeta(req);
return db.audit_events.create(
{
entity_type: entityType,
entity_key: entityKey || relatedRequestId || relatedDocumentId || currentUser?.id || null,
action,
summary,
details: details || null,
event_at: new Date(),
ip_address: ipAddress,
user_agent: userAgent,
actorId: currentUser?.id || null,
related_requestId: relatedRequestId || null,
related_documentId: relatedDocumentId || null,
from_departmentId: fromDepartmentId || null,
to_departmentId: toDepartmentId || null,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
{ transaction },
);
}
async function generateRunningNumber(model, prefix, transaction) {
const count = await model.count({ transaction });
return `${prefix}-${String(count + 1).padStart(4, '0')}`;
}
async function getRequestForWorkflow(id, transaction) {
return db.requests.findByPk(id, {
include: [
{
model: db.users,
as: 'requester',
},
{
model: db.departments,
as: 'origin_department',
},
{
model: db.departments,
as: 'assigned_department',
},
],
transaction,
});
}
async function getPendingApproval(requestId, transaction) {
return db.request_approvals.findOne({
where: {
requestId,
decision: 'pending',
},
include: [
{
model: db.requests,
as: 'request',
include: [
{
model: db.users,
as: 'requester',
},
{
model: db.departments,
as: 'origin_department',
},
{
model: db.departments,
as: 'assigned_department',
},
],
},
],
order: [['createdAt', 'ASC']],
transaction,
});
}
function canSupervisorHandleRequest(request, currentUser) {
if (!isSupervisor(currentUser)) {
return false;
}
const supervisorIds = [
request?.origin_department?.supervisor_userId,
request?.assigned_department?.supervisor_userId,
].filter(Boolean);
if (!supervisorIds.length) {
return true;
}
return supervisorIds.includes(currentUser.id);
}
function canDecideApproval(approval, currentUser) {
if (!approval?.request) {
return false;
}
if (isAdminLike(currentUser)) {
return true;
}
if (approval.step === 'director') {
return isDirector(currentUser);
}
if (approval.step === 'supervisor_hod') {
return canSupervisorHandleRequest(approval.request, currentUser);
}
return false;
}
function mapSequelizeRow(row) {
return row?.get ? row.get({ plain: true }) : row;
}
module.exports = class RequestWorkflowService {
static async getSummary(currentUser) {
const requestScope = isElevated(currentUser)
? {}
: {
requesterId: currentUser.id,
};
const departments = await db.departments.findAll({
order: [
['requires_director_approval', 'DESC'],
['name', 'ASC'],
],
});
const pendingApprovalRows = await db.request_approvals.findAll({
where: {
decision: 'pending',
},
include: [
{
model: db.requests,
as: 'request',
include: [
{
model: db.users,
as: 'requester',
},
{
model: db.departments,
as: 'origin_department',
},
{
model: db.departments,
as: 'assigned_department',
},
],
},
],
order: [
['createdAt', 'ASC'],
],
limit: 100,
});
const pendingApprovals = pendingApprovalRows
.filter((approval) => canDecideApproval(approval, currentUser))
.slice(0, 8)
.map((approval) => mapSequelizeRow(approval));
const recentRequestRows = await db.requests.findAll({
where: requestScope,
include: [
{
model: db.users,
as: 'requester',
},
{
model: db.departments,
as: 'origin_department',
},
{
model: db.departments,
as: 'assigned_department',
},
],
order: [
['updatedAt', 'DESC'],
],
limit: 8,
});
const recentRequests = recentRequestRows.map((request) => mapSequelizeRow(request));
const recentRequestIds = recentRequests.map((request) => request.id);
const auditWhere = isElevated(currentUser)
? {}
: {
[Op.or]: [
{
actorId: currentUser.id,
},
...(recentRequestIds.length
? [
{
related_requestId: {
[Op.in]: recentRequestIds,
},
},
]
: []),
],
};
const recentAuditRows = await db.audit_events.findAll({
where: auditWhere,
include: [
{
model: db.users,
as: 'actor',
},
{
model: db.departments,
as: 'from_department',
},
{
model: db.departments,
as: 'to_department',
},
],
order: [
['event_at', 'DESC'],
['createdAt', 'DESC'],
],
limit: 8,
});
const [activeCount, approvedCount, rejectedCount] = await Promise.all([
db.requests.count({
where: {
...requestScope,
status: {
[Op.in]: ACTIVE_STATUSES,
},
},
}),
db.requests.count({
where: {
...requestScope,
status: 'approved',
},
}),
db.requests.count({
where: {
...requestScope,
status: 'rejected',
},
}),
]);
const mappedDepartments = departments.map((department) => mapSequelizeRow(department));
const featuredDepartments = mappedDepartments.filter((department) => {
const code = String(department.code || '').toUpperCase();
return ['OPS', 'FIN', 'HR'].includes(code);
});
return {
role: currentUser?.app_role?.name || 'User',
canApprove: isElevated(currentUser),
canOverride: isAdminLike(currentUser),
departments: featuredDepartments.length ? featuredDepartments : mappedDepartments,
stats: {
active: activeCount,
pendingApprovals: pendingApprovals.length,
approved: approvedCount,
rejected: rejectedCount,
},
pendingApprovals,
recentRequests,
recentAudit: recentAuditRows.map((audit) => mapSequelizeRow(audit)),
};
}
static async createRequest(data, currentUser, req) {
const title = String(data?.title || '').trim();
const description = String(data?.description || '').trim();
const originDepartmentId = data?.origin_department;
const assignedDepartmentId = data?.assigned_department;
const attachment = Array.isArray(data?.attachment) ? data.attachment : [];
if (!title) {
throw createRequestError('A request title is required.');
}
if (!description) {
throw createRequestError('A request description is required.');
}
if (!originDepartmentId || !assignedDepartmentId) {
throw createRequestError('Please choose both the source and target departments.');
}
const transaction = await db.sequelize.transaction();
try {
const [originDepartment, assignedDepartment] = await Promise.all([
db.departments.findByPk(originDepartmentId, { transaction }),
db.departments.findByPk(assignedDepartmentId, { transaction }),
]);
if (!originDepartment || !assignedDepartment) {
throw createRequestError('The selected department could not be found.');
}
const requestNumber = await generateRunningNumber(
db.requests,
`REQ-${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, '0')}`,
transaction,
);
const requiresDirector = Boolean(data?.requires_director) || departmentNeedsDirector(assignedDepartment);
const request = await db.requests.create(
{
request_number: requestNumber,
title,
description,
priority: data?.priority || 'medium',
status: 'submitted',
requires_director: requiresDirector,
submitted_at: new Date(),
restart_count: 0,
rejection_reason: null,
requesterId: currentUser.id,
origin_departmentId: originDepartment.id,
assigned_departmentId: assignedDepartment.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await db.request_approvals.create(
{
step: 'supervisor_hod',
decision: 'pending',
comment: String(data?.submission_note || '').trim() || null,
requestId: request.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await createAuditEvent({
transaction,
currentUser,
req,
action: 'create',
entityType: 'request',
entityKey: requestNumber,
summary: `Created ${requestNumber}`,
details: `Submitted request "${title}" for ${assignedDepartment.name}.`,
relatedRequestId: request.id,
fromDepartmentId: originDepartment.id,
toDepartmentId: assignedDepartment.id,
});
await createAuditEvent({
transaction,
currentUser,
req,
action: 'submit',
entityType: 'request',
entityKey: requestNumber,
summary: `${requestNumber} entered the approval queue`,
details: requiresDirector
? 'The request will continue to director approval after supervisor review.'
: 'The request is waiting for supervisor review.',
relatedRequestId: request.id,
fromDepartmentId: originDepartment.id,
toDepartmentId: assignedDepartment.id,
});
if (attachment.length) {
const file = attachment[0];
const documentNumber = await generateRunningNumber(
db.documents,
`DOC-${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, '0')}`,
transaction,
);
const document = await db.documents.create(
{
document_number: documentNumber,
title: data?.document_title || `${title} Attachment`,
document_type: 'request_attachment',
original_filename: file.name || null,
file_size_bytes: file.sizeInBytes || null,
visibility: 'private',
uploaded_at: new Date(),
is_active: true,
uploaded_by_userId: currentUser.id,
requestId: request.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.documents.getTableName(),
belongsToColumn: 'file_blob',
belongsToId: document.id,
},
attachment,
{
currentUser,
transaction,
},
);
await createAuditEvent({
transaction,
currentUser,
req,
action: 'upload',
entityType: 'document',
entityKey: documentNumber,
summary: `Uploaded ${file.name || documentNumber}`,
details: `Attached the supporting document to ${requestNumber}.`,
relatedRequestId: request.id,
relatedDocumentId: document.id,
toDepartmentId: assignedDepartment.id,
});
}
await transaction.commit();
return RequestsDBApi.findBy({ id: request.id });
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async decideRequest(requestId, data, currentUser, req) {
const decision = String(data?.decision || '').trim().toLowerCase();
const comment = String(data?.comment || '').trim();
if (!['approved', 'rejected'].includes(decision)) {
throw createRequestError('Decision must be either approved or rejected.');
}
const transaction = await db.sequelize.transaction();
try {
const request = await getRequestForWorkflow(requestId, transaction);
if (!request) {
throw createRequestError('Request not found.');
}
const pendingApproval = await getPendingApproval(requestId, transaction);
const adminLike = isAdminLike(currentUser);
const now = new Date();
if (pendingApproval && !canDecideApproval(pendingApproval, currentUser)) {
throw createRequestError('You do not have permission to decide this request.');
}
if (!pendingApproval && !adminLike) {
throw createRequestError('There is no pending approval step for this request.');
}
if (adminLike) {
if (pendingApproval) {
await pendingApproval.update(
{
decision: 'overridden',
decided_at: now,
decided_byId: currentUser.id,
comment: comment || 'Decision overridden by administrator.',
updatedById: currentUser.id,
},
{ transaction },
);
}
await db.request_approvals.create(
{
step: 'admin_override',
decision,
decided_at: now,
comment: comment || null,
requestId: request.id,
decided_byId: currentUser.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await request.update(
{
status: decision === 'approved' ? 'approved' : 'rejected',
rejection_reason: decision === 'rejected' ? comment || 'Rejected by administrator.' : null,
finalized_at: now,
updatedById: currentUser.id,
},
{ transaction },
);
await createAuditEvent({
transaction,
currentUser,
req,
action: 'override',
entityType: 'request',
entityKey: request.request_number,
summary: `Administrator overrode ${request.request_number}`,
details: comment || `Administrator marked the request as ${decision}.`,
relatedRequestId: request.id,
fromDepartmentId: request.origin_departmentId,
toDepartmentId: request.assigned_departmentId,
});
await transaction.commit();
return RequestsDBApi.findBy({ id: request.id });
}
await pendingApproval.update(
{
decision,
decided_at: now,
decided_byId: currentUser.id,
comment: comment || null,
updatedById: currentUser.id,
},
{ transaction },
);
if (decision === 'rejected') {
await request.update(
{
status: 'rejected',
rejection_reason: comment || 'Request rejected.',
finalized_at: now,
updatedById: currentUser.id,
},
{ transaction },
);
await createAuditEvent({
transaction,
currentUser,
req,
action: 'reject',
entityType: 'request',
entityKey: request.request_number,
summary: `${request.request_number} was rejected`,
details: comment || 'Approval flow stopped at the current stage.',
relatedRequestId: request.id,
fromDepartmentId: request.origin_departmentId,
toDepartmentId: request.assigned_departmentId,
});
await transaction.commit();
return RequestsDBApi.findBy({ id: request.id });
}
const needsDirector = Boolean(request.requires_director) || departmentNeedsDirector(request.assigned_department);
if (pendingApproval.step === 'supervisor_hod' && needsDirector) {
await db.request_approvals.create(
{
step: 'director',
decision: 'pending',
requestId: request.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await request.update(
{
status: 'in_review',
requires_director: true,
rejection_reason: null,
finalized_at: null,
updatedById: currentUser.id,
},
{ transaction },
);
await createAuditEvent({
transaction,
currentUser,
req,
action: 'approve',
entityType: 'request',
entityKey: request.request_number,
summary: `Supervisor approved ${request.request_number}`,
details: 'The request has been escalated to the director.',
relatedRequestId: request.id,
fromDepartmentId: request.origin_departmentId,
toDepartmentId: request.assigned_departmentId,
});
await createAuditEvent({
transaction,
currentUser,
req,
action: 'assign',
entityType: 'request',
entityKey: request.request_number,
summary: `${request.request_number} moved to director review`,
details: 'Director approval is required for the assigned department.',
relatedRequestId: request.id,
fromDepartmentId: request.origin_departmentId,
toDepartmentId: request.assigned_departmentId,
});
await transaction.commit();
return RequestsDBApi.findBy({ id: request.id });
}
await request.update(
{
status: 'approved',
rejection_reason: null,
finalized_at: now,
updatedById: currentUser.id,
},
{ transaction },
);
await createAuditEvent({
transaction,
currentUser,
req,
action: 'approve',
entityType: 'request',
entityKey: request.request_number,
summary: `${request.request_number} was approved`,
details: comment || 'The request completed the approval chain.',
relatedRequestId: request.id,
fromDepartmentId: request.origin_departmentId,
toDepartmentId: request.assigned_departmentId,
});
await transaction.commit();
return RequestsDBApi.findBy({ id: request.id });
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async restartRequest(requestId, currentUser, req) {
const transaction = await db.sequelize.transaction();
try {
const request = await getRequestForWorkflow(requestId, transaction);
if (!request) {
throw createRequestError('Request not found.');
}
const canRestart = isAdminLike(currentUser) || request.requesterId === currentUser.id;
if (!canRestart) {
throw createRequestError('Only the original requester or an administrator can restart this request.');
}
if (request.status !== 'rejected') {
throw createRequestError('Only rejected requests can be restarted.');
}
await request.update(
{
status: 'restarted',
submitted_at: new Date(),
finalized_at: null,
rejection_reason: null,
restart_count: (request.restart_count || 0) + 1,
updatedById: currentUser.id,
},
{ transaction },
);
await db.request_approvals.create(
{
step: 'supervisor_hod',
decision: 'pending',
requestId: request.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await createAuditEvent({
transaction,
currentUser,
req,
action: 'restart',
entityType: 'request',
entityKey: request.request_number,
summary: `${request.request_number} restarted`,
details: 'The request was sent back to the beginning of the decision flow.',
relatedRequestId: request.id,
fromDepartmentId: request.origin_departmentId,
toDepartmentId: request.assigned_departmentId,
});
await transaction.commit();
return RequestsDBApi.findBy({ id: request.id });
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async logDocumentEvent(requestId, data, currentUser, req) {
const action = String(data?.action || '').trim().toLowerCase();
const documentId = data?.documentId;
if (!['preview', 'download'].includes(action)) {
throw createRequestError('Unsupported document event action.');
}
if (!documentId) {
throw createRequestError('A document is required for this event.');
}
const transaction = await db.sequelize.transaction();
try {
const request = await db.requests.findByPk(requestId, { transaction });
const document = await db.documents.findOne({
where: {
id: documentId,
requestId,
},
transaction,
});
if (!request || !document) {
throw createRequestError('The selected attachment could not be found.');
}
await createAuditEvent({
transaction,
currentUser,
req,
action,
entityType: 'document',
entityKey: document.document_number || document.id,
summary: `${action === 'preview' ? 'Previewed' : 'Downloaded'} ${document.original_filename || document.title}`,
details: `Activity recorded for ${request.request_number || request.id}.`,
relatedRequestId: request.id,
relatedDocumentId: document.id,
});
await transaction.commit();
return {
success: true,
};
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -0,0 +1,81 @@
export const normalizeRoleName = (roleName?: string) =>
String(roleName || '')
.toLowerCase()
.replace(/\s+/g, '');
export const isAdminLike = (currentUser?: any) => {
const normalizedRole = normalizeRoleName(currentUser?.app_role?.name);
return normalizedRole === 'administrator' || normalizedRole === 'systemowner';
};
export const isDirectorRole = (currentUser?: any) => {
return normalizeRoleName(currentUser?.app_role?.name) === 'director';
};
export const isSupervisorRole = (currentUser?: any) => {
return normalizeRoleName(currentUser?.app_role?.name) === 'departmentsupervisor';
};
export const getRequestStatusClasses = (status?: string) => {
switch (status) {
case 'approved':
return 'bg-emerald-500/15 text-emerald-700 ring-1 ring-emerald-400/40 dark:text-emerald-300';
case 'rejected':
return 'bg-rose-500/15 text-rose-700 ring-1 ring-rose-400/40 dark:text-rose-300';
case 'in_review':
return 'bg-amber-500/15 text-amber-700 ring-1 ring-amber-400/40 dark:text-amber-300';
case 'submitted':
return 'bg-sky-500/15 text-sky-700 ring-1 ring-sky-400/40 dark:text-sky-300';
case 'restarted':
return 'bg-violet-500/15 text-violet-700 ring-1 ring-violet-400/40 dark:text-violet-300';
default:
return 'bg-slate-500/10 text-slate-700 ring-1 ring-slate-400/30 dark:text-slate-300';
}
};
export const getPriorityClasses = (priority?: string) => {
switch (priority) {
case 'urgent':
return 'text-rose-600 dark:text-rose-300';
case 'high':
return 'text-amber-600 dark:text-amber-300';
case 'medium':
return 'text-sky-600 dark:text-sky-300';
default:
return 'text-emerald-600 dark:text-emerald-300';
}
};
export const getApprovalStepLabel = (step?: string) => {
switch (step) {
case 'supervisor_hod':
return 'Supervisor review';
case 'director':
return 'Director review';
case 'finance_hr':
return 'Finance / HR review';
case 'admin_override':
return 'Admin override';
default:
return 'Decision pending';
}
};
export const getRoleAccentClasses = (roleName?: string) => {
const normalizedRole = normalizeRoleName(roleName);
if (normalizedRole === 'administrator' || normalizedRole === 'systemowner') {
return 'bg-[#ffe169]/20 text-[#7a5a00] ring-1 ring-[#ffd84d]/50';
}
if (normalizedRole === 'director') {
return 'bg-[#34d399]/20 text-[#065f46] ring-1 ring-[#34d399]/40';
}
if (normalizedRole === 'departmentsupervisor') {
return 'bg-[#2dd4bf]/20 text-[#115e59] ring-1 ring-[#2dd4bf]/40';
}
return 'bg-white/10 text-white/90 ring-1 ring-white/20';
};

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline, icon: icon.mdiViewDashboardOutline,
label: 'Dashboard', label: 'Dashboard',
}, },
{
href: '/workflow-center',
icon: icon.mdiChartTimelineVariant,
label: 'Workflow Center',
permissions: 'READ_REQUESTS',
},
{ {
href: '/users/users-list', href: '/users/users-list',

View File

@ -1,161 +1,143 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config'; 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() { export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('background');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Request & Approval Manager'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return ( return (
<div <div className='min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(255,225,105,0.3),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.22),_transparent_34%),linear-gradient(135deg,_#06281f,_#0b3d2f_55%,_#0a5a44)] text-white'>
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('Request & Approval Manager')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <div className='mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-8 lg:px-10'>
<div <header className='flex flex-col gap-4 rounded-[32px] border border-white/10 bg-white/5 px-6 py-5 backdrop-blur-xl md:flex-row md:items-center md:justify-between'>
className={`flex ${ <div>
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <p className='text-xs uppercase tracking-[0.24em] text-white/60'>Internal workflow platform</p>
} min-h-screen w-full`} <h1 className='mt-2 text-2xl font-semibold'>Request & Approval Manager</h1>
> </div>
{contentType === 'image' && contentPosition !== 'background' <div className='flex flex-wrap gap-3'>
? imageBlock(illustrationImage) <BaseButton href='/login' color='whiteDark' label='Login' />
: null} <BaseButton href='/dashboard' color='info' label='Admin interface' />
{contentType === 'video' && contentPosition !== 'background' </div>
? videoBlock(illustrationVideo) </header>
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> <main className='flex flex-1 flex-col justify-center py-12'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'> <section className='grid gap-8 lg:grid-cols-[1.15fr_0.85fr] lg:items-center'>
<CardBoxComponentTitle title="Welcome to your Request & Approval Manager app!"/> <div className='space-y-6'>
<div className='inline-flex rounded-full border border-[#ffe169]/40 bg-[#ffe169]/15 px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#ffe169]'>
<div className="space-y-3"> Yellow Emerald design system
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p> </div>
<p className='text-center '>For guides and documentation please check <div className='space-y-5'>
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p> <h2 className='max-w-4xl text-5xl font-semibold leading-tight tracking-tight md:text-6xl'>
A modern decision workspace for internal requests, approvals, and document handoffs.
</h2>
<p className='max-w-3xl text-lg leading-8 text-white/75'>
The first MVP slice is already focused on the real workflow: requesters can submit with an attachment, approvers get a queue,
admins can override decisions, and the request detail screen now exposes approvals, documents, and audit history together.
</p>
</div>
<div className='flex flex-wrap gap-4'>
<BaseButton href='/login' color='info' label='Open login' />
<BaseButton href='/workflow-center' color='whiteDark' label='Open workflow center' />
</div>
<div className='grid gap-4 md:grid-cols-3'>
{roleCards.map((card) => (
<div key={card.role} className='rounded-[28px] border border-white/10 bg-white/5 p-5 backdrop-blur-xl'>
<p className='text-xs uppercase tracking-[0.18em] text-[#ffe169]'>{card.role}</p>
<p className='mt-3 text-sm leading-6 text-white/75'>{card.summary}</p>
</div>
))}
</div>
</div> </div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons> <CardBox className='border border-white/10 bg-white/5 text-white shadow-2xl backdrop-blur-xl'>
</CardBox> <div className='space-y-5'>
</div> <div className='rounded-[28px] border border-white/10 bg-gradient-to-br from-[#ffe169]/20 to-transparent p-5'>
</div> <p className='text-xs uppercase tracking-[0.2em] text-white/60'>Included in this iteration</p>
</SectionFullScreen> <h3 className='mt-3 text-2xl font-semibold'>Thin slice, end to end</h3>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> <p className='mt-3 text-sm leading-7 text-white/75'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p> Create a request, attach a document, approve or reject it, restart on decline, and review the resulting audit trail without rebuilding generic CRUD.
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> </p>
Privacy Policy </div>
</Link> <div className='space-y-3 rounded-[28px] border border-white/10 bg-[#0b3429]/70 p-5'>
</div> <p className='text-xs uppercase tracking-[0.18em] text-[#ffe169]'>Admin shortcuts</p>
<div className='grid gap-3 sm:grid-cols-2'>
<BaseButton href='/users/users-list' color='whiteDark' label='Users' />
<BaseButton href='/departments/departments-list' color='whiteDark' label='Departments' />
<BaseButton href='/document_intakes/document_intakes-list' color='whiteDark' label='Front desk intake' />
<BaseButton href='/system_settings/system_settings-list' color='whiteDark' label='Header & footer' />
</div>
</div>
<p className='rounded-[28px] border border-[#34d399]/30 bg-[#34d399]/10 px-4 py-4 text-sm leading-6 text-white/80'>
Tip: the login page now includes quick-fill demo credentials for admin, supervisor, director, and user accounts.
</p>
</div>
</CardBox>
</section>
<section className='mt-12 grid gap-5 md:grid-cols-2 xl:grid-cols-4'>
{featureCards.map((feature) => (
<div key={feature.title} className='rounded-[28px] border border-white/10 bg-white/5 p-6 backdrop-blur-xl'>
<p className='text-sm font-semibold text-[#ffe169]'>{feature.title}</p>
<p className='mt-3 text-sm leading-7 text-white/75'>{feature.description}</p>
</div>
))}
</section>
</main>
<footer className='flex flex-col gap-3 border-t border-white/10 pt-6 text-sm text-white/60 md:flex-row md:items-center md:justify-between'>
<p>© 2026 Request & Approval Manager. Built for server-side approval workflows.</p>
<div className='flex flex-wrap gap-4'>
<Link className='transition hover:text-white' href='/login'>
Login
</Link>
<Link className='transition hover:text-white' href='/dashboard'>
Dashboard
</Link>
<Link className='transition hover:text-white' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</footer>
</div>
</div> </div>
); );
} }
@ -163,4 +145,3 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) { Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };

View File

@ -1,273 +1,218 @@
import { mdiEye, mdiEyeOff } from '@mdi/js';
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import BaseButton from '../components/BaseButton'; import React, { useEffect } from 'react';
import CardBox from '../components/CardBox'; import type { ReactElement } from 'react';
import BaseIcon from "../components/BaseIcon";
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik'; 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 FormCheckRadio from '../components/FormCheckRadio';
import BaseDivider from '../components/BaseDivider'; import FormField from '../components/FormField';
import BaseButtons from '../components/BaseButtons'; import LayoutGuest from '../layouts/Guest';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { findMe, loginUser, resetAction } from '../stores/authSlice'; import { findMe, loginUser, resetAction } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify"; const demoAccounts = [
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels' {
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() { export default function Login() {
const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const textColor = useAppSelector((state) => state.style.linkColor); const { currentUser, isFetching, errorMessage, token, notify } = useAppSelector((state) => state.auth);
const iconsColor = useAppSelector((state) => state.style.iconsColor); const [showPassword, setShowPassword] = React.useState(false);
const notify = (type, msg) => toast(msg, { type }); const [initialValues, setInitialValues] = React.useState({
const [ illustrationImage, setIllustrationImage ] = useState({ email: 'admin',
src: undefined, password: 'Tw3nde2025',
photographer: undefined, remember: true,
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 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(() => { useEffect(() => {
if (token) { if (token) {
dispatch(findMe()); dispatch(findMe());
} }
}, [token, dispatch]); }, [dispatch, token]);
// Redirect to dashboard if user is logged in
useEffect(() => { useEffect(() => {
if (currentUser?.id) { if (currentUser?.id) {
router.push('/dashboard'); window.location.href = '/dashboard';
} }
}, [currentUser?.id, router]); }, [currentUser?.id]);
// Show error message if there is one
useEffect(() => { useEffect(() => {
if (errorMessage){ if (errorMessage) {
notify('error', errorMessage) toast(errorMessage, { type: 'error' });
} }
}, [errorMessage]);
}, [errorMessage])
// Show notification if there is one
useEffect(() => { useEffect(() => {
if (notifyState?.showNotification) { if (notify?.showNotification) {
notify('success', notifyState?.textNotification) toast(notify.textNotification, { type: 'success' });
dispatch(resetAction()); dispatch(resetAction());
} }
}, [notifyState?.showNotification]) }, [dispatch, notify?.showNotification, notify?.textNotification]);
const togglePasswordVisibility = () => { const applyDemoLogin = (login: string, password: string) => {
setShowPassword(!showPassword); setInitialValues({
email: login,
password,
remember: true,
});
}; };
const handleSubmit = async (value) => { const handleSubmit = async (values) => {
const {remember, ...rest} = value const { remember, ...credentials } = values;
await dispatch(loginUser(rest)); await dispatch(loginUser(credentials));
};
const setLogin = (target: HTMLElement) => {
setInitialValues(prev => ({
...prev,
email : target.innerText.trim(),
password: target.dataset.password ?? '',
}));
};
const imageBlock = (image) => (
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
style={{
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}>
<div className="flex justify-center w-full bg-blue-300/20">
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
by {image?.photographer} on Pexels</a>
</div>
</div>
)
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video.user.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
}; };
return ( return (
<div style={contentPosition === 'background' ? { <div className='min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(255,225,105,0.26),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.22),_transparent_34%),linear-gradient(135deg,_#06281f,_#0b3d2f_55%,_#0a5a44)] text-white'>
backgroundImage: `${ <Head>
illustrationImage <title>{getPageTitle('Login')}</title>
? `url(${illustrationImage.src?.original})` </Head>
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
} : {}}>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<SectionFullScreen bg='violet'> <div className='mx-auto grid min-h-screen max-w-7xl gap-8 px-6 py-8 lg:grid-cols-[1.08fr_0.92fr] lg:items-center lg:px-10'>
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}> <section className='space-y-8'>
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null} <div className='inline-flex rounded-full border border-[#ffe169]/40 bg-[#ffe169]/15 px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#ffe169]'>
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null} Yellow Emerald login
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
<h2 className="text-4xl font-semibold my-4">{title}</h2>
<div className='flex flex-row justify-between'>
<div>
<p className='mb-2'>Use{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="806bd392"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>806bd392</code>{' / '}
to login as Admin</p>
<p>Use <code
className={`cursor-pointer ${textColor} `}
data-password="618e272f4df4"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>618e272f4df4</code>{' / '}
to login as User</p>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={mdiInformation}
/>
</div>
</div>
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<Formik
initialValues={initialValues}
enableReinitialize
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label='Login'
help='Please enter your login'>
<Field name='email' />
</FormField>
<div className='relative'>
<FormField
label='Password'
help='Please enter your password'>
<Field name='password' type={showPassword ? 'text' : 'password'} />
</FormField>
<div
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
onClick={togglePasswordVisibility}
>
<BaseIcon
className='text-gray-500 hover:text-gray-700'
size={20}
path={showPassword ? mdiEyeOff : mdiEye}
/>
</div>
</div>
<div className={'flex justify-between'}>
<FormCheckRadio type='checkbox' label='Remember'>
<Field type='checkbox' name='remember' />
</FormCheckRadio>
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
Forgot password?
</Link>
</div>
<BaseDivider />
<BaseButtons>
<BaseButton
className={'w-full'}
type='submit'
label={isFetching ? 'Loading...' : 'Login'}
color='info'
disabled={isFetching}
/>
</BaseButtons>
<br />
<p className={'text-center'}>
Dont have an account yet?{' '}
<Link className={`${textColor}`} href={'/register'}>
New Account
</Link>
</p>
</Form>
</Formik>
</CardBox>
</div>
</div> </div>
</SectionFullScreen> <div className='space-y-5'>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> <h1 className='text-5xl font-semibold leading-tight tracking-tight md:text-6xl'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p> Login to the request and approval command center.
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> </h1>
Privacy Policy <p className='max-w-3xl text-lg leading-8 text-white/75'>
</Link> This glassmorphic sign-in experience is tuned for the internal workflow: requester submission, supervisor review,
</div> director escalation, and administrator override all against SQL-backed data, not browser-only state.
<ToastContainer /> </p>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
{demoAccounts.map((account) => (
<button
key={account.role}
type='button'
onClick={() => applyDemoLogin(account.login, account.password)}
className='rounded-[28px] border border-white/10 bg-white/5 p-5 text-left transition duration-150 hover:bg-white/10'
>
<div className='flex items-center justify-between gap-3'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#ffe169]'>{account.role}</p>
<span className='rounded-full bg-white/10 px-3 py-1 text-xs text-white/70'>Use demo</span>
</div>
<p className='mt-3 text-base font-semibold'>{account.login}</p>
<p className='mt-1 text-xs uppercase tracking-[0.18em] text-white/50'>{account.password}</p>
<p className='mt-4 text-sm leading-6 text-white/70'>{account.description}</p>
</button>
))}
</div>
<div className='rounded-[28px] border border-white/10 bg-white/5 p-6 text-sm leading-7 text-white/75 backdrop-blur-xl'>
<p className='font-semibold text-white'>What this role-aware build already covers</p>
<ul className='mt-3 space-y-2'>
<li> Workflow Center for request creation, approval queue, restart handling, and audit visibility.</li>
<li> Request detail page with document preview/download plus movement logging.</li>
<li> Direct links into users, departments, front desk intake, and header/footer settings.</li>
</ul>
</div>
</section>
<CardBox className='border border-white/10 bg-white/10 text-white shadow-2xl backdrop-blur-2xl'>
<div className='space-y-6'>
<div>
<p className='text-sm uppercase tracking-[0.22em] text-white/60'>Secure access</p>
<h2 className='mt-3 text-3xl font-semibold'>Welcome back</h2>
<p className='mt-3 text-sm leading-7 text-white/70'>
Choose a role above to prefill the credentials or enter them manually below.
</p>
</div>
<Formik initialValues={initialValues} enableReinitialize onSubmit={handleSubmit}>
<Form>
<FormField label='Login' labelFor='email' help='Use one of the demo usernames above or your own account.'>
<Field id='email' name='email' placeholder='admin' />
</FormField>
<div className='relative'>
<FormField label='Password' labelFor='password' help='The password stays hidden until you choose to reveal it.'>
<Field id='password' name='password' type={showPassword ? 'text' : 'password'} placeholder='Enter your password' />
</FormField>
<button
type='button'
className='absolute right-4 top-[54px] text-white/50 transition hover:text-white'
onClick={() => setShowPassword((currentValue) => !currentValue)}
>
<BaseIcon size={20} path={showPassword ? mdiEyeOff : mdiEye} />
</button>
</div>
<div className='flex flex-wrap items-center justify-between gap-3 text-sm text-white/70'>
<FormCheckRadio type='checkbox' label='Remember'>
<Field type='checkbox' name='remember' />
</FormCheckRadio>
<Link className='transition hover:text-white' href='/forgot'>
Forgot password?
</Link>
</div>
<div className='mt-8 flex flex-wrap gap-3'>
<BaseButton
className='flex-1'
type='submit'
color='info'
label={isFetching ? 'Logging in...' : 'Login to dashboard'}
disabled={isFetching}
/>
<BaseButton href='/' color='whiteDark' label='Back home' />
</div>
</Form>
</Formik>
<div className='rounded-[28px] border border-[#34d399]/30 bg-[#34d399]/10 p-5 text-sm leading-7 text-white/75'>
<p className='font-semibold text-white'>Need a guided start?</p>
<p className='mt-1'>Log in as <span className='font-semibold text-[#ffe169]'>user</span> to submit a request, then switch to <span className='font-semibold text-[#ffe169]'>supervisor</span> or <span className='font-semibold text-[#ffe169]'>director</span> to move it through the approval chain.</p>
</div>
<p className='text-center text-sm text-white/60'>
Dont have an account yet?{' '}
<Link className='font-semibold text-[#ffe169] transition hover:text-white' href='/register'>
Create one
</Link>
</p>
</div>
</CardBox>
</div> </div>
<div className='border-t border-white/10 px-6 py-6 text-center text-sm text-white/60 lg:px-10'>
<p>
© 2026 Request & Approval Manager <Link className='transition hover:text-white' href='/privacy-policy/'>Privacy Policy</Link>
</p>
</div>
<ToastContainer />
</div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,811 @@
import {
mdiChartTimelineVariant,
mdiClipboardTextClock,
mdiCogOutline,
mdiDownload,
mdiFileDocumentEditOutline,
mdiRestart,
mdiUpload,
mdiViewDashboardOutline,
} from '@mdi/js';
import axios from 'axios';
import dayjs from 'dayjs';
import { Field, Form, Formik } from 'formik';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseDivider from '../components/BaseDivider';
import CardBox from '../components/CardBox';
import CardBoxModal from '../components/CardBoxModal';
import FormField from '../components/FormField';
import FormFilePicker from '../components/FormFilePicker';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions';
import {
getApprovalStepLabel,
getPriorityClasses,
getRequestStatusClasses,
getRoleAccentClasses,
isAdminLike,
isDirectorRole,
isSupervisorRole,
} from '../helpers/requestWorkflow';
import LayoutAuthenticated from '../layouts/Authenticated';
import { useAppSelector } from '../stores/hooks';
interface WorkflowDepartment {
id: string;
name: string;
code?: string;
requires_director_approval?: boolean;
}
interface WorkflowRequest {
id: string;
request_number: string;
title: string;
status: string;
priority: string;
submitted_at?: string;
updatedAt?: string;
requester?: {
firstName?: string;
lastName?: string;
};
origin_department?: WorkflowDepartment;
assigned_department?: WorkflowDepartment;
}
interface WorkflowApproval {
id: string;
step: string;
requestId?: string;
request?: WorkflowRequest;
}
interface WorkflowAuditItem {
id: string;
action: string;
summary: string;
details?: string;
event_at?: string;
actor?: {
firstName?: string;
lastName?: string;
};
from_department?: WorkflowDepartment;
to_department?: WorkflowDepartment;
}
interface WorkflowSummary {
role: string;
canApprove: boolean;
canOverride: boolean;
departments: WorkflowDepartment[];
stats: {
active: number;
pendingApprovals: number;
approved: number;
rejected: number;
};
pendingApprovals: WorkflowApproval[];
recentRequests: WorkflowRequest[];
recentAudit: WorkflowAuditItem[];
}
const initialRequestValues = {
title: '',
description: '',
priority: 'medium',
origin_department: '',
assigned_department: '',
submission_note: '',
attachment: [],
};
const WorkflowCenterPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const [summary, setSummary] = React.useState<WorkflowSummary | null>(null);
const [loading, setLoading] = React.useState(true);
const [pageError, setPageError] = React.useState('');
const [feedback, setFeedback] = React.useState<{
type: 'success' | 'error';
text: string;
requestId?: string;
} | null>(null);
const [decisionState, setDecisionState] = React.useState<{
requestId: string;
action: 'approved' | 'rejected';
title: string;
} | null>(null);
const [decisionComment, setDecisionComment] = React.useState('');
const [busyRequestId, setBusyRequestId] = React.useState('');
const loadSummary = React.useCallback(async () => {
setLoading(true);
setPageError('');
try {
const response = await axios.get('/requests/workflow/summary');
setSummary(response.data);
} catch (error) {
const errorMessage = axios.isAxiosError(error)
? String(error.response?.data || error.message)
: 'Unable to load your request workflow right now.';
setPageError(errorMessage);
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
if (!currentUser?.id) {
return;
}
loadSummary();
}, [currentUser?.id, loadSummary]);
const closeDecisionModal = () => {
setDecisionState(null);
setDecisionComment('');
};
const openDecisionModal = (
requestId: string,
action: 'approved' | 'rejected',
title: string,
) => {
setDecisionState({ requestId, action, title });
setDecisionComment('');
};
const submitDecision = async () => {
if (!decisionState) {
return;
}
setBusyRequestId(decisionState.requestId);
setFeedback(null);
try {
await axios.put(`/requests/${decisionState.requestId}/workflow-decision`, {
data: {
decision: decisionState.action,
comment: decisionComment,
},
});
setFeedback({
type: 'success',
text: `${decisionState.title} was ${decisionState.action}.`,
requestId: decisionState.requestId,
});
closeDecisionModal();
await loadSummary();
} catch (error) {
const errorMessage = axios.isAxiosError(error)
? String(error.response?.data || error.message)
: 'Unable to apply that decision right now.';
setFeedback({
type: 'error',
text: errorMessage,
});
} finally {
setBusyRequestId('');
}
};
const restartRequest = async (requestId: string, requestNumber: string) => {
setBusyRequestId(requestId);
setFeedback(null);
try {
await axios.put(`/requests/${requestId}/workflow-restart`, {});
setFeedback({
type: 'success',
text: `${requestNumber} was restarted and moved back into review.`,
requestId,
});
await loadSummary();
} catch (error) {
const errorMessage = axios.isAxiosError(error)
? String(error.response?.data || error.message)
: 'Unable to restart the request.';
setFeedback({
type: 'error',
text: errorMessage,
});
} finally {
setBusyRequestId('');
}
};
const pendingApprovalMap = React.useMemo(() => {
return new Map(
(summary?.pendingApprovals || []).map((approval) => [approval.request?.id, approval]),
);
}, [summary?.pendingApprovals]);
const canApproveRequests = Boolean(summary?.canApprove);
const canOverrideRequests = Boolean(summary?.canOverride);
const isRequesterOnly = !isAdminLike(currentUser) && !isDirectorRole(currentUser) && !isSupervisorRole(currentUser);
const statCards = [
{
label: isRequesterOnly ? 'Open requests' : 'Active requests',
value: summary?.stats.active ?? 0,
tone: 'from-[#ffe169]/30 via-[#ffe169]/10 to-transparent',
},
{
label: 'Pending decisions',
value: summary?.stats.pendingApprovals ?? 0,
tone: 'from-[#34d399]/25 via-[#34d399]/10 to-transparent',
},
{
label: 'Approved',
value: summary?.stats.approved ?? 0,
tone: 'from-[#6ee7b7]/30 via-[#6ee7b7]/10 to-transparent',
},
{
label: 'Rejected',
value: summary?.stats.rejected ?? 0,
tone: 'from-[#fb7185]/25 via-[#fb7185]/10 to-transparent',
},
];
return (
<>
<Head>
<title>{getPageTitle('Workflow Center')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Workflow Center'
main
>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/dashboard' color='info' icon={mdiViewDashboardOutline} label='Dashboard' />
<BaseButton href='/requests/requests-list' color='whiteDark' icon={mdiFileDocumentEditOutline} label='All requests' />
</div>
</SectionTitleLineWithButton>
<CardBox className='mb-6 overflow-hidden border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(255,225,105,0.24),_transparent_38%),linear-gradient(135deg,_#0c3d2f,_#0f5e47_55%,_#0a2e24)] text-white shadow-2xl'>
<div className='grid gap-8 lg:grid-cols-[1.45fr_0.85fr]'>
<div className='space-y-4'>
<div className='inline-flex items-center rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white/80'>
Yellow Emerald workflow
</div>
<div className='space-y-3'>
<h2 className='text-3xl font-semibold tracking-tight md:text-4xl'>
Submit requests, route decisions, and keep every document traceable.
</h2>
<p className='max-w-3xl text-sm leading-7 text-white/75 md:text-base'>
This first iteration turns the generated CRUD into a real approval flow: requesters can submit with an attachment,
supervisors or directors can decide from a shared queue, administrators can override, and the audit trail follows the request.
</p>
</div>
<div className='flex flex-wrap gap-3 text-sm text-white/80'>
<span className={`rounded-full px-3 py-1 ${getRoleAccentClasses(summary?.role)}`}>
Signed in as {summary?.role || currentUser?.app_role?.name || 'User'}
</span>
<span className='rounded-full bg-white/10 px-3 py-1 ring-1 ring-white/10'>
Director review triggers automatically for Finance / HR requests
</span>
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-1'>
{statCards.map((card) => (
<div
key={card.label}
className={`rounded-3xl border border-white/10 bg-gradient-to-br ${card.tone} p-4 backdrop-blur-sm`}
>
<p className='text-xs uppercase tracking-[0.18em] text-white/60'>{card.label}</p>
<p className='mt-3 text-3xl font-semibold'>{card.value}</p>
</div>
))}
</div>
</div>
</CardBox>
{feedback && (
<CardBox
className={`mb-6 border ${
feedback.type === 'success'
? 'border-emerald-400/40 bg-emerald-500/10'
: 'border-rose-400/40 bg-rose-500/10'
}`}
>
<div className='flex flex-col gap-3 md:flex-row md:items-center md:justify-between'>
<p className='text-sm font-medium'>{feedback.text}</p>
{feedback.requestId && (
<BaseButton
href={`/requests/requests-view/?id=${feedback.requestId}`}
color='info'
label='View request'
/>
)}
</div>
</CardBox>
)}
{pageError && (
<CardBox className='mb-6 border border-rose-400/40 bg-rose-500/10'>
<p className='text-sm text-rose-100'>{pageError}</p>
</CardBox>
)}
<div className='grid gap-6 xl:grid-cols-[1.15fr_0.85fr]'>
<CardBox className='border border-white/10'>
<div className='flex items-start justify-between gap-4'>
<div>
<h3 className='text-2xl font-semibold'>Create a new request</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
Capture the request, choose a department, and upload one supporting document to start the approval trail.
</p>
</div>
<div className='rounded-2xl bg-[#ffe169]/20 p-3 text-[#7a5a00] ring-1 ring-[#ffd84d]/40'>
<svg className='h-5 w-5' viewBox='0 0 24 24' fill='currentColor' aria-hidden='true'>
<path d='M12 2a5 5 0 0 1 5 5v1h.25A3.75 3.75 0 0 1 21 11.75v6.5A3.75 3.75 0 0 1 17.25 22h-10.5A3.75 3.75 0 0 1 3 18.25v-6.5A3.75 3.75 0 0 1 6.75 8H7V7a5 5 0 0 1 5-5Zm3 5a3 3 0 0 0-6 0v1h6V7Zm-3 4.25a1.25 1.25 0 0 0-1.25 1.25v2.5a1.25 1.25 0 1 0 2.5 0v-2.5A1.25 1.25 0 0 0 12 11.25Z' />
</svg>
</div>
</div>
<BaseDivider />
<Formik
initialValues={initialRequestValues}
onSubmit={async (values, { resetForm, setSubmitting }) => {
setFeedback(null);
try {
const response = await axios.post('/requests/workflow/request', {
data: values,
});
setFeedback({
type: 'success',
text: `${response.data.request_number} was submitted successfully.`,
requestId: response.data.id,
});
resetForm();
await loadSummary();
} catch (error) {
const errorMessage = axios.isAxiosError(error)
? String(error.response?.data || error.message)
: 'Unable to submit the request.';
setFeedback({
type: 'error',
text: errorMessage,
});
} finally {
setSubmitting(false);
}
}}
>
{({ isSubmitting }) => (
<Form>
<div className='grid gap-6 md:grid-cols-2'>
<FormField label='Request title' labelFor='title' help='This becomes the visible title across the queue.'>
<Field id='title' name='title' placeholder='e.g. Budget approval for field operations' />
</FormField>
<FormField label='Priority' labelFor='priority' help='Set the urgency before routing the decision.'>
<Field as='select' id='priority' name='priority'>
<option value='low'>Low</option>
<option value='medium'>Medium</option>
<option value='high'>High</option>
<option value='urgent'>Urgent</option>
</Field>
</FormField>
</div>
<FormField label='Request details' labelFor='description' hasTextareaHeight help='Give approvers enough context to make a clear decision.'>
<Field as='textarea' id='description' name='description' placeholder='Summarize the request, the business reason, and the expected outcome.' />
</FormField>
<div className='grid gap-6 md:grid-cols-2'>
<FormField label='Origin department' labelFor='origin_department'>
<Field as='select' id='origin_department' name='origin_department'>
<option value=''>Select a department</option>
{(summary?.departments || []).map((department) => (
<option key={department.id} value={department.id}>
{department.name}{department.code ? ` (${department.code})` : ''}
</option>
))}
</Field>
</FormField>
<FormField label='Target department' labelFor='assigned_department' help='Finance and HR requests automatically trigger director review.'>
<Field as='select' id='assigned_department' name='assigned_department'>
<option value=''>Select a department</option>
{(summary?.departments || []).map((department) => (
<option key={department.id} value={department.id}>
{department.name}{department.requires_director_approval ? ' • Director approval' : ''}
</option>
))}
</Field>
</FormField>
</div>
<FormField label='Submission note' labelFor='submission_note' help='Optional note for the first approver.'>
<Field id='submission_note' name='submission_note' placeholder='Optional context for the supervisor or director' />
</FormField>
<FormField label='Attach document' help='Upload one supporting file now. It will be linked to the request and visible from the detail view.'>
<Field
label='Upload attachment'
color='info'
icon={mdiUpload}
path='requests/attachment'
name='attachment'
id='attachment'
schema={{
size: undefined,
formats: undefined,
}}
component={FormFilePicker}
/>
</FormField>
<div className='rounded-3xl border border-slate-200/70 bg-slate-50/80 p-4 text-sm leading-6 text-slate-600 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300'>
<p className='font-semibold text-slate-900 dark:text-white'>Approval rules in this slice</p>
<ul className='mt-2 space-y-2'>
<li> The request enters supervisor review immediately after submission.</li>
<li> Finance and Human Resources automatically escalate to director approval.</li>
<li> If a request is declined, the requester can restart it without losing audit history.</li>
</ul>
</div>
<BaseDivider />
<div className='flex flex-wrap gap-3'>
<BaseButton
type='submit'
color='info'
label={isSubmitting ? 'Submitting...' : 'Submit request'}
disabled={isSubmitting}
/>
<BaseButton
href='/documents/documents-list'
color='whiteDark'
icon={mdiDownload}
label='Document library'
/>
</div>
</Form>
)}
</Formik>
</CardBox>
<div className='space-y-6'>
<CardBox className='border border-white/10 bg-[linear-gradient(135deg,_rgba(255,225,105,0.14),_rgba(15,94,71,0.05))]'>
<div className='flex items-start justify-between gap-4'>
<div>
<h3 className='text-xl font-semibold'>Quick links</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
Jump straight into the existing admin screens while this workflow page handles the high-value daily flow.
</p>
</div>
<span className='rounded-full bg-[#0f5e47]/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-[#0f5e47] dark:text-emerald-300'>
First delivery
</span>
</div>
<div className='mt-6 grid gap-3 sm:grid-cols-2'>
<BaseButton href='/request_approvals/request_approvals-list' color='whiteDark' label='Approval history' />
<BaseButton href='/document_intakes/document_intakes-list' color='whiteDark' label='Front desk intake' />
{hasPermission(currentUser, 'READ_SYSTEM_SETTINGS') && (
<BaseButton href='/system_settings/system_settings-list' color='whiteDark' icon={mdiCogOutline} label='Header & footer' />
)}
{hasPermission(currentUser, 'READ_USERS') && (
<BaseButton href='/users/users-list' color='whiteDark' label='Users & roles' />
)}
</div>
</CardBox>
<CardBox className='border border-white/10'>
<h3 className='text-xl font-semibold'>What happens next</h3>
<div className='mt-4 space-y-4 text-sm leading-6 text-slate-500 dark:text-slate-300'>
<div className='rounded-2xl bg-slate-50/80 p-4 dark:bg-dark-800'>
<p className='font-semibold text-slate-900 dark:text-white'>1. Submission</p>
<p className='mt-1'>The requester creates a request, uploads a document, and instantly receives a request number.</p>
</div>
<div className='rounded-2xl bg-slate-50/80 p-4 dark:bg-dark-800'>
<p className='font-semibold text-slate-900 dark:text-white'>2. Decision</p>
<p className='mt-1'>Supervisors decide first. Finance and HR requests continue to the director. Administrators can override the final outcome.</p>
</div>
<div className='rounded-2xl bg-slate-50/80 p-4 dark:bg-dark-800'>
<p className='font-semibold text-slate-900 dark:text-white'>3. Traceability</p>
<p className='mt-1'>Every upload, approval, rejection, restart, preview, and download is written to the audit trail.</p>
</div>
</div>
</CardBox>
</div>
</div>
<div className='mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]'>
<CardBox className='border border-white/10'>
<div className='flex items-start justify-between gap-4'>
<div>
<h3 className='text-2xl font-semibold'>
{canApproveRequests ? 'Decision queue' : 'My requests'}
</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
{canApproveRequests
? 'Approve or reject pending work without leaving the dashboard.'
: 'Track your latest submissions and restart any request that was declined.'}
</p>
</div>
</div>
<div className='mt-6 space-y-4'>
{loading && !summary && (
<div className='rounded-3xl border border-slate-200/70 bg-slate-50 p-6 text-sm text-slate-500 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300'>
Loading your queue...
</div>
)}
{!loading && canApproveRequests && !(summary?.pendingApprovals || []).length && (
<div className='rounded-3xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-dark-700 dark:text-slate-300'>
No pending approvals right now. When new requests reach your stage, they will appear here.
</div>
)}
{!loading && !canApproveRequests && !(summary?.recentRequests || []).length && (
<div className='rounded-3xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-dark-700 dark:text-slate-300'>
You have not submitted any requests yet. Use the form above to create the first one.
</div>
)}
{canApproveRequests
? (summary?.pendingApprovals || []).map((approval) => {
const request = approval.request;
if (!request) {
return null;
}
return (
<div key={approval.id} className='rounded-3xl border border-slate-200/70 bg-slate-50/80 p-5 dark:border-dark-700 dark:bg-dark-800'>
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-3'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>
{request.request_number}
</p>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getRequestStatusClasses(request.status)}`}>
{request.status}
</span>
</div>
<div>
<h4 className='text-lg font-semibold'>{request.title}</h4>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
{request.requester?.firstName || 'Unknown requester'} {request.origin_department?.name || 'No origin'} {request.assigned_department?.name || 'No target'}
</p>
</div>
<div className='flex flex-wrap items-center gap-3 text-xs text-slate-500 dark:text-slate-400'>
<span className='rounded-full bg-slate-900/5 px-3 py-1 dark:bg-white/5'>
{getApprovalStepLabel(approval.step)}
</span>
<span className={getPriorityClasses(request.priority)}>
Priority: {request.priority}
</span>
</div>
</div>
<div className='flex flex-wrap gap-3 lg:justify-end'>
<BaseButton
href={`/requests/requests-view/?id=${request.id}`}
color='whiteDark'
label='View detail'
/>
<BaseButton
color='info'
label={busyRequestId === request.id ? 'Working...' : 'Approve'}
disabled={busyRequestId === request.id}
onClick={() => openDecisionModal(request.id, 'approved', request.request_number)}
/>
<BaseButton
color='danger'
label={busyRequestId === request.id ? 'Working...' : 'Reject'}
disabled={busyRequestId === request.id}
onClick={() => openDecisionModal(request.id, 'rejected', request.request_number)}
/>
</div>
</div>
</div>
);
})
: (summary?.recentRequests || []).map((request) => (
<div key={request.id} className='rounded-3xl border border-slate-200/70 bg-slate-50/80 p-5 dark:border-dark-700 dark:bg-dark-800'>
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-3'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>
{request.request_number}
</p>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getRequestStatusClasses(request.status)}`}>
{request.status}
</span>
</div>
<h4 className='text-lg font-semibold'>{request.title}</h4>
<p className='text-sm text-slate-500 dark:text-slate-300'>
{request.origin_department?.name || 'No origin'} {request.assigned_department?.name || 'No target'}
</p>
<p className='text-xs text-slate-400'>
Updated {request.updatedAt ? dayjs(request.updatedAt).format('MMM D, YYYY HH:mm') : 'recently'}
</p>
</div>
<div className='flex flex-wrap gap-3 lg:justify-end'>
<BaseButton href={`/requests/requests-view/?id=${request.id}`} color='whiteDark' label='View detail' />
{request.status === 'rejected' && (
<BaseButton
color='info'
icon={mdiRestart}
label={busyRequestId === request.id ? 'Restarting...' : 'Restart'}
disabled={busyRequestId === request.id}
onClick={() => restartRequest(request.id, request.request_number)}
/>
)}
</div>
</div>
</div>
))}
</div>
</CardBox>
<CardBox className='border border-white/10'>
<div className='flex items-start justify-between gap-4'>
<div>
<h3 className='text-2xl font-semibold'>Recent requests</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
Review current outcomes, jump to the request detail screen, and let administrators override final decisions when needed.
</p>
</div>
</div>
<div className='mt-6 space-y-4'>
{!(summary?.recentRequests || []).length && !loading && (
<div className='rounded-3xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-dark-700 dark:text-slate-300'>
No requests to show yet.
</div>
)}
{(summary?.recentRequests || []).map((request) => {
const pendingApproval = pendingApprovalMap.get(request.id);
const oppositeDecision = request.status === 'approved' ? 'rejected' : 'approved';
const canOverrideThisRequest = canOverrideRequests && ['approved', 'rejected'].includes(request.status);
return (
<div key={request.id} className='rounded-3xl border border-slate-200/70 p-5 dark:border-dark-700'>
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-3'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>
{request.request_number}
</p>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getRequestStatusClasses(request.status)}`}>
{request.status}
</span>
{pendingApproval && (
<span className='rounded-full bg-slate-900/5 px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-white/5 dark:text-slate-200'>
{getApprovalStepLabel(pendingApproval.step)}
</span>
)}
</div>
<h4 className='text-lg font-semibold'>{request.title}</h4>
<p className='text-sm text-slate-500 dark:text-slate-300'>
{request.requester?.firstName || 'Unknown requester'} {request.origin_department?.name || 'No origin'} {request.assigned_department?.name || 'No target'}
</p>
<p className='text-xs text-slate-400'>
Submitted {request.submitted_at ? dayjs(request.submitted_at).format('MMM D, YYYY HH:mm') : 'recently'}
</p>
</div>
<div className='flex flex-wrap gap-3 lg:justify-end'>
<BaseButton href={`/requests/requests-view/?id=${request.id}`} color='whiteDark' label='Open detail' />
{canOverrideThisRequest && (
<BaseButton
color={request.status === 'approved' ? 'danger' : 'success'}
label={request.status === 'approved' ? 'Override to reject' : 'Override to approve'}
disabled={busyRequestId === request.id}
onClick={() => openDecisionModal(request.id, oppositeDecision as 'approved' | 'rejected', request.request_number)}
/>
)}
</div>
</div>
</div>
);
})}
</div>
</CardBox>
</div>
<CardBox className='mt-6 border border-white/10'>
<div className='flex items-start justify-between gap-4'>
<div>
<h3 className='text-2xl font-semibold'>Audit trail</h3>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
Watch the most recent workflow activity. Preview and download actions from the request detail page also land here.
</p>
</div>
<BaseButton href='/audit_events/audit_events-list' color='whiteDark' icon={mdiClipboardTextClock} label='Full audit log' />
</div>
<div className='mt-6 space-y-4'>
{!(summary?.recentAudit || []).length && !loading && (
<div className='rounded-3xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-dark-700 dark:text-slate-300'>
No audit entries yet. Submit or approve a request to start building the trail.
</div>
)}
{(summary?.recentAudit || []).map((auditItem) => (
<div key={auditItem.id} className='rounded-3xl border border-slate-200/70 bg-slate-50/80 p-5 dark:border-dark-700 dark:bg-dark-800'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
<div>
<div className='flex flex-wrap items-center gap-3'>
<span className='rounded-full bg-[#0f5e47]/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-[#0f5e47] dark:text-emerald-300'>
{auditItem.action}
</span>
<span className='text-xs text-slate-400'>
{auditItem.event_at ? dayjs(auditItem.event_at).format('MMM D, YYYY HH:mm') : 'Recently'}
</span>
</div>
<p className='mt-3 font-semibold'>{auditItem.summary}</p>
{auditItem.details && (
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>{auditItem.details}</p>
)}
</div>
<div className='text-sm text-slate-500 dark:text-slate-300'>
<p>
{auditItem.actor?.firstName || 'System'}
{auditItem.actor?.lastName ? ` ${auditItem.actor.lastName}` : ''}
</p>
{(auditItem.from_department?.name || auditItem.to_department?.name) && (
<p className='mt-1 text-xs text-slate-400'>
{auditItem.from_department?.name || '—'} {auditItem.to_department?.name || '—'}
</p>
)}
</div>
</div>
</div>
))}
</div>
</CardBox>
</SectionMain>
<CardBoxModal
title={decisionState?.action === 'approved' ? 'Approve request' : 'Reject request'}
buttonColor={decisionState?.action === 'approved' ? 'info' : 'danger'}
buttonLabel={busyRequestId && decisionState ? 'Working...' : 'Confirm'}
isActive={Boolean(decisionState)}
onConfirm={submitDecision}
onCancel={closeDecisionModal}
>
<div className='space-y-4'>
<p className='text-sm text-slate-500 dark:text-slate-300'>
{decisionState?.title
? `Add an optional note for ${decisionState.title}.`
: 'Add an optional note for this decision.'}
</p>
<textarea
className='h-32 w-full rounded-2xl border border-slate-300 bg-white px-3 py-3 text-sm focus:border-emerald-500 focus:outline-none focus:ring focus:ring-emerald-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white'
value={decisionComment}
onChange={(event) => setDecisionComment(event.target.value)}
placeholder='Explain why this was approved, rejected, or overridden...'
/>
</div>
</CardBoxModal>
</>
);
};
WorkflowCenterPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='READ_REQUESTS'>
{page}
</LayoutAuthenticated>
);
};
export default WorkflowCenterPage;