Autosave: 20260404-171444
This commit is contained in:
parent
d7c816c260
commit
8ceeef1051
@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const validator = require('validator');
|
||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
@ -67,6 +68,27 @@ const SUPPORTED_RECORD_TYPES = new Set([
|
|||||||
'compliance_alerts',
|
'compliance_alerts',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const MIN_MAJOR_CONTRACT_VALUE = 1000;
|
||||||
|
|
||||||
|
const hasMeaningfulText = (value) => Boolean(String(value || '').trim());
|
||||||
|
|
||||||
|
const getRecordId = (value) => {
|
||||||
|
const normalizedValue = String(value || '').trim();
|
||||||
|
|
||||||
|
if (!normalizedValue || !validator.isUUID(normalizedValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserDisplayName = (user, fallback = '') => {
|
||||||
|
if (!user) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
const formatCurrencyValue = (value, currency) =>
|
const formatCurrencyValue = (value, currency) =>
|
||||||
new Intl.NumberFormat(currency === 'CDF' ? 'fr-CD' : 'en-US', {
|
new Intl.NumberFormat(currency === 'CDF' ? 'fr-CD' : 'en-US', {
|
||||||
@ -813,7 +835,11 @@ router.get(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const provinceRolloutMap = rolloutProjects.reduce((accumulator, project) => {
|
const provinceRolloutMap = rolloutProjects.reduce((accumulator, project) => {
|
||||||
const provinceName = project.province?.name || 'Province not assigned';
|
const provinceName = project.province?.name;
|
||||||
|
|
||||||
|
if (!provinceName) {
|
||||||
|
return accumulator;
|
||||||
|
}
|
||||||
|
|
||||||
if (!accumulator[provinceName]) {
|
if (!accumulator[provinceName]) {
|
||||||
accumulator[provinceName] = {
|
accumulator[provinceName] = {
|
||||||
@ -847,22 +873,26 @@ router.get(
|
|||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
|
|
||||||
const formattedApprovalQueue = approvalQueue
|
const formattedApprovalQueue = approvalQueue
|
||||||
.filter((approval) => SUPPORTED_RECORD_TYPES.has(approval.record_type))
|
.filter(
|
||||||
|
(approval) =>
|
||||||
|
SUPPORTED_RECORD_TYPES.has(approval.record_type)
|
||||||
|
&& hasMeaningfulText(approval.record_key)
|
||||||
|
&& hasMeaningfulText(approval.workflow?.name)
|
||||||
|
&& hasMeaningfulText(approval.step?.name)
|
||||||
|
&& Boolean(getUserDisplayName(approval.requested_by_user)),
|
||||||
|
)
|
||||||
.map((approval) => ({
|
.map((approval) => ({
|
||||||
id: approval.id,
|
id: approval.id,
|
||||||
recordType: approval.record_type,
|
recordType: approval.record_type,
|
||||||
recordKey: approval.record_key,
|
recordKey: approval.record_key,
|
||||||
status: approval.status,
|
status: approval.status,
|
||||||
requestedAt: approval.requested_at,
|
requestedAt: approval.requested_at,
|
||||||
workflowName: approval.workflow?.name || 'Workflow not configured',
|
recordId: getRecordId(approval.record_key),
|
||||||
stepName: approval.step?.name || 'Pending step setup',
|
workflowName: approval.workflow.name,
|
||||||
|
stepName: approval.step.name,
|
||||||
stepOrder: approval.step?.step_order || null,
|
stepOrder: approval.step?.step_order || null,
|
||||||
requestedBy: approval.requested_by_user
|
requestedBy: getUserDisplayName(approval.requested_by_user),
|
||||||
? `${approval.requested_by_user.firstName || ''} ${approval.requested_by_user.lastName || ''}`.trim() || approval.requested_by_user.email
|
assignedTo: getUserDisplayName(approval.assigned_to_user, 'Awaiting assignment'),
|
||||||
: 'Requester unavailable',
|
|
||||||
assignedTo: approval.assigned_to_user
|
|
||||||
? `${approval.assigned_to_user.firstName || ''} ${approval.assigned_to_user.lastName || ''}`.trim() || approval.assigned_to_user.email
|
|
||||||
: 'Not assigned',
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const formattedProcurementQueue = procurementQueue.map((requisition) => ({
|
const formattedProcurementQueue = procurementQueue.map((requisition) => ({
|
||||||
@ -895,7 +925,12 @@ router.get(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const formattedTopContracts = topContracts
|
const formattedTopContracts = topContracts
|
||||||
.filter((contract) => contract.vendor?.name || contract.project?.name)
|
.filter(
|
||||||
|
(contract) =>
|
||||||
|
(contract.vendor?.name || contract.project?.name)
|
||||||
|
&& hasMeaningfulText(contract.contract_number || contract.title)
|
||||||
|
&& toNumber(contract.contract_value) >= MIN_MAJOR_CONTRACT_VALUE,
|
||||||
|
)
|
||||||
.map((contract) => ({
|
.map((contract) => ({
|
||||||
id: contract.id,
|
id: contract.id,
|
||||||
contractNumber: contract.contract_number,
|
contractNumber: contract.contract_number,
|
||||||
@ -918,11 +953,10 @@ router.get(
|
|||||||
details: alert.details,
|
details: alert.details,
|
||||||
recordType: alert.record_type,
|
recordType: alert.record_type,
|
||||||
recordKey: alert.record_key,
|
recordKey: alert.record_key,
|
||||||
|
recordId: getRecordId(alert.record_key),
|
||||||
dueAt: alert.due_at,
|
dueAt: alert.due_at,
|
||||||
status: alert.status,
|
status: alert.status,
|
||||||
assignedTo: alert.assigned_to_user
|
assignedTo: getUserDisplayName(alert.assigned_to_user, 'Unassigned'),
|
||||||
? `${alert.assigned_to_user.firstName || ''} ${alert.assigned_to_user.lastName || ''}`.trim() || alert.assigned_to_user.email
|
|
||||||
: 'Not assigned',
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const formattedNotifications = recentNotifications
|
const formattedNotifications = recentNotifications
|
||||||
@ -936,6 +970,7 @@ router.get(
|
|||||||
sentAt: notification.sent_at,
|
sentAt: notification.sent_at,
|
||||||
recordType: notification.record_type,
|
recordType: notification.record_type,
|
||||||
recordKey: notification.record_key,
|
recordKey: notification.record_key,
|
||||||
|
recordId: getRecordId(notification.record_key),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default function AsideMenu({
|
|||||||
<>
|
<>
|
||||||
<AsideMenuLayer
|
<AsideMenuLayer
|
||||||
menu={props.menu}
|
menu={props.menu}
|
||||||
className={`${isAsideMobileExpanded ? 'left-0' : '-left-60 lg:left-0'} ${
|
className={`${isAsideMobileExpanded ? 'left-0' : '-left-72 lg:left-0'} ${
|
||||||
!isAsideLgActive ? 'lg:hidden xl:flex' : ''
|
!isAsideLgActive ? 'lg:hidden xl:flex' : ''
|
||||||
}`}
|
}`}
|
||||||
onAsideLgCloseClick={props.onAsideLgClose}
|
onAsideLgCloseClick={props.onAsideLgClose}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { mdiMinus, mdiPlus } from '@mdi/js'
|
import { mdiChevronDown, mdiChevronUp } from '@mdi/js'
|
||||||
import BaseIcon from './BaseIcon'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import BaseIcon from './BaseIcon'
|
||||||
import { getButtonColor } from '../colors'
|
import { getButtonColor } from '../colors'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppSelector } from '../stores/hooks'
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: MenuAsideItem
|
item: MenuAsideItem
|
||||||
@ -34,10 +34,8 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
|||||||
const asideMenuItemStyle = useAppSelector((state) => state.style.asideMenuItemStyle)
|
const asideMenuItemStyle = useAppSelector((state) => state.style.asideMenuItemStyle)
|
||||||
const asideMenuDropdownStyle = useAppSelector((state) => state.style.asideMenuDropdownStyle)
|
const asideMenuDropdownStyle = useAppSelector((state) => state.style.asideMenuDropdownStyle)
|
||||||
const asideMenuItemActiveStyle = useAppSelector((state) => state.style.asideMenuItemActiveStyle)
|
const asideMenuItemActiveStyle = useAppSelector((state) => state.style.asideMenuItemActiveStyle)
|
||||||
const borders = useAppSelector((state) => state.style.borders);
|
const borders = useAppSelector((state) => state.style.borders)
|
||||||
const activeLinkColor = useAppSelector(
|
const activeLinkColor = useAppSelector((state) => state.style.activeLinkColor)
|
||||||
(state) => state.style.activeLinkColor,
|
|
||||||
);
|
|
||||||
const activeClassAddon = !item.color && isLinkActive ? asideMenuItemActiveStyle : ''
|
const activeClassAddon = !item.color && isLinkActive ? asideMenuItemActiveStyle : ''
|
||||||
const isGroupTrigger = Boolean(item.menu && !item.href && !isDropdownList)
|
const isGroupTrigger = Boolean(item.menu && !item.href && !isDropdownList)
|
||||||
|
|
||||||
@ -62,56 +60,51 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
|||||||
const asideMenuItemInnerContents = (
|
const asideMenuItemInnerContents = (
|
||||||
<>
|
<>
|
||||||
{item.icon && (
|
{item.icon && (
|
||||||
<BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" />
|
<span className='flex h-9 w-9 flex-none items-center justify-center rounded-xl'>
|
||||||
|
<BaseIcon path={item.icon} className={`${activeClassAddon}`} size='18' />
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`grow text-ellipsis line-clamp-1 ${
|
className={`min-w-0 grow whitespace-normal break-words leading-5 line-clamp-2 ${item.menu ? '' : 'pr-3'} ${isGroupTrigger ? 'text-[0.93rem] tracking-[0.01em]' : ''} ${activeClassAddon}`}
|
||||||
item.menu ? '' : 'pr-12'
|
|
||||||
} ${isGroupTrigger ? 'text-[0.95rem] tracking-[0.01em]' : ''} ${activeClassAddon}`}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{item.menu && (
|
{item.menu && (
|
||||||
<BaseIcon
|
<BaseIcon
|
||||||
path={isDropdownActive ? mdiMinus : mdiPlus}
|
path={isDropdownActive ? mdiChevronUp : mdiChevronDown}
|
||||||
className={`flex-none ${activeClassAddon}`}
|
className={`flex-none ${activeClassAddon}`}
|
||||||
w="w-12"
|
size='18'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
const componentClass = [
|
const componentClass = [
|
||||||
'group flex items-center gap-1 rounded-xl border border-transparent px-3 transition-all duration-150',
|
'group flex w-full items-start gap-3 rounded-xl px-3 text-left transition-all duration-150',
|
||||||
isDropdownList ? 'py-2 text-sm' : 'py-2.5',
|
isDropdownList ? 'py-2 text-sm' : 'py-2.5',
|
||||||
item.color
|
item.color ? getButtonColor(item.color, false, true) : `${asideMenuItemStyle}`,
|
||||||
? getButtonColor(item.color, false, true)
|
|
||||||
: `${asideMenuItemStyle}`,
|
|
||||||
isGroupTrigger ? 'font-semibold' : '',
|
isGroupTrigger ? 'font-semibold' : '',
|
||||||
isLinkActive
|
isLinkActive ? `${activeLinkColor} shadow-sm` : '',
|
||||||
? `text-slate-950 ${activeLinkColor} shadow-sm ring-1 ring-inset ring-slate-200/80 dark:text-white dark:bg-slate-900 dark:ring-slate-800`
|
].join(' ')
|
||||||
: 'hover:border-slate-200/80 dark:hover:border-slate-800/80',
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={isDropdownList ? 'px-3 py-1' : 'px-3 py-1.5'}>
|
<li className={isDropdownList ? 'px-2 py-1' : 'px-2 py-1.5'}>
|
||||||
{item.withDevider && <hr className={`${borders} mb-3`} />}
|
{item.withDevider && <hr className={`${borders} mb-3`} />}
|
||||||
{item.href && (
|
{item.href ? (
|
||||||
<Link href={item.href} target={item.target} className={componentClass}>
|
<Link href={item.href} target={item.target} className={componentClass}>
|
||||||
{asideMenuItemInnerContents}
|
{asideMenuItemInnerContents}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
) : (
|
||||||
{!item.href && (
|
<button type='button' className={componentClass} onClick={() => setIsDropdownActive(!isDropdownActive)}>
|
||||||
<div className={componentClass} onClick={() => setIsDropdownActive(!isDropdownActive)}>
|
|
||||||
{asideMenuItemInnerContents}
|
{asideMenuItemInnerContents}
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
{item.menu && (
|
{item.menu && (
|
||||||
<AsideMenuList
|
<AsideMenuList
|
||||||
menu={item.menu}
|
menu={item.menu}
|
||||||
className={`${asideMenuDropdownStyle} ${
|
className={`${asideMenuDropdownStyle} ${
|
||||||
isDropdownActive
|
isDropdownActive
|
||||||
? 'mt-2 ml-3 block rounded-xl border border-slate-200/70 pl-2 dark:border-slate-800 dark:bg-slate-900/40'
|
? 'mt-2 ml-3 block rounded-xl p-2'
|
||||||
: 'hidden'
|
: 'hidden'
|
||||||
}`}
|
}`}
|
||||||
isDropdownList
|
isDropdownList
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { mdiLogout, mdiClose } from '@mdi/js'
|
import Link from 'next/link'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { mdiArrowTopRight, mdiClose, mdiOfficeBuildingOutline } from '@mdi/js'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
import { useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import { getWorkspaceConfig, getWorkspaceRoute } from '../helpers/workspace'
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuAsideItem[]
|
menu: MenuAsideItem[]
|
||||||
@ -15,75 +14,102 @@ type Props = {
|
|||||||
onAsideLgCloseClick: () => void
|
onAsideLgCloseClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrganizationOption = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
|
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
const corners = useAppSelector((state) => state.style.corners)
|
||||||
const asideStyle = useAppSelector((state) => state.style.asideStyle)
|
const asideStyle = useAppSelector((state) => state.style.asideStyle)
|
||||||
const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle)
|
const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle)
|
||||||
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth)
|
||||||
|
|
||||||
|
const [organizations, setOrganizations] = React.useState<OrganizationOption[]>([])
|
||||||
|
|
||||||
|
const roleName = currentUser?.app_role?.name || ''
|
||||||
|
const workspaceConfig = getWorkspaceConfig(roleName)
|
||||||
|
const workspaceHref = getWorkspaceRoute(roleName)
|
||||||
|
const organizationId = currentUser?.organizations?.id
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
|
||||||
|
const fetchOrganizations = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/org-for-auth')
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setOrganizations(Array.isArray(response.data) ? response.data : [])
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load organizations for sidebar', error?.response || error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchOrganizations()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
props.onAsideLgCloseClick()
|
props.onAsideLgCloseClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const organizationName = React.useMemo(() => {
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const matchedOrganization = organizations.find((item) => item.id === organizationId)?.name
|
||||||
const organizationsId = currentUser?.organizations?.id;
|
|
||||||
const [organizations, setOrganizations] = React.useState(null);
|
|
||||||
|
|
||||||
const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => {
|
if (!matchedOrganization) {
|
||||||
try {
|
return 'Organization workspace'
|
||||||
const response = await axios.get('/org-for-auth');
|
|
||||||
setOrganizations(response.data);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error.response);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
dispatch(fetchOrganizations());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
let organizationName = organizations?.find(item => item.id === organizationsId)?.name;
|
|
||||||
if(organizationName?.length > 25){
|
|
||||||
organizationName = organizationName?.substring(0, 25) + '...';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return matchedOrganization.length > 26 ? `${matchedOrganization.substring(0, 26)}...` : matchedOrganization
|
||||||
|
}, [organizationId, organizations])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
id='asideMenu'
|
id='asideMenu'
|
||||||
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
|
className={`${className} fixed top-0 z-40 flex h-screen w-72 overflow-hidden transition-position lg:py-2 lg:pl-2`}
|
||||||
>
|
>
|
||||||
<div
|
<div className={`flex flex-1 flex-col overflow-hidden border ${asideStyle} ${corners}`}>
|
||||||
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
|
<div className={`border-b px-4 py-4 ${asideBrandStyle}`}>
|
||||||
>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<div
|
<div className='min-w-0 flex-1'>
|
||||||
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
<p className='text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500 dark:text-slate-400'>FDSU ERP</p>
|
||||||
>
|
<h2 className='mt-2 line-clamp-2 text-lg font-semibold leading-6 text-slate-900 dark:text-white'>
|
||||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
{workspaceConfig.sidebarLabel}
|
||||||
|
</h2>
|
||||||
<b className="font-black">FDSU ERP</b>
|
<div className='mt-3 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
<BaseIcon path={mdiOfficeBuildingOutline} size={14} />
|
||||||
|
<span className='line-clamp-2 leading-5'>{organizationName}</span>
|
||||||
{organizationName && <p>{organizationName}</p>}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
className="hidden lg:inline-block xl:hidden p-3"
|
<button className='hidden rounded-lg p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 lg:inline-flex xl:hidden dark:hover:bg-slate-900 dark:hover:text-white' onClick={handleAsideLgCloseClick}>
|
||||||
onClick={handleAsideLgCloseClick}
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiClose} />
|
<BaseIcon path={mdiClose} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className={`flex-1 overflow-y-auto overflow-x-hidden ${
|
<div className='mt-4 border-t border-slate-200 pt-3 dark:border-slate-800'>
|
||||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
<p className='truncate text-sm font-semibold text-slate-900 dark:text-white'>
|
||||||
}`}
|
{currentUser?.firstName || currentUser?.email || 'Authenticated user'}
|
||||||
>
|
</p>
|
||||||
|
<div className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
<span>{roleName || 'Workspace access'}</span>
|
||||||
|
<span aria-hidden='true'>•</span>
|
||||||
|
<Link href={workspaceHref} className='inline-flex items-center gap-1 font-medium text-blue-700 transition hover:text-slate-950 dark:text-sky-400 dark:hover:text-white'>
|
||||||
|
Open workspace home
|
||||||
|
<BaseIcon path={mdiArrowTopRight} size={14} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 ${darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle}`}>
|
||||||
<AsideMenuList menu={menu} />
|
<AsideMenuList menu={menu} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply pt-14 xl:pl-60 h-full;
|
@apply pt-14 xl:pl-72 h-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { ReactNode, useEffect, useState } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken'
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiBackburger, mdiForwardburger, mdiMenu } from '@mdi/js'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
import menuNavBar from '../menuNavBar'
|
import menuNavBar from '../menuNavBar'
|
||||||
import BaseIcon from '../components/BaseIcon'
|
import BaseIcon from '../components/BaseIcon'
|
||||||
@ -8,70 +9,61 @@ import NavBar from '../components/NavBar'
|
|||||||
import NavBarItemPlain from '../components/NavBarItemPlain'
|
import NavBarItemPlain from '../components/NavBarItemPlain'
|
||||||
import AsideMenu from '../components/AsideMenu'
|
import AsideMenu from '../components/AsideMenu'
|
||||||
import FooterBar from '../components/FooterBar'
|
import FooterBar from '../components/FooterBar'
|
||||||
|
import Search from '../components/Search'
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Search from '../components/Search';
|
import { findMe, logoutUser } from '../stores/authSlice'
|
||||||
import { useRouter } from 'next/router'
|
import { hasPermission } from '../helpers/userPermissions'
|
||||||
import {findMe, logoutUser} from "../stores/authSlice";
|
import { getLoginRoute } from '../helpers/workspace'
|
||||||
|
|
||||||
import {hasPermission} from "../helpers/userPermissions";
|
|
||||||
import { getLoginRoute } from "../helpers/workspace";
|
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|
||||||
permission?: string
|
permission?: string
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LayoutAuthenticated({
|
export default function LayoutAuthenticated({ children, permission }: Props) {
|
||||||
children,
|
|
||||||
|
|
||||||
permission
|
|
||||||
|
|
||||||
}: Props) {
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { token, currentUser } = useAppSelector((state) => state.auth)
|
const { token, currentUser } = useAppSelector((state) => state.auth)
|
||||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
const bgColor = useAppSelector((state) => state.style.bgLayoutColor)
|
||||||
let localToken
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
// Perform localStorage action
|
|
||||||
localToken = localStorage.getItem('token')
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTokenValid = () => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) return;
|
|
||||||
const date = new Date().getTime() / 1000;
|
|
||||||
const data = jwt.decode(token);
|
|
||||||
if (!data) return;
|
|
||||||
return date < data.exp;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTokenValid()) {
|
|
||||||
dispatch(logoutUser());
|
|
||||||
router.replace(getLoginRoute(router.asPath));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(findMe());
|
|
||||||
}, [dispatch, localToken, router, token]);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!permission || !currentUser) return;
|
|
||||||
|
|
||||||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
|
||||||
}, [currentUser, permission]);
|
|
||||||
|
|
||||||
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
|
|
||||||
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
||||||
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
||||||
|
|
||||||
|
let localToken
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localToken = localStorage.getItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTokenValid = () => {
|
||||||
|
const storedToken = localStorage.getItem('token')
|
||||||
|
|
||||||
|
if (!storedToken) return
|
||||||
|
|
||||||
|
const date = new Date().getTime() / 1000
|
||||||
|
const data: any = jwt.decode(storedToken)
|
||||||
|
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
return date < data.exp
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTokenValid()) {
|
||||||
|
dispatch(logoutUser())
|
||||||
|
router.replace(getLoginRoute(router.asPath))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(findMe())
|
||||||
|
}, [dispatch, localToken, router, token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!permission || !currentUser) return
|
||||||
|
|
||||||
|
if (!hasPermission(currentUser, permission)) router.push('/error')
|
||||||
|
}, [currentUser, permission, router])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChangeStart = () => {
|
const handleRouteChangeStart = () => {
|
||||||
setIsAsideMobileExpanded(false)
|
setIsAsideMobileExpanded(false)
|
||||||
@ -80,51 +72,51 @@ export default function LayoutAuthenticated({
|
|||||||
|
|
||||||
router.events.on('routeChangeStart', handleRouteChangeStart)
|
router.events.on('routeChangeStart', handleRouteChangeStart)
|
||||||
|
|
||||||
// If the component is unmounted, unsubscribe
|
|
||||||
// from the event with the `off` method:
|
|
||||||
return () => {
|
return () => {
|
||||||
router.events.off('routeChangeStart', handleRouteChangeStart)
|
router.events.off('routeChangeStart', handleRouteChangeStart)
|
||||||
}
|
}
|
||||||
}, [router.events, dispatch])
|
}, [router.events])
|
||||||
|
|
||||||
|
const layoutAsidePadding = 'xl:pl-72'
|
||||||
const layoutAsidePadding = 'xl:pl-60'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||||
<div
|
<div
|
||||||
className={`${layoutAsidePadding} ${
|
className={`${layoutAsidePadding} ${
|
||||||
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
isAsideMobileExpanded ? 'ml-72 lg:ml-0' : ''
|
||||||
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
} min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||||
>
|
>
|
||||||
<NavBar
|
<NavBar
|
||||||
menu={menuNavBar}
|
menu={menuNavBar}
|
||||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
|
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-72 lg:ml-0' : ''}`}
|
||||||
>
|
>
|
||||||
<NavBarItemPlain
|
<NavBarItemPlain
|
||||||
display="flex lg:hidden"
|
display='flex lg:hidden'
|
||||||
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
||||||
>
|
>
|
||||||
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size='24' />
|
||||||
</NavBarItemPlain>
|
</NavBarItemPlain>
|
||||||
<NavBarItemPlain
|
<NavBarItemPlain display='hidden lg:flex xl:hidden' onClick={() => setIsAsideLgActive(true)}>
|
||||||
display="hidden lg:flex xl:hidden"
|
<BaseIcon path={mdiMenu} size='24' />
|
||||||
onClick={() => setIsAsideLgActive(true)}
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiMenu} size="24" />
|
|
||||||
</NavBarItemPlain>
|
</NavBarItemPlain>
|
||||||
<NavBarItemPlain useMargin>
|
<NavBarItemPlain useMargin>
|
||||||
<Search />
|
<Search />
|
||||||
</NavBarItemPlain>
|
</NavBarItemPlain>
|
||||||
</NavBar>
|
</NavBar>
|
||||||
|
|
||||||
<AsideMenu
|
<AsideMenu
|
||||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||||
isAsideLgActive={isAsideLgActive}
|
isAsideLgActive={isAsideLgActive}
|
||||||
menu={menuAside}
|
menu={menuAside}
|
||||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className='pt-14'>
|
||||||
|
<div className='min-h-[calc(100vh-3.5rem)]'>
|
||||||
{children}
|
{children}
|
||||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
</div>
|
||||||
|
<FooterBar />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -10,13 +10,6 @@ const optionalIcon = (name: string, fallback: string = icon.mdiTable): string =>
|
|||||||
|
|
||||||
const superAdminWorkspaceRoles = [WORKSPACE_ROLES.superAdmin]
|
const superAdminWorkspaceRoles = [WORKSPACE_ROLES.superAdmin]
|
||||||
const adminWorkspaceRoles = [WORKSPACE_ROLES.administrator]
|
const adminWorkspaceRoles = [WORKSPACE_ROLES.administrator]
|
||||||
const sharedEntityRoles = [
|
|
||||||
WORKSPACE_ROLES.directorGeneral,
|
|
||||||
WORKSPACE_ROLES.financeDirector,
|
|
||||||
WORKSPACE_ROLES.procurementLead,
|
|
||||||
WORKSPACE_ROLES.complianceAuditLead,
|
|
||||||
WORKSPACE_ROLES.projectDeliveryLead,
|
|
||||||
]
|
|
||||||
|
|
||||||
const superAdminGroupedNavigation: MenuAsideItem[] = [
|
const superAdminGroupedNavigation: MenuAsideItem[] = [
|
||||||
{
|
{
|
||||||
@ -58,7 +51,7 @@ const superAdminGroupedNavigation: MenuAsideItem[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Platform Workflow',
|
label: 'Workflow & Oversight',
|
||||||
icon: icon.mdiSitemap,
|
icon: icon.mdiSitemap,
|
||||||
roles: superAdminWorkspaceRoles,
|
roles: superAdminWorkspaceRoles,
|
||||||
menu: [
|
menu: [
|
||||||
@ -92,10 +85,41 @@ const superAdminGroupedNavigation: MenuAsideItem[] = [
|
|||||||
|
|
||||||
const adminGroupedNavigation: MenuAsideItem[] = [
|
const adminGroupedNavigation: MenuAsideItem[] = [
|
||||||
{
|
{
|
||||||
label: 'Workflow Readiness',
|
label: 'Organization Setup',
|
||||||
icon: icon.mdiSitemap,
|
icon: icon.mdiAccountGroup,
|
||||||
roles: adminWorkspaceRoles,
|
roles: adminWorkspaceRoles,
|
||||||
withDevider: true,
|
withDevider: true,
|
||||||
|
menu: [
|
||||||
|
{
|
||||||
|
href: '/users/users-list',
|
||||||
|
label: 'Users',
|
||||||
|
icon: icon.mdiAccountGroup,
|
||||||
|
permissions: 'READ_USERS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/provinces/provinces-list',
|
||||||
|
label: 'Provinces',
|
||||||
|
icon: optionalIcon('mdiMapMarker'),
|
||||||
|
permissions: 'READ_PROVINCES',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/departments/departments-list',
|
||||||
|
label: 'Departments',
|
||||||
|
icon: optionalIcon('mdiOfficeBuilding'),
|
||||||
|
permissions: 'READ_DEPARTMENTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/documents/documents-list',
|
||||||
|
label: 'Documents',
|
||||||
|
icon: optionalIcon('mdiFolderFile'),
|
||||||
|
permissions: 'READ_DOCUMENTS',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Workflow Control',
|
||||||
|
icon: icon.mdiSitemap,
|
||||||
|
roles: adminWorkspaceRoles,
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
href: '/approval_workflows/approval_workflows-list',
|
href: '/approval_workflows/approval_workflows-list',
|
||||||
@ -124,7 +148,7 @@ const adminGroupedNavigation: MenuAsideItem[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Operational Notices',
|
label: 'Assurance & Notices',
|
||||||
icon: icon.mdiBellOutline,
|
icon: icon.mdiBellOutline,
|
||||||
roles: adminWorkspaceRoles,
|
roles: adminWorkspaceRoles,
|
||||||
menu: [
|
menu: [
|
||||||
@ -134,293 +158,17 @@ const adminGroupedNavigation: MenuAsideItem[] = [
|
|||||||
icon: optionalIcon('mdiBell'),
|
icon: optionalIcon('mdiBell'),
|
||||||
permissions: 'READ_NOTIFICATIONS',
|
permissions: 'READ_NOTIFICATIONS',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/audit_logs/audit_logs-list',
|
|
||||||
label: 'Audit logs',
|
|
||||||
icon: optionalIcon('mdiClipboardTextClock'),
|
|
||||||
permissions: 'READ_AUDIT_LOGS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/documents/documents-list',
|
|
||||||
label: 'Documents',
|
|
||||||
icon: optionalIcon('mdiFolderFile'),
|
|
||||||
permissions: 'READ_DOCUMENTS',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/compliance_alerts/compliance_alerts-list',
|
href: '/compliance_alerts/compliance_alerts-list',
|
||||||
label: 'Compliance alerts',
|
label: 'Compliance alerts',
|
||||||
icon: optionalIcon('mdiShieldAlert'),
|
icon: optionalIcon('mdiShieldAlert'),
|
||||||
permissions: 'READ_COMPLIANCE_ALERTS',
|
permissions: 'READ_COMPLIANCE_ALERTS',
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Administration',
|
href: '/audit_logs/audit_logs-list',
|
||||||
icon: icon.mdiAccountGroup,
|
label: 'Audit logs',
|
||||||
roles: adminWorkspaceRoles,
|
icon: optionalIcon('mdiClipboardTextClock'),
|
||||||
menu: [
|
permissions: 'READ_AUDIT_LOGS',
|
||||||
{
|
|
||||||
href: '/users/users-list',
|
|
||||||
label: 'Users',
|
|
||||||
icon: icon.mdiAccountGroup,
|
|
||||||
permissions: 'READ_USERS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/provinces/provinces-list',
|
|
||||||
label: 'Provinces',
|
|
||||||
icon: optionalIcon('mdiMapMarker'),
|
|
||||||
permissions: 'READ_PROVINCES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/departments/departments-list',
|
|
||||||
label: 'Departments',
|
|
||||||
icon: optionalIcon('mdiOfficeBuilding'),
|
|
||||||
permissions: 'READ_DEPARTMENTS',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Budget & Planning',
|
|
||||||
icon: optionalIcon('mdiWalletOutline'),
|
|
||||||
roles: adminWorkspaceRoles,
|
|
||||||
menu: [
|
|
||||||
{
|
|
||||||
href: '/fiscal_years/fiscal_years-list',
|
|
||||||
label: 'Fiscal years',
|
|
||||||
icon: optionalIcon('mdiCalendarRange'),
|
|
||||||
permissions: 'READ_FISCAL_YEARS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/funding_sources/funding_sources-list',
|
|
||||||
label: 'Funding sources',
|
|
||||||
icon: optionalIcon('mdiCashMultiple'),
|
|
||||||
permissions: 'READ_FUNDING_SOURCES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/budget_programs/budget_programs-list',
|
|
||||||
label: 'Budget programs',
|
|
||||||
icon: optionalIcon('mdiChartDonut'),
|
|
||||||
permissions: 'READ_BUDGET_PROGRAMS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/budget_lines/budget_lines-list',
|
|
||||||
label: 'Budget lines',
|
|
||||||
icon: optionalIcon('mdiFormatListBulleted'),
|
|
||||||
permissions: 'READ_BUDGET_LINES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/allocations/allocations-list',
|
|
||||||
label: 'Allocations',
|
|
||||||
icon: optionalIcon('mdiDatabaseArrowRight'),
|
|
||||||
permissions: 'READ_ALLOCATIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/budget_reallocations/budget_reallocations-list',
|
|
||||||
label: 'Reallocations',
|
|
||||||
icon: optionalIcon('mdiSwapHorizontal'),
|
|
||||||
permissions: 'READ_BUDGET_REALLOCATIONS',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Procurement',
|
|
||||||
icon: icon.mdiGavel,
|
|
||||||
roles: adminWorkspaceRoles,
|
|
||||||
menu: [
|
|
||||||
{
|
|
||||||
href: '/procurement_plans/procurement_plans-list',
|
|
||||||
label: 'Plans',
|
|
||||||
icon: optionalIcon('mdiClipboardList'),
|
|
||||||
permissions: 'READ_PROCUREMENT_PLANS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/requisitions/requisitions-list',
|
|
||||||
label: 'Requisitions',
|
|
||||||
icon: optionalIcon('mdiFileDocumentEdit'),
|
|
||||||
permissions: 'READ_REQUISITIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/tenders/tenders-list',
|
|
||||||
label: 'Tenders',
|
|
||||||
icon: optionalIcon('mdiGavel'),
|
|
||||||
permissions: 'READ_TENDERS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/vendors/vendors-list',
|
|
||||||
label: 'Vendors',
|
|
||||||
icon: optionalIcon('mdiTruckFast'),
|
|
||||||
permissions: 'READ_VENDORS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/vendor_compliance_documents/vendor_compliance_documents-list',
|
|
||||||
label: 'Vendor compliance',
|
|
||||||
icon: optionalIcon('mdiFileCertificate'),
|
|
||||||
permissions: 'READ_VENDOR_COMPLIANCE_DOCUMENTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/bids/bids-list',
|
|
||||||
label: 'Bids',
|
|
||||||
icon: optionalIcon('mdiFileSign'),
|
|
||||||
permissions: 'READ_BIDS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/bid_evaluations/bid_evaluations-list',
|
|
||||||
label: 'Bid evaluations',
|
|
||||||
icon: optionalIcon('mdiClipboardCheck'),
|
|
||||||
permissions: 'READ_BID_EVALUATIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/awards/awards-list',
|
|
||||||
label: 'Awards',
|
|
||||||
icon: optionalIcon('mdiTrophyAward'),
|
|
||||||
permissions: 'READ_AWARDS',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Delivery & Contracts',
|
|
||||||
icon: icon.mdiChartTimelineVariant,
|
|
||||||
roles: adminWorkspaceRoles,
|
|
||||||
menu: [
|
|
||||||
{
|
|
||||||
href: '/programs/programs-list',
|
|
||||||
label: 'Programs',
|
|
||||||
icon: optionalIcon('mdiViewGridOutline'),
|
|
||||||
permissions: 'READ_PROGRAMS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/projects/projects-list',
|
|
||||||
label: 'Projects',
|
|
||||||
icon: optionalIcon('mdiBriefcaseCheck'),
|
|
||||||
permissions: 'READ_PROJECTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/project_milestones/project_milestones-list',
|
|
||||||
label: 'Milestones',
|
|
||||||
icon: optionalIcon('mdiTimelineCheck'),
|
|
||||||
permissions: 'READ_PROJECT_MILESTONES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/risks/risks-list',
|
|
||||||
label: 'Risks',
|
|
||||||
icon: optionalIcon('mdiAlertOctagon'),
|
|
||||||
permissions: 'READ_RISKS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/issues/issues-list',
|
|
||||||
label: 'Issues',
|
|
||||||
icon: optionalIcon('mdiBug'),
|
|
||||||
permissions: 'READ_ISSUES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/field_verifications/field_verifications-list',
|
|
||||||
label: 'Field checks',
|
|
||||||
icon: optionalIcon('mdiMapMarkerCheck'),
|
|
||||||
permissions: 'READ_FIELD_VERIFICATIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/contracts/contracts-list',
|
|
||||||
label: 'Contracts',
|
|
||||||
icon: optionalIcon('mdiFileDocumentOutline'),
|
|
||||||
permissions: 'READ_CONTRACTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/contract_amendments/contract_amendments-list',
|
|
||||||
label: 'Amendments',
|
|
||||||
icon: optionalIcon('mdiFileReplaceOutline'),
|
|
||||||
permissions: 'READ_CONTRACT_AMENDMENTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/contract_milestones/contract_milestones-list',
|
|
||||||
label: 'Contract milestones',
|
|
||||||
icon: optionalIcon('mdiTimelineCheck'),
|
|
||||||
permissions: 'READ_CONTRACT_MILESTONES',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Grants & Beneficiaries',
|
|
||||||
icon: optionalIcon('mdiHandCoin'),
|
|
||||||
roles: adminWorkspaceRoles,
|
|
||||||
menu: [
|
|
||||||
{
|
|
||||||
href: '/grants/grants-list',
|
|
||||||
label: 'Grants',
|
|
||||||
icon: optionalIcon('mdiHandCoin'),
|
|
||||||
permissions: 'READ_GRANTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/beneficiaries/beneficiaries-list',
|
|
||||||
label: 'Beneficiaries',
|
|
||||||
icon: optionalIcon('mdiAccountGroup'),
|
|
||||||
permissions: 'READ_BENEFICIARIES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/grant_applications/grant_applications-list',
|
|
||||||
label: 'Applications',
|
|
||||||
icon: optionalIcon('mdiFileDocumentMultiple'),
|
|
||||||
permissions: 'READ_GRANT_APPLICATIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/grant_evaluations/grant_evaluations-list',
|
|
||||||
label: 'Evaluations',
|
|
||||||
icon: optionalIcon('mdiStarCheck'),
|
|
||||||
permissions: 'READ_GRANT_EVALUATIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/grant_tranches/grant_tranches-list',
|
|
||||||
label: 'Tranches',
|
|
||||||
icon: optionalIcon('mdiCashCheck'),
|
|
||||||
permissions: 'READ_GRANT_TRANCHES',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Finance & Payments',
|
|
||||||
icon: optionalIcon('mdiCashCheck'),
|
|
||||||
roles: adminWorkspaceRoles,
|
|
||||||
menu: [
|
|
||||||
{
|
|
||||||
href: '/expense_categories/expense_categories-list',
|
|
||||||
label: 'Expense categories',
|
|
||||||
icon: optionalIcon('mdiTagMultiple'),
|
|
||||||
permissions: 'READ_EXPENSE_CATEGORIES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/invoices/invoices-list',
|
|
||||||
label: 'Invoices',
|
|
||||||
icon: optionalIcon('mdiReceiptText'),
|
|
||||||
permissions: 'READ_INVOICES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/payment_requests/payment_requests-list',
|
|
||||||
label: 'Payment requests',
|
|
||||||
icon: optionalIcon('mdiCashFast'),
|
|
||||||
permissions: 'READ_PAYMENT_REQUESTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/payment_batches/payment_batches-list',
|
|
||||||
label: 'Payment batches',
|
|
||||||
icon: optionalIcon('mdiPackageVariantClosed'),
|
|
||||||
permissions: 'READ_PAYMENT_BATCHES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/payments/payments-list',
|
|
||||||
label: 'Payments',
|
|
||||||
icon: optionalIcon('mdiBankTransfer'),
|
|
||||||
permissions: 'READ_PAYMENTS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/obligations/obligations-list',
|
|
||||||
label: 'Obligations',
|
|
||||||
icon: optionalIcon('mdiBookArrowDown'),
|
|
||||||
permissions: 'READ_OBLIGATIONS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/ledger_entries/ledger_entries-list',
|
|
||||||
label: 'Ledger entries',
|
|
||||||
icon: optionalIcon('mdiBookOpenPageVariant'),
|
|
||||||
permissions: 'READ_LEDGER_ENTRIES',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -428,7 +176,6 @@ const adminGroupedNavigation: MenuAsideItem[] = [
|
|||||||
|
|
||||||
const sharedEntityNavigation: MenuAsideItem[] = []
|
const sharedEntityNavigation: MenuAsideItem[] = []
|
||||||
|
|
||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
const menuAside: MenuAsideItem[] = [
|
||||||
{
|
{
|
||||||
href: '/executive-summary',
|
href: '/executive-summary',
|
||||||
@ -447,7 +194,7 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Admin Widgets',
|
label: 'Role Widgets',
|
||||||
labelByRole: {
|
labelByRole: {
|
||||||
[WORKSPACE_ROLES.superAdmin]: 'Platform Widgets',
|
[WORKSPACE_ROLES.superAdmin]: 'Platform Widgets',
|
||||||
[WORKSPACE_ROLES.administrator]: 'Operations Widgets',
|
[WORKSPACE_ROLES.administrator]: 'Operations Widgets',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -64,6 +64,7 @@ interface ApprovalItem {
|
|||||||
id: string;
|
id: string;
|
||||||
recordType: string;
|
recordType: string;
|
||||||
recordKey: string;
|
recordKey: string;
|
||||||
|
recordId?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
requestedAt: string;
|
requestedAt: string;
|
||||||
workflowName: string;
|
workflowName: string;
|
||||||
@ -106,6 +107,7 @@ interface RiskItem {
|
|||||||
details: string;
|
details: string;
|
||||||
recordType: string;
|
recordType: string;
|
||||||
recordKey: string;
|
recordKey: string;
|
||||||
|
recordId?: string | null;
|
||||||
dueAt: string;
|
dueAt: string;
|
||||||
status: string;
|
status: string;
|
||||||
assignedTo: string;
|
assignedTo: string;
|
||||||
@ -127,6 +129,7 @@ interface NotificationItem {
|
|||||||
sentAt: string;
|
sentAt: string;
|
||||||
recordType: string;
|
recordType: string;
|
||||||
recordKey: string;
|
recordKey: string;
|
||||||
|
recordId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceFocusCard {
|
interface WorkspaceFocusCard {
|
||||||
@ -232,6 +235,47 @@ const formatCurrency = (value: number, currency: 'USD' | 'CDF') =>
|
|||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value || 0);
|
}).format(value || 0);
|
||||||
|
|
||||||
|
const formatAbsoluteCurrency = (value: number, currency: 'USD' | 'CDF') => formatCurrency(Math.abs(value || 0), currency);
|
||||||
|
|
||||||
|
const getBudgetPositionMeta = (value: number, currency: 'USD' | 'CDF') => {
|
||||||
|
if (value < 0) {
|
||||||
|
return {
|
||||||
|
currency,
|
||||||
|
label: 'Overcommitted',
|
||||||
|
amount: formatAbsoluteCurrency(value, currency),
|
||||||
|
note: 'Live commitments are above approved allocations.',
|
||||||
|
containerClassName: 'border-red-200 bg-red-50',
|
||||||
|
amountClassName: 'text-red-950',
|
||||||
|
noteClassName: 'text-red-700',
|
||||||
|
badgeClassName: 'border-red-200 bg-white text-red-700',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
return {
|
||||||
|
currency,
|
||||||
|
label: 'Headroom',
|
||||||
|
amount: formatAbsoluteCurrency(value, currency),
|
||||||
|
note: 'Approved allocations still exceed live commitments.',
|
||||||
|
containerClassName: 'border-emerald-200 bg-emerald-50',
|
||||||
|
amountClassName: 'text-emerald-950',
|
||||||
|
noteClassName: 'text-emerald-700',
|
||||||
|
badgeClassName: 'border-emerald-200 bg-white text-emerald-700',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currency,
|
||||||
|
label: 'On plan',
|
||||||
|
amount: formatCurrency(0, currency),
|
||||||
|
note: 'Approved allocations and live commitments are aligned.',
|
||||||
|
containerClassName: 'border-slate-200 bg-slate-50',
|
||||||
|
amountClassName: 'text-slate-900 dark:text-white',
|
||||||
|
noteClassName: 'text-slate-600 dark:text-slate-300',
|
||||||
|
badgeClassName: 'border-slate-200 bg-white text-slate-600 dark:text-slate-300',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => {
|
const formatDate = (value?: string | null) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '—';
|
return '—';
|
||||||
@ -286,11 +330,6 @@ const severityBadgeClass = (severity?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRecordLink = (recordType?: string, recordKey?: string) => {
|
|
||||||
if (!recordType || !recordKey) {
|
|
||||||
return '/approvals/approvals-list';
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedDetailPages = new Set([
|
const allowedDetailPages = new Set([
|
||||||
'requisitions',
|
'requisitions',
|
||||||
'contracts',
|
'contracts',
|
||||||
@ -302,13 +341,27 @@ const getRecordLink = (recordType?: string, recordKey?: string) => {
|
|||||||
'payment_requests',
|
'payment_requests',
|
||||||
'budget_reallocations',
|
'budget_reallocations',
|
||||||
'grants',
|
'grants',
|
||||||
|
'compliance_alerts',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!allowedDetailPages.has(recordType)) {
|
const getRecordListLink = (recordType?: string) => {
|
||||||
|
if (!recordType || !allowedDetailPages.has(recordType)) {
|
||||||
return '/approvals/approvals-list';
|
return '/approvals/approvals-list';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `/${recordType}/${recordType}-view?id=${recordKey}`;
|
return `/${recordType}/${recordType}-list`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRecordLink = (recordType?: string, recordId?: string | null) => {
|
||||||
|
if (!recordType || !allowedDetailPages.has(recordType)) {
|
||||||
|
return '/approvals/approvals-list';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recordId) {
|
||||||
|
return getRecordListLink(recordType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${recordType}/${recordType}-view?id=${recordId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SectionHeader = ({
|
const SectionHeader = ({
|
||||||
@ -320,7 +373,7 @@ const SectionHeader = ({
|
|||||||
title: string;
|
title: string;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
}) => (
|
}) => (
|
||||||
<div className='flex items-center justify-between gap-4 border-b border-slate-200 pb-4 dark:border-slate-800'>
|
<div className='flex flex-col gap-3 border-b border-slate-200 pb-4 md:flex-row md:items-end md:justify-between dark:border-slate-800'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{eyebrow}</p>
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{eyebrow}</p>
|
||||||
<h3 className='mt-1 text-xl font-semibold text-slate-900 dark:text-white'>{title}</h3>
|
<h3 className='mt-1 text-xl font-semibold text-slate-900 dark:text-white'>{title}</h3>
|
||||||
@ -340,14 +393,14 @@ const SummaryMetric = ({
|
|||||||
note: string;
|
note: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
}) => (
|
}) => (
|
||||||
<CardBox className='h-full border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:shadow-none'>
|
<CardBox className='h-full border border-slate-200 bg-white/90 shadow-sm shadow-slate-950/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-none'>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{title}</p>
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{title}</p>
|
||||||
<p className='mt-3 text-2xl font-semibold text-slate-900 dark:text-white'>{value}</p>
|
<p className='mt-3 text-2xl font-semibold text-slate-900 dark:text-white'>{value}</p>
|
||||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>{note}</p>
|
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>{note}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex h-11 w-11 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-800'>
|
<div className='flex h-11 w-11 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-800'>
|
||||||
<BaseIcon path={icon} size={22} className='text-slate-700 dark:text-slate-200' />
|
<BaseIcon path={icon} size={22} className='text-slate-700 dark:text-slate-200' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -398,14 +451,14 @@ const RoleBriefingCard = ({
|
|||||||
const style = getBriefingCardStyle(title);
|
const style = getBriefingCardStyle(title);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBox className='h-full border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:shadow-none'>
|
<CardBox className='h-full border border-slate-200 bg-white/90 shadow-sm shadow-slate-950/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-none'>
|
||||||
<div className='flex h-full flex-col gap-4'>
|
<div className='flex h-full flex-col gap-4'>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${style.badgeClass}`}>{title}</span>
|
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${style.badgeClass}`}>{title}</span>
|
||||||
<p className='mt-3 text-base font-semibold text-slate-900 dark:text-white'>Responsibility brief</p>
|
<p className='mt-3 text-base font-semibold text-slate-900 dark:text-white'>Responsibility brief</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex h-11 w-11 items-center justify-center rounded-xl border ${style.iconClass}`}>
|
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border ${style.iconClass}`}>
|
||||||
<BaseIcon path={style.icon} size={20} />
|
<BaseIcon path={style.icon} size={20} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -590,6 +643,17 @@ const ExecutiveSummaryPage = () => {
|
|||||||
const workspaceBriefingCards = workspaceConfig.briefingCards.slice(0, 4);
|
const workspaceBriefingCards = workspaceConfig.briefingCards.slice(0, 4);
|
||||||
const receivesFromCard = workspaceBriefingCards.find((card) => card.title.toLowerCase().includes('receive'));
|
const receivesFromCard = workspaceBriefingCards.find((card) => card.title.toLowerCase().includes('receive'));
|
||||||
const handsOffCard = workspaceBriefingCards.find((card) => card.title.toLowerCase().includes('hands off'));
|
const handsOffCard = workspaceBriefingCards.find((card) => card.title.toLowerCase().includes('hands off'));
|
||||||
|
const hiddenApprovalCount = useMemo(
|
||||||
|
() => Math.max((data.summary.pendingApprovals || 0) - data.approvalQueue.length, 0),
|
||||||
|
[data.approvalQueue.length, data.summary.pendingApprovals],
|
||||||
|
);
|
||||||
|
const budgetPositionCards = useMemo(
|
||||||
|
() => ([
|
||||||
|
getBudgetPositionMeta(data.summary.budgetVariance.USD, 'USD'),
|
||||||
|
getBudgetPositionMeta(data.summary.budgetVariance.CDF, 'CDF'),
|
||||||
|
]),
|
||||||
|
[data.summary.budgetVariance.CDF, data.summary.budgetVariance.USD],
|
||||||
|
);
|
||||||
|
|
||||||
const focusBlock = Boolean(data.workspace?.focusCards?.length) && (
|
const focusBlock = Boolean(data.workspace?.focusCards?.length) && (
|
||||||
<div className='mb-6 grid grid-cols-1 gap-4 xl:grid-cols-4'>
|
<div className='mb-6 grid grid-cols-1 gap-4 xl:grid-cols-4'>
|
||||||
@ -660,6 +724,12 @@ const ExecutiveSummaryPage = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className='py-10 text-sm text-slate-500 dark:text-slate-400'>Loading approval workload…</div>
|
<div className='py-10 text-sm text-slate-500 dark:text-slate-400'>Loading approval workload…</div>
|
||||||
) : data.approvalQueue.length ? (
|
) : data.approvalQueue.length ? (
|
||||||
|
<>
|
||||||
|
{hiddenApprovalCount ? (
|
||||||
|
<div className='mt-4 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900'>
|
||||||
|
{hiddenApprovalCount} pending approval{hiddenApprovalCount === 1 ? '' : 's'} {hiddenApprovalCount === 1 ? 'is' : 'are'} hidden from this queue because workflow or record routing details are incomplete. Complete the underlying setup to make {hiddenApprovalCount === 1 ? 'it' : 'them'} operational.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className='mt-4 overflow-x-auto'>
|
<div className='mt-4 overflow-x-auto'>
|
||||||
<table className='min-w-full divide-y divide-slate-200 text-sm'>
|
<table className='min-w-full divide-y divide-slate-200 text-sm'>
|
||||||
<thead>
|
<thead>
|
||||||
@ -694,7 +764,7 @@ const ExecutiveSummaryPage = () => {
|
|||||||
<Link href={`/approvals/approvals-view?id=${item.id}`} className='font-medium text-blue-700 hover:text-blue-900'>
|
<Link href={`/approvals/approvals-view?id=${item.id}`} className='font-medium text-blue-700 hover:text-blue-900'>
|
||||||
Open approval
|
Open approval
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={getRecordLink(item.recordType, item.recordKey)} className='text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:text-white'>
|
<Link href={getRecordLink(item.recordType, item.recordId)} className='text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:text-white'>
|
||||||
{humanize(item.recordType)} record
|
{humanize(item.recordType)} record
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -704,6 +774,11 @@ const ExecutiveSummaryPage = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : hiddenApprovalCount ? (
|
||||||
|
<div className='mt-5 rounded-md border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900'>
|
||||||
|
{hiddenApprovalCount} pending approval{hiddenApprovalCount === 1 ? '' : 's'} are not yet routable because workflow or requester setup is incomplete. Complete the approval setup to move them into the active queue.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='mt-5 rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500 dark:text-slate-400 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400'>
|
<div className='mt-5 rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500 dark:text-slate-400 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400'>
|
||||||
No pending approvals are queued right now. New items will appear here when records reach the approval flow.
|
No pending approvals are queued right now. New items will appear here when records reach the approval flow.
|
||||||
@ -723,12 +798,21 @@ const ExecutiveSummaryPage = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='mt-4 grid gap-3'>
|
<div className='mt-4 grid gap-3'>
|
||||||
<div className='rounded-md border border-red-100 bg-red-50 p-4 dark:border-red-900/60 dark:bg-red-950/40'>
|
<div className='rounded-md border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/70'>
|
||||||
<p className='text-sm font-semibold text-red-800'>Budget headroom</p>
|
<p className='text-sm font-semibold text-slate-900 dark:text-white'>Budget position</p>
|
||||||
<p className='mt-2 text-lg font-semibold text-red-950'>
|
<p className='mt-1 text-sm text-slate-600 dark:text-slate-300'>Difference between approved allocations and live commitments by currency.</p>
|
||||||
{formatCurrency(data.summary.budgetVariance.USD, 'USD')} / {formatCurrency(data.summary.budgetVariance.CDF, 'CDF')}
|
<div className='mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2'>
|
||||||
</p>
|
{budgetPositionCards.map((item) => (
|
||||||
<p className='mt-1 text-sm text-red-700'>Remaining variance between approved allocations and current commitments.</p>
|
<div key={item.currency} className={`rounded-md border p-4 ${item.containerClassName}`}>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400'>{item.currency}</p>
|
||||||
|
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-semibold ${item.badgeClassName}`}>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className={`mt-3 text-lg font-semibold ${item.amountClassName}`}>{item.amount}</p>
|
||||||
|
<p className={`mt-1 text-sm ${item.noteClassName}`}>{item.note}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid grid-cols-2 gap-3'>
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
<div className='rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/60 dark:bg-amber-950/30'>
|
<div className='rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/60 dark:bg-amber-950/30'>
|
||||||
@ -745,7 +829,7 @@ const ExecutiveSummaryPage = () => {
|
|||||||
<div className='mt-5 space-y-3'>
|
<div className='mt-5 space-y-3'>
|
||||||
{data.riskPanel.length ? (
|
{data.riskPanel.length ? (
|
||||||
data.riskPanel.slice(0, 4).map((risk) => (
|
data.riskPanel.slice(0, 4).map((risk) => (
|
||||||
<Link href={getRecordLink(risk.recordType, risk.recordKey)} key={risk.id} className='block rounded-md border border-slate-200 p-4 transition hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:hover:border-slate-700 dark:hover:bg-slate-800/70'>
|
<Link href={getRecordLink(risk.recordType, risk.recordId)} key={risk.id} className='block rounded-md border border-slate-200 p-4 transition hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:hover:border-slate-700 dark:hover:bg-slate-800/70'>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<p className='font-medium text-slate-900 dark:text-white'>{risk.title}</p>
|
<p className='font-medium text-slate-900 dark:text-white'>{risk.title}</p>
|
||||||
@ -900,7 +984,7 @@ const ExecutiveSummaryPage = () => {
|
|||||||
<div className='mt-4 space-y-3'>
|
<div className='mt-4 space-y-3'>
|
||||||
{data.recentNotifications.length ? (
|
{data.recentNotifications.length ? (
|
||||||
data.recentNotifications.map((notification) => (
|
data.recentNotifications.map((notification) => (
|
||||||
<Link href={getRecordLink(notification.recordType, notification.recordKey)} key={notification.id} className='block rounded-md border border-slate-200 p-4 transition hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:hover:border-slate-700 dark:hover:bg-slate-800/70'>
|
<Link href={getRecordLink(notification.recordType, notification.recordId)} key={notification.id} className='block rounded-md border border-slate-200 p-4 transition hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:hover:border-slate-700 dark:hover:bg-slate-800/70'>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<p className='font-medium text-slate-900 dark:text-white'>{notification.title}</p>
|
<p className='font-medium text-slate-900 dark:text-white'>{notification.title}</p>
|
||||||
@ -961,7 +1045,7 @@ const ExecutiveSummaryPage = () => {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500 dark:text-slate-400 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400'>
|
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500 dark:text-slate-400 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400'>
|
||||||
No provincial rollout data is available yet.
|
No provincial rollout is available yet. Assign provinces to projects to unlock delivery coverage by province.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1008,7 +1092,7 @@ const ExecutiveSummaryPage = () => {
|
|||||||
</table>
|
</table>
|
||||||
) : (
|
) : (
|
||||||
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500 dark:text-slate-400 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400'>
|
<div className='rounded-md border border-dashed border-slate-300 bg-slate-50 p-6 text-sm text-slate-500 dark:text-slate-400 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400'>
|
||||||
No contract commitments are available yet.
|
No material contract commitments are ready for executive review yet. Major commitments appear here once contracts have linked delivery context and a meaningful registered value.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1072,59 +1156,59 @@ const ExecutiveSummaryPage = () => {
|
|||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
<CardBox className='mb-6 overflow-hidden border border-slate-200 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-800 text-slate-100 shadow-sm'>
|
<CardBox className='mb-6 overflow-hidden border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900'>
|
||||||
<div className='grid gap-6 lg:grid-cols-[1.7fr,1fr]'>
|
<div className='grid gap-6 lg:grid-cols-[1.7fr,1fr] lg:items-start'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-slate-400 dark:text-slate-500'>{workspaceConfig.eyebrow}</p>
|
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-slate-500 dark:text-slate-400'>{workspaceConfig.eyebrow}</p>
|
||||||
<h2 className='mt-3 text-3xl font-semibold tracking-tight text-white'>{workspaceConfig.heroTitle}</h2>
|
<h2 className='mt-3 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white'>{workspaceConfig.heroTitle}</h2>
|
||||||
<p className='mt-4 max-w-3xl text-sm leading-6 text-slate-300'>{workspaceConfig.heroDescription}</p>
|
<p className='mt-4 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300'>{workspaceConfig.heroDescription}</p>
|
||||||
<div className='mt-6 grid gap-3 sm:grid-cols-2'>
|
<div className='mt-6 grid gap-3 sm:grid-cols-2'>
|
||||||
{heroMetricChips.map((metric) => (
|
{heroMetricChips.map((metric) => (
|
||||||
<div key={metric.title} className='rounded-xl border border-white/10 bg-white/5 p-4 backdrop-blur-sm'>
|
<div key={metric.title} className='rounded-xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-800/70'>
|
||||||
<p className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500'>{metric.title}</p>
|
<p className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{metric.title}</p>
|
||||||
<p className='mt-2 text-2xl font-semibold text-white'>{metric.value}</p>
|
<p className='mt-2 text-2xl font-semibold text-slate-950 dark:text-white'>{metric.value}</p>
|
||||||
<p className='mt-2 text-xs leading-5 text-slate-400 dark:text-slate-500'>{metric.note}</p>
|
<p className='mt-2 text-xs leading-5 text-slate-500 dark:text-slate-400'>{metric.note}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-4 text-sm'>
|
<div className='grid gap-4 text-sm'>
|
||||||
<div className='rounded-xl border border-white/10 bg-white/5 p-4 backdrop-blur-sm'>
|
<div className='rounded-xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-800/70'>
|
||||||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500'>Logged-in role</p>
|
<p className='text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400'>Logged-in role</p>
|
||||||
<p className='mt-2 text-lg font-semibold text-white'>{currentUser?.firstName || currentUser?.email || 'Authenticated user'}</p>
|
<p className='mt-2 text-lg font-semibold text-slate-950 dark:text-white'>{currentUser?.firstName || currentUser?.email || 'Authenticated user'}</p>
|
||||||
<p className='mt-1 text-slate-400 dark:text-slate-500'>{data.workspace?.roleName || currentUser?.app_role?.name || 'Operational access'}</p>
|
<p className='mt-1 text-slate-500 dark:text-slate-400'>{data.workspace?.roleName || currentUser?.app_role?.name || 'Operational access'}</p>
|
||||||
<div className='mt-4 flex flex-wrap gap-2'>
|
<div className='mt-4 flex flex-wrap gap-2'>
|
||||||
<span className='rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300'>
|
<span className='rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'>
|
||||||
{workspaceConfig.quickLinks.length} quick links
|
{workspaceConfig.quickLinks.length} quick links
|
||||||
</span>
|
</span>
|
||||||
<span className='rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300'>
|
<span className='rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'>
|
||||||
{workspaceConfig.blockOrder.length} focus sections
|
{workspaceConfig.blockOrder.length} focus sections
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='rounded-xl border border-white/10 bg-white/5 p-4 backdrop-blur-sm'>
|
<div className='rounded-xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-800/70'>
|
||||||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500'>Role interconnection</p>
|
<p className='text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400'>Role interconnection</p>
|
||||||
<div className='mt-4 space-y-4'>
|
<div className='mt-4 space-y-4'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{receivesFromCard?.title || 'Receives from'}</p>
|
<p className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{receivesFromCard?.title || 'Receives from'}</p>
|
||||||
<p className='mt-2 text-sm leading-6 text-slate-300'>{receivesFromCard?.items?.[0] || 'Who this role receives work and information from.'}</p>
|
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300'>{receivesFromCard?.items?.[0] || 'Who this role receives work and information from.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{handsOffCard?.title || 'Hands off to'}</p>
|
<p className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{handsOffCard?.title || 'Hands off to'}</p>
|
||||||
<p className='mt-2 text-sm leading-6 text-slate-300'>{handsOffCard?.items?.[0] || "Who depends on this role's decisions and follow-through."}</p>
|
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300'>{handsOffCard?.items?.[0] || "Who depends on this role's decisions and follow-through."}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='rounded-xl border border-white/10 bg-white/5 p-4 backdrop-blur-sm'>
|
<div className='rounded-xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-800/70'>
|
||||||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500'>Institution signals</p>
|
<p className='text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400'>Institution signals</p>
|
||||||
<div className='mt-3 grid grid-cols-2 gap-3'>
|
<div className='mt-3 grid grid-cols-2 gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-2xl font-semibold text-white'>{data.summary.overduePayments}</p>
|
<p className='text-2xl font-semibold text-slate-950 dark:text-white'>{data.summary.overduePayments}</p>
|
||||||
<p className='text-slate-400 dark:text-slate-500'>Overdue payment requests</p>
|
<p className='text-slate-500 dark:text-slate-400'>Overdue payment requests</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-2xl font-semibold text-white'>{data.summary.unreadNotifications}</p>
|
<p className='text-2xl font-semibold text-slate-950 dark:text-white'>{data.summary.unreadNotifications}</p>
|
||||||
<p className='text-slate-400 dark:text-slate-500'>Unread notifications</p>
|
<p className='text-slate-500 dark:text-slate-400'>Unread notifications</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user