Autosave: 20260404-171444

This commit is contained in:
Flatlogic Bot 2026-04-04 17:14:44 +00:00
parent d7c816c260
commit 8ceeef1051
9 changed files with 957 additions and 2125 deletions

View File

@ -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,23 +873,27 @@ 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,
stepOrder: approval.step?.step_order || null, stepName: approval.step.name,
requestedBy: approval.requested_by_user stepOrder: approval.step?.step_order || null,
? `${approval.requested_by_user.firstName || ''} ${approval.requested_by_user.lastName || ''}`.trim() || approval.requested_by_user.email requestedBy: getUserDisplayName(approval.requested_by_user),
: 'Requester unavailable', assignedTo: getUserDisplayName(approval.assigned_to_user, 'Awaiting assignment'),
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) => ({
id: requisition.id, id: requisition.id,
@ -895,18 +925,23 @@ 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,
title: contract.title, title: contract.title,
contractValue: toNumber(contract.contract_value), contractValue: toNumber(contract.contract_value),
currency: contract.currency, currency: contract.currency,
endDate: contract.end_date, endDate: contract.end_date,
status: contract.status, status: contract.status,
vendorName: contract.vendor?.name || 'Vendor not linked', vendorName: contract.vendor?.name || 'Vendor not linked',
projectName: contract.project?.name || 'Project not linked', projectName: contract.project?.name || 'Project not linked',
})); }));
const formattedRiskPanel = riskPanel const formattedRiskPanel = riskPanel
.filter((alert) => !alert.record_type || SUPPORTED_RECORD_TYPES.has(alert.record_type)) .filter((alert) => !alert.record_type || SUPPORTED_RECORD_TYPES.has(alert.record_type))
@ -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 = {

View File

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

View File

@ -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

View File

@ -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 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}>
<BaseIcon path={mdiClose} />
</button>
</div>
<div className='mt-4 border-t border-slate-200 pt-3 dark:border-slate-800'>
<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>
<button
className="hidden lg:inline-block xl:hidden p-3"
onClick={handleAsideLgCloseClick}
>
<BaseIcon path={mdiClose} />
</button>
</div> </div>
<div
className={`flex-1 overflow-y-auto overflow-x-hidden ${ <div className={`flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 ${darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle}`}>
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`}
>
<AsideMenuList menu={menu} /> <AsideMenuList menu={menu} />
</div> </div>
</div> </div>

View File

@ -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 {

View File

@ -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)}
/> />
{children}
<FooterBar>Hand-crafted & Made with </FooterBar> <div className='pt-14'>
<div className='min-h-[calc(100vh-3.5rem)]'>
{children}
</div>
<FooterBar />
</div>
</div> </div>
</div> </div>
) )

View File

@ -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',
icon: icon.mdiAccountGroup,
roles: adminWorkspaceRoles,
menu: [
{ {
href: '/users/users-list', href: '/audit_logs/audit_logs-list',
label: 'Users', label: 'Audit logs',
icon: icon.mdiAccountGroup, icon: optionalIcon('mdiClipboardTextClock'),
permissions: 'READ_USERS', permissions: 'READ_AUDIT_LOGS',
},
{
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

View File

@ -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,29 +330,38 @@ const severityBadgeClass = (severity?: string) => {
} }
}; };
const getRecordLink = (recordType?: string, recordKey?: string) => { const allowedDetailPages = new Set([
if (!recordType || !recordKey) { 'requisitions',
'contracts',
'projects',
'vendors',
'tenders',
'awards',
'invoices',
'payment_requests',
'budget_reallocations',
'grants',
'compliance_alerts',
]);
const getRecordListLink = (recordType?: string) => {
if (!recordType || !allowedDetailPages.has(recordType)) {
return '/approvals/approvals-list'; return '/approvals/approvals-list';
} }
const allowedDetailPages = new Set([ return `/${recordType}/${recordType}-list`;
'requisitions', };
'contracts',
'projects',
'vendors',
'tenders',
'awards',
'invoices',
'payment_requests',
'budget_reallocations',
'grants',
]);
if (!allowedDetailPages.has(recordType)) { const getRecordLink = (recordType?: string, recordId?: string | null) => {
if (!recordType || !allowedDetailPages.has(recordType)) {
return '/approvals/approvals-list'; return '/approvals/approvals-list';
} }
return `/${recordType}/${recordType}-view?id=${recordKey}`; 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,8 +724,14 @@ 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 ? (
<div className='mt-4 overflow-x-auto'> <>
<table className='min-w-full divide-y divide-slate-200 text-sm'> {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'>
<table className='min-w-full divide-y divide-slate-200 text-sm'>
<thead> <thead>
<tr className='text-left text-xs uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400'> <tr className='text-left text-xs uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400'>
<th className='py-3 pr-4 font-semibold'>Workflow</th> <th className='py-3 pr-4 font-semibold'>Workflow</th>
@ -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>
@ -702,7 +772,12 @@ const ExecutiveSummaryPage = () => {
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</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>
) : ( ) : (
<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'>
@ -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>