604 lines
25 KiB
TypeScript
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
|