39462-vm/frontend/src/pages/dashboard.tsx
2026-04-04 17:14:44 +00:00

604 lines
25 KiB
TypeScript

import * as icon from '@mdi/js'
import Head from 'next/head'
import Link from 'next/link'
import axios from 'axios'
import React from 'react'
import { useRouter } from 'next/router'
import type { ReactElement } from 'react'
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import CardBox from '../components/CardBox'
import BaseButton from '../components/BaseButton'
import BaseButtons from '../components/BaseButtons'
import BaseIcon from '../components/BaseIcon'
import NotificationBar from '../components/NotificationBar'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'
import { SmartWidget } from '../components/SmartWidget/SmartWidget'
import { getPageTitle } from '../config'
import { hasPermission } from '../helpers/userPermissions'
import { getWorkspaceRoute, isDashboardRole } from '../helpers/workspace'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import { fetchWidgets } from '../stores/roles/rolesSlice'
type DashboardEntityCard = {
entity: string
label: string
description: string
href: string
permission: string
iconPath: string
}
type DashboardSection = {
title: string
eyebrow: string
description: string
cards: DashboardEntityCard[]
}
type DashboardRoleConfig = {
pageTitle: string
eyebrow: string
description: string
primaryAction: {
href: string
label: string
}
secondaryAction: {
href: string
label: string
}
sections: DashboardSection[]
}
const DASHBOARD_CONFIG_BY_ROLE: Record<string, DashboardRoleConfig> = {
'Super Administrator': {
pageTitle: 'Platform Widgets',
eyebrow: 'Super Administrator widget surface',
description:
'Review the platform through a governance lens: tenants, users, roles, permissions, workflow configuration, and audit visibility.',
primaryAction: {
href: '/organizations/organizations-list',
label: 'Review organizations',
},
secondaryAction: {
href: '/executive-summary',
label: 'Open workspace',
},
sections: [
{
title: 'Platform governance',
eyebrow: 'Control surface',
description: 'The structural entities that shape tenants, users, and role design across the platform.',
cards: [
{
entity: 'organizations',
label: 'Organizations',
description: 'Track tenant coverage and ownership.',
href: '/organizations/organizations-list',
permission: 'READ_ORGANIZATIONS',
iconPath: icon.mdiDomain,
},
{
entity: 'users',
label: 'Users',
description: 'Inspect who can access the platform.',
href: '/users/users-list',
permission: 'READ_USERS',
iconPath: icon.mdiAccountGroup,
},
{
entity: 'roles',
label: 'Roles',
description: 'Review responsibility boundaries.',
href: '/roles/roles-list',
permission: 'READ_ROLES',
iconPath: icon.mdiShieldAccountVariantOutline,
},
{
entity: 'permissions',
label: 'Permissions',
description: 'Validate the permission catalogue.',
href: '/permissions/permissions-list',
permission: 'READ_PERMISSIONS',
iconPath: icon.mdiShieldAccountOutline,
},
{
entity: 'role_permissions',
label: 'Role permissions',
description: 'Inspect how permissions map to roles.',
href: '/role_permissions/role_permissions-list',
permission: 'READ_ROLE_PERMISSIONS',
iconPath: icon.mdiLinkVariant,
},
],
},
{
title: 'Workflow and observability',
eyebrow: 'Readiness and traceability',
description: 'Templates, notices, and audit trails that help supervise tenant readiness without owning operational workflows.',
cards: [
{
entity: 'approval_workflows',
label: 'Approval workflows',
description: 'Monitor routing templates and approvals design.',
href: '/approval_workflows/approval_workflows-list',
permission: 'READ_APPROVAL_WORKFLOWS',
iconPath: icon.mdiSitemap,
},
{
entity: 'approval_steps',
label: 'Approval steps',
description: 'Check stage definitions and progression rules.',
href: '/approval_steps/approval_steps-list',
permission: 'READ_APPROVAL_STEPS',
iconPath: icon.mdiStairs,
},
{
entity: 'notifications',
label: 'Notifications',
description: 'Review platform-wide message flow.',
href: '/notifications/notifications-list',
permission: 'READ_NOTIFICATIONS',
iconPath: icon.mdiBellOutline,
},
{
entity: 'audit_logs',
label: 'Audit logs',
description: 'Investigate system activity and changes.',
href: '/audit_logs/audit_logs-list',
permission: 'READ_AUDIT_LOGS',
iconPath: icon.mdiClipboardTextClock,
},
],
},
],
},
Administrator: {
pageTitle: 'Operations Widgets',
eyebrow: 'Administrator operations surface',
description:
'Keep the organization ready to operate by focusing on users, master data, approval routing, administrative notices, and records assurance.',
primaryAction: {
href: '/approval_workflows/approval_workflows-list',
label: 'Review workflow setup',
},
secondaryAction: {
href: '/executive-summary',
label: 'Open workspace',
},
sections: [
{
title: 'Organization setup',
eyebrow: 'People and reference records',
description: 'The administrative entities that keep the organization usable, searchable, and ready for day-to-day work.',
cards: [
{
entity: 'users',
label: 'Users',
description: 'Support staff access and account readiness.',
href: '/users/users-list',
permission: 'READ_USERS',
iconPath: icon.mdiAccountGroup,
},
{
entity: 'provinces',
label: 'Provinces',
description: 'Maintain the delivery geography master list.',
href: '/provinces/provinces-list',
permission: 'READ_PROVINCES',
iconPath: icon.mdiMapMarker,
},
{
entity: 'departments',
label: 'Departments',
description: 'Keep internal organization structure aligned.',
href: '/departments/departments-list',
permission: 'READ_DEPARTMENTS',
iconPath: icon.mdiOfficeBuilding,
},
{
entity: 'documents',
label: 'Documents',
description: 'Review supporting records and shared files.',
href: '/documents/documents-list',
permission: 'READ_DOCUMENTS',
iconPath: icon.mdiFolderFile,
},
],
},
{
title: 'Workflow control',
eyebrow: 'Routing and follow-through',
description: 'The operating controls that help the Administrator keep approvals, notifications, and control exceptions from drifting.',
cards: [
{
entity: 'approval_workflows',
label: 'Approval workflows',
description: 'Inspect routing patterns and gaps.',
href: '/approval_workflows/approval_workflows-list',
permission: 'READ_APPROVAL_WORKFLOWS',
iconPath: icon.mdiSitemap,
},
{
entity: 'approval_steps',
label: 'Approval steps',
description: 'Maintain stage definitions and owners.',
href: '/approval_steps/approval_steps-list',
permission: 'READ_APPROVAL_STEPS',
iconPath: icon.mdiStairs,
},
{
entity: 'approvals',
label: 'Approvals',
description: 'Review the active approval queue.',
href: '/approvals/approvals-list',
permission: 'READ_APPROVALS',
iconPath: icon.mdiChecklist,
},
{
entity: 'role_permissions',
label: 'Role access',
description: 'Check whether access design matches responsibility.',
href: '/role_permissions/role_permissions-list',
permission: 'READ_ROLE_PERMISSIONS',
iconPath: icon.mdiLinkVariant,
},
{
entity: 'notifications',
label: 'Notifications',
description: 'Track operational signals requiring follow-up.',
href: '/notifications/notifications-list',
permission: 'READ_NOTIFICATIONS',
iconPath: icon.mdiBellOutline,
},
{
entity: 'audit_logs',
label: 'Audit logs',
description: 'Investigate who changed what and when.',
href: '/audit_logs/audit_logs-list',
permission: 'READ_AUDIT_LOGS',
iconPath: icon.mdiClipboardTextClock,
},
{
entity: 'compliance_alerts',
label: 'Compliance alerts',
description: 'Spot records that need administrative intervention.',
href: '/compliance_alerts/compliance_alerts-list',
permission: 'READ_COMPLIANCE_ALERTS',
iconPath: icon.mdiShieldAlertOutline,
},
],
},
],
},
}
const SectionHeader = ({
eyebrow,
title,
description,
action,
}: {
eyebrow: string
title: string
description?: string
action?: React.ReactNode
}) => (
<div className='mb-4 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>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>{eyebrow}</p>
<h2 className='mt-1 text-xl font-semibold text-slate-900 dark:text-white'>{title}</h2>
{description ? <p className='mt-2 max-w-3xl text-sm text-slate-500 dark:text-slate-400'>{description}</p> : null}
</div>
{action}
</div>
)
const DashboardCard = ({
label,
description,
count,
href,
iconPath,
}: DashboardEntityCard & { count: number | string | null }) => {
const numericCount = typeof count === 'number'
const displayValue = count === null ? '—' : count
return (
<Link href={href} className='group block h-full'>
<CardBox className='h-full border border-slate-200 bg-white/90 shadow-sm transition-all duration-150 group-hover:-translate-y-0.5 group-hover:border-slate-300 group-hover:shadow-md dark:border-slate-800 dark:bg-slate-900 dark:group-hover:border-slate-700'>
<div className='flex h-full flex-col justify-between gap-6'>
<div className='flex items-start justify-between gap-4'>
<div>
<p className='text-sm font-semibold text-slate-900 dark:text-white'>{label}</p>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>{description}</p>
</div>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-200'>
<BaseIcon path={iconPath} size={20} />
</div>
</div>
<div className='flex items-end justify-between gap-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>Current records</p>
<p className='mt-2 text-3xl font-semibold text-slate-950 dark:text-white'>{displayValue}</p>
</div>
<span className={`rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${numericCount ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'}`}>
{numericCount ? 'Live' : 'Unavailable'}
</span>
</div>
</div>
</CardBox>
</Link>
)
}
const Dashboard = () => {
const dispatch = useAppDispatch()
const router = useRouter()
const { currentUser } = useAppSelector((state) => state.auth)
const { isFetchingQuery } = useAppSelector((state) => state.openAi)
const { rolesWidgets, loading } = useAppSelector((state) => state.roles)
const roleName = currentUser?.app_role?.name || ''
const dashboardAllowed = isDashboardRole(roleName)
const workspaceRoute = getWorkspaceRoute(roleName)
const dashboardConfig = DASHBOARD_CONFIG_BY_ROLE[roleName] || DASHBOARD_CONFIG_BY_ROLE.Administrator
const availableSections = React.useMemo(
() =>
dashboardConfig.sections
.map((section) => ({
...section,
cards: section.cards.filter((card) => hasPermission(currentUser, card.permission)),
}))
.filter((section) => section.cards.length > 0),
[currentUser, dashboardConfig],
)
const [entityCounts, setEntityCounts] = React.useState<Record<string, number | string | null>>({})
const [loadError, setLoadError] = React.useState('')
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
})
const totalVisibleEntities = availableSections.reduce((sum, section) => sum + section.cards.length, 0)
const totalLoadedRecords = Object.values(entityCounts).reduce((sum, value) => {
if (typeof value === 'number') {
return sum + value
}
return sum
}, 0)
React.useEffect(() => {
if (!currentUser?.id || dashboardAllowed) return
router.replace(workspaceRoute)
}, [currentUser?.id, dashboardAllowed, router, workspaceRoute])
React.useEffect(() => {
if (!currentUser?.app_role?.id) return
setWidgetsRole({
role: {
value: currentUser.app_role.id,
label: currentUser.app_role.name,
},
})
}, [currentUser?.app_role?.id, currentUser?.app_role?.name])
React.useEffect(() => {
if (!widgetsRole?.role?.value) return
dispatch(fetchWidgets(widgetsRole.role.value))
}, [dispatch, widgetsRole?.role?.value])
React.useEffect(() => {
if (!currentUser || !dashboardAllowed) return
let mounted = true
const loadCounts = async () => {
try {
setLoadError('')
const cards = availableSections.flatMap((section) => section.cards)
const requests = cards.map((card) => axios.get(`/${card.entity}/count`))
const results = await Promise.allSettled(requests)
const nextCounts: Record<string, number | string | null> = {}
results.forEach((result, index) => {
const card = cards[index]
if (result.status === 'fulfilled') {
nextCounts[card.entity] = result.value.data.count
return
}
console.error(`Failed to load ${card.entity} count`, result.reason)
nextCounts[card.entity] = 'Error'
})
if (mounted) {
setEntityCounts(nextCounts)
}
} catch (error: any) {
console.error('Failed to load dashboard counts', error)
if (mounted) {
setLoadError(error?.response?.data?.message || 'Unable to load dashboard counts right now.')
}
}
}
loadCounts()
return () => {
mounted = false
}
}, [availableSections, currentUser, dashboardAllowed])
if (currentUser?.id && !dashboardAllowed) {
return (
<SectionMain>
<CardBox className='border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900'>
<div className='flex flex-col gap-2'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>Role workspace redirect</p>
<h2 className='text-xl font-semibold text-slate-900 dark:text-white'>This dashboard is reserved for Super Administrators and Administrators.</h2>
<p className='text-sm text-slate-600 dark:text-slate-300'>Redirecting you to your role workspace now.</p>
<div>
<Link href={workspaceRoute} className='text-sm font-medium text-blue-700 dark:text-sky-400'>Go to workspace now</Link>
</div>
</div>
</CardBox>
</SectionMain>
)
}
return (
<>
<Head>
<title>{getPageTitle(dashboardConfig.pageTitle)}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={icon.mdiChartTimelineVariant} title={dashboardConfig.pageTitle} main>
<BaseButtons>
<BaseButton href={dashboardConfig.primaryAction.href} color='info' label={dashboardConfig.primaryAction.label} icon={icon.mdiArrowTopRight} />
<BaseButton href={dashboardConfig.secondaryAction.href} color='whiteDark' label={dashboardConfig.secondaryAction.label} />
</BaseButtons>
</SectionTitleLineWithButton>
<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.6fr,1fr]'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-slate-500 dark:text-slate-400'>{dashboardConfig.eyebrow}</p>
<h2 className='mt-3 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white'>{dashboardConfig.pageTitle}</h2>
<p className='mt-4 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300'>{dashboardConfig.description}</p>
<div className='mt-6 grid gap-3 sm:grid-cols-3'>
<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-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>Visible entity cards</p>
<p className='mt-2 text-2xl font-semibold text-slate-950 dark:text-white'>{totalVisibleEntities}</p>
<p className='mt-2 text-xs text-slate-500 dark:text-slate-400'>Only role-approved modules are surfaced here.</p>
</div>
<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-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>Records represented</p>
<p className='mt-2 text-2xl font-semibold text-slate-950 dark:text-white'>{totalLoadedRecords}</p>
<p className='mt-2 text-xs text-slate-500 dark:text-slate-400'>Combined count across the visible control surface.</p>
</div>
<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-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>Saved widgets</p>
<p className='mt-2 text-2xl font-semibold text-slate-950 dark:text-white'>{rolesWidgets.length}</p>
<p className='mt-2 text-xs text-slate-500 dark:text-slate-400'>Role-specific widgets remain available below.</p>
</div>
</div>
</div>
<div className='grid gap-4'>
<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-500 dark:text-slate-400'>Current operator</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-sm text-slate-500 dark:text-slate-400'>{roleName || 'Operational access'}</p>
</div>
<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-500 dark:text-slate-400'>Dashboard intent</p>
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300'>
This page is now a focused control surface instead of a generic entity dump. If a module does not belong to this role, it stays out of view.
</p>
</div>
<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-500 dark:text-slate-400'>Next best action</p>
<Link href={dashboardConfig.primaryAction.href} className='mt-2 inline-flex items-center gap-2 text-sm font-semibold text-blue-700 transition hover:text-slate-950 dark:text-sky-400 dark:hover:text-white'>
{dashboardConfig.primaryAction.label}
<BaseIcon path={icon.mdiArrowTopRight} size={16} />
</Link>
</div>
</div>
</div>
</CardBox>
{loadError ? <NotificationBar color='danger'>{loadError}</NotificationBar> : null}
<CardBox className='mb-6 border border-slate-200 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900'>
<SectionHeader
eyebrow='Role widgets'
title={`${widgetsRole?.role?.label || roleName || 'Current role'} widgets`}
description='Continue using AI-configured widgets, but keep them inside a cleaner role-specific shell.'
action={
hasPermission(currentUser, 'CREATE_ROLES') ? (
<span className='rounded-full bg-blue-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-700 dark:bg-blue-950/40 dark:text-blue-300'>
Widget editing enabled
</span>
) : null
}
/>
{hasPermission(currentUser, 'CREATE_ROLES') ? (
<WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}
setWidgetsRole={setWidgetsRole}
widgetsRole={widgetsRole}
/>
) : null}
<div className='mt-6 grid grid-cols-1 gap-6 lg:grid-cols-4'>
{(isFetchingQuery || loading) && (
<div className='flex items-center gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-6 text-slate-600 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-300'>
<BaseIcon className='animate-spin text-blue-600 dark:text-sky-400' w='w-10' h='h-10' size={32} path={icon.mdiLoading} />
<div>
<p className='font-semibold'>Loading widgets</p>
<p className='text-sm'>Refreshing saved role insights.</p>
</div>
</div>
)}
{rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={hasPermission(currentUser, 'CREATE_ROLES')}
/>
))}
{!rolesWidgets.length && !(isFetchingQuery || loading) ? (
<div className='lg:col-span-4'>
<div className='rounded-2xl border border-dashed border-slate-300 bg-slate-50/80 p-6 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'>
No widgets are saved for this role yet. Use the widget creator to add focused insights, or continue with the curated entity sections below.
</div>
</div>
) : null}
</div>
</CardBox>
<div className='space-y-6'>
{availableSections.map((section) => (
<CardBox key={section.title} className='border border-slate-200 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900'>
<SectionHeader eyebrow={section.eyebrow} title={section.title} description={section.description} />
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{section.cards.map((card) => (
<DashboardCard
key={card.entity}
{...card}
count={Object.prototype.hasOwnProperty.call(entityCounts, card.entity) ? entityCounts[card.entity] : 'Loading…'}
/>
))}
</div>
</CardBox>
))}
</div>
</SectionMain>
</>
)
}
Dashboard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Dashboard