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,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 = {

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

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)}
/> />
<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>
) )

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', 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

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