555 lines
18 KiB
TypeScript
555 lines
18 KiB
TypeScript
import * as icon from '@mdi/js'
|
|
import Head from 'next/head'
|
|
import React from 'react'
|
|
import axios from 'axios'
|
|
import type { ReactElement } from 'react'
|
|
import Link from 'next/link'
|
|
import LayoutAuthenticated from '../layouts/Authenticated'
|
|
import SectionMain from '../components/SectionMain'
|
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
|
import BaseIcon from '../components/BaseIcon'
|
|
import BaseButton from '../components/BaseButton'
|
|
import CardBox from '../components/CardBox'
|
|
import { getPageTitle } from '../config'
|
|
|
|
import { hasPermission } from '../helpers/userPermissions'
|
|
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels'
|
|
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles'
|
|
import { fetchWidgets } from '../stores/roles/rolesSlice'
|
|
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'
|
|
import { SmartWidget } from '../components/SmartWidget/SmartWidget'
|
|
|
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
|
|
|
type EntityKey =
|
|
| 'users'
|
|
| 'roles'
|
|
| 'permissions'
|
|
| 'businesses'
|
|
| 'customers'
|
|
| 'transactions'
|
|
| 'review_requests'
|
|
| 'stripe_events'
|
|
| 'email_delivery_logs'
|
|
| 'cron_runs'
|
|
|
|
type CountValue = string | number | null
|
|
|
|
type CountState = Record<EntityKey, CountValue>
|
|
|
|
type DashboardCard = {
|
|
key: EntityKey
|
|
label: string
|
|
description: string
|
|
href: string
|
|
iconPath: string
|
|
permission: string
|
|
}
|
|
|
|
type DashboardAction = {
|
|
label: string
|
|
href: string
|
|
permission?: string | string[]
|
|
}
|
|
|
|
type DashboardActionGroup = {
|
|
title: string
|
|
description: string
|
|
actions: DashboardAction[]
|
|
}
|
|
|
|
const loadingMessage = 'Loading...'
|
|
|
|
const entityKeys: EntityKey[] = [
|
|
'users',
|
|
'roles',
|
|
'permissions',
|
|
'businesses',
|
|
'customers',
|
|
'transactions',
|
|
'review_requests',
|
|
'stripe_events',
|
|
'email_delivery_logs',
|
|
'cron_runs',
|
|
]
|
|
|
|
const entityConfig: Record<EntityKey, { endpoint: string; permission: string }> = {
|
|
users: { endpoint: 'users', permission: 'READ_USERS' },
|
|
roles: { endpoint: 'roles', permission: 'READ_ROLES' },
|
|
permissions: { endpoint: 'permissions', permission: 'READ_PERMISSIONS' },
|
|
businesses: { endpoint: 'businesses', permission: 'READ_BUSINESSES' },
|
|
customers: { endpoint: 'customers', permission: 'READ_CUSTOMERS' },
|
|
transactions: { endpoint: 'transactions', permission: 'READ_TRANSACTIONS' },
|
|
review_requests: { endpoint: 'review_requests', permission: 'READ_REVIEW_REQUESTS' },
|
|
stripe_events: { endpoint: 'stripe_events', permission: 'READ_STRIPE_EVENTS' },
|
|
email_delivery_logs: { endpoint: 'email_delivery_logs', permission: 'READ_EMAIL_DELIVERY_LOGS' },
|
|
cron_runs: { endpoint: 'cron_runs', permission: 'READ_CRON_RUNS' },
|
|
}
|
|
|
|
const initialCounts = entityKeys.reduce((counts, key) => {
|
|
counts[key] = loadingMessage
|
|
|
|
return counts
|
|
}, {} as CountState)
|
|
|
|
const storeIcon =
|
|
'mdiStore' in icon
|
|
? icon['mdiStore' as keyof typeof icon]
|
|
: icon.mdiTable
|
|
|
|
const accountMultipleIcon =
|
|
'mdiAccountMultiple' in icon
|
|
? icon['mdiAccountMultiple' as keyof typeof icon]
|
|
: icon.mdiTable
|
|
|
|
const emailFastIcon =
|
|
'mdiEmailFastOutline' in icon
|
|
? icon['mdiEmailFastOutline' as keyof typeof icon]
|
|
: icon.mdiTable
|
|
|
|
const webhookIcon =
|
|
'mdiWebhook' in icon
|
|
? icon['mdiWebhook' as keyof typeof icon]
|
|
: icon.mdiTable
|
|
|
|
const emailCheckIcon =
|
|
'mdiEmailCheckOutline' in icon
|
|
? icon['mdiEmailCheckOutline' as keyof typeof icon]
|
|
: icon.mdiTable
|
|
|
|
const clockIcon =
|
|
'mdiClockOutline' in icon
|
|
? icon['mdiClockOutline' as keyof typeof icon]
|
|
: icon.mdiTable
|
|
|
|
function formatCount(value: CountValue) {
|
|
if (value === null || value === undefined) return '—'
|
|
if (typeof value === 'number') return value.toLocaleString()
|
|
|
|
return value
|
|
}
|
|
|
|
function StatCard({
|
|
card,
|
|
value,
|
|
corners,
|
|
cardsStyle,
|
|
iconsColor,
|
|
}: {
|
|
card: DashboardCard
|
|
value: CountValue
|
|
corners: string
|
|
cardsStyle: string
|
|
iconsColor: string
|
|
}) {
|
|
return (
|
|
<Link href={card.href}>
|
|
<div
|
|
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6 h-full hover:shadow-lg transition-shadow`}
|
|
>
|
|
<div className='flex justify-between gap-4'>
|
|
<div>
|
|
<div className='text-sm font-black uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400'>
|
|
{card.label}
|
|
</div>
|
|
<div className='mt-2 text-3xl leading-tight font-semibold'>
|
|
{formatCount(value)}
|
|
</div>
|
|
<p className='mt-3 text-sm text-gray-500 dark:text-gray-400'>
|
|
{card.description}
|
|
</p>
|
|
</div>
|
|
<BaseIcon
|
|
className={`${iconsColor} flex-none`}
|
|
w='w-16'
|
|
h='h-16'
|
|
size={48}
|
|
path={card.iconPath}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
function ActionGroupCard({ group, currentUser }: { group: DashboardActionGroup; currentUser: any }) {
|
|
const visibleActions = group.actions.filter(
|
|
(action) => !action.permission || hasPermission(currentUser, action.permission),
|
|
)
|
|
|
|
if (!visibleActions.length) return null
|
|
|
|
return (
|
|
<CardBox className='h-full'>
|
|
<div className='flex h-full flex-col'>
|
|
<div>
|
|
<h2 className='text-xl font-semibold'>{group.title}</h2>
|
|
<p className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{group.description}</p>
|
|
</div>
|
|
<div className='mt-6 grid gap-3'>
|
|
{visibleActions.map((action) => (
|
|
<BaseButton
|
|
key={`${group.title}-${action.href}`}
|
|
href={action.href}
|
|
label={action.label}
|
|
color='whiteDark'
|
|
className='w-full justify-start'
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
)
|
|
}
|
|
|
|
function PortalIntroCard({ currentUser, adminPortal }: { currentUser: any; adminPortal: boolean }) {
|
|
const portalLabel = getPortalLabel(currentUser)
|
|
const roleName = currentUser?.app_role?.name || 'User'
|
|
const name = currentUser?.firstName || currentUser?.email || 'there'
|
|
|
|
return (
|
|
<CardBox className='mb-6 overflow-hidden'>
|
|
<div className='grid gap-6 lg:grid-cols-[1.4fr_0.8fr] lg:items-center'>
|
|
<div>
|
|
<p className='text-xs font-black uppercase tracking-[0.25em] text-indigo-500 dark:text-indigo-300'>
|
|
{portalLabel}
|
|
</p>
|
|
<h2 className='mt-3 text-2xl font-bold'>Welcome, {name}</h2>
|
|
<p className='mt-3 text-gray-600 dark:text-gray-300'>
|
|
{adminPortal
|
|
? 'This internal area is for running the SaaS business: customer accounts, business profiles, billing events, review operations, and access control.'
|
|
: 'This customer workspace is for setting up your business profile, connecting review automation, managing customers, tracking transactions, and handling your subscription.'}
|
|
</p>
|
|
</div>
|
|
<div className='rounded-3xl bg-slate-950 p-6 text-white'>
|
|
<p className='text-sm text-slate-300'>Signed in as</p>
|
|
<p className='mt-2 text-2xl font-bold'>{roleName}</p>
|
|
<p className='mt-4 text-sm text-slate-300'>
|
|
{adminPortal
|
|
? 'Customer workspace setup links are intentionally hidden from this portal.'
|
|
: 'Internal platform administration links are intentionally hidden from this workspace.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
)
|
|
}
|
|
|
|
const Dashboard = () => {
|
|
const dispatch = useAppDispatch()
|
|
const iconsColor = useAppSelector((state) => state.style.iconsColor)
|
|
const corners = useAppSelector((state) => state.style.corners)
|
|
const cardsStyle = useAppSelector((state) => state.style.cardsStyle)
|
|
const { currentUser } = useAppSelector((state) => state.auth)
|
|
const { isFetchingQuery } = useAppSelector((state) => state.openAi)
|
|
const { rolesWidgets, loading } = useAppSelector((state) => state.roles)
|
|
const [counts, setCounts] = React.useState<CountState>(initialCounts)
|
|
const [widgetsRole, setWidgetsRole] = React.useState({
|
|
role: { value: '', label: '' },
|
|
})
|
|
|
|
const adminPortal = isInternalAdmin(currentUser)
|
|
const businessLabel = getBusinessMenuLabel(currentUser?.subscriptionPlanId)
|
|
const businessProfilesLabel = adminPortal ? 'Business profiles' : businessLabel
|
|
|
|
const loadData = React.useCallback(async () => {
|
|
if (!currentUser) return
|
|
|
|
const requests = entityKeys.map(async (key) => {
|
|
const config = entityConfig[key]
|
|
|
|
if (!hasPermission(currentUser, config.permission)) {
|
|
return { key, count: null }
|
|
}
|
|
|
|
const response = await axios.get(`/${config.endpoint}/count`)
|
|
|
|
return { key, count: response.data.count as CountValue }
|
|
})
|
|
|
|
const results = await Promise.allSettled(requests)
|
|
|
|
setCounts((previousCounts) => {
|
|
const nextCounts = { ...previousCounts }
|
|
|
|
results.forEach((result, index) => {
|
|
const key = entityKeys[index]
|
|
|
|
if (result.status === 'fulfilled') {
|
|
nextCounts[result.value.key] = result.value.count
|
|
} else {
|
|
console.error(`Failed to load ${key} dashboard count:`, result.reason)
|
|
nextCounts[key] = 'Error'
|
|
}
|
|
})
|
|
|
|
return nextCounts
|
|
})
|
|
}, [currentUser])
|
|
|
|
const getWidgets = React.useCallback(async (roleId: string) => {
|
|
await dispatch(fetchWidgets(roleId))
|
|
}, [dispatch])
|
|
|
|
React.useEffect(() => {
|
|
if (!currentUser) return
|
|
|
|
loadData().then()
|
|
setWidgetsRole({
|
|
role: {
|
|
value: currentUser?.app_role?.id,
|
|
label: currentUser?.app_role?.name,
|
|
},
|
|
})
|
|
}, [currentUser, loadData])
|
|
|
|
React.useEffect(() => {
|
|
if (!currentUser || !widgetsRole?.role?.value || adminPortal) return
|
|
|
|
getWidgets(widgetsRole?.role?.value || '').then()
|
|
}, [adminPortal, currentUser, getWidgets, widgetsRole?.role?.value])
|
|
|
|
const adminCards: DashboardCard[] = [
|
|
{
|
|
key: 'users',
|
|
label: 'Customer accounts',
|
|
description: 'Owners and team users across the platform.',
|
|
href: '/users/users-list',
|
|
iconPath: icon.mdiAccountGroup,
|
|
permission: 'READ_USERS',
|
|
},
|
|
{
|
|
key: 'businesses',
|
|
label: 'Business profiles',
|
|
description: 'Business locations connected to review flows.',
|
|
href: '/businesses/businesses-list',
|
|
iconPath: storeIcon,
|
|
permission: 'READ_BUSINESSES',
|
|
},
|
|
{
|
|
key: 'transactions',
|
|
label: 'Transactions',
|
|
description: 'Payment records feeding review requests.',
|
|
href: '/transactions/transactions-list',
|
|
iconPath: icon.mdiCreditCardOutline,
|
|
permission: 'READ_TRANSACTIONS',
|
|
},
|
|
{
|
|
key: 'review_requests',
|
|
label: 'Review requests',
|
|
description: 'Review invitations generated by the system.',
|
|
href: '/review_requests/review_requests-list',
|
|
iconPath: emailFastIcon,
|
|
permission: 'READ_REVIEW_REQUESTS',
|
|
},
|
|
{
|
|
key: 'stripe_events',
|
|
label: 'Payment events',
|
|
description: 'Stripe webhook events and processing status.',
|
|
href: '/stripe_events/stripe_events-list',
|
|
iconPath: webhookIcon,
|
|
permission: 'READ_STRIPE_EVENTS',
|
|
},
|
|
{
|
|
key: 'cron_runs',
|
|
label: 'Automation runs',
|
|
description: 'Scheduled background job execution history.',
|
|
href: '/cron_runs/cron_runs-list',
|
|
iconPath: clockIcon,
|
|
permission: 'READ_CRON_RUNS',
|
|
},
|
|
]
|
|
|
|
const customerCards: DashboardCard[] = [
|
|
{
|
|
key: 'businesses',
|
|
label: businessProfilesLabel,
|
|
description: 'Your business profile and Google review destination.',
|
|
href: '/businesses/businesses-list',
|
|
iconPath: storeIcon,
|
|
permission: 'READ_BUSINESSES',
|
|
},
|
|
{
|
|
key: 'customers',
|
|
label: 'Customers',
|
|
description: 'Customer records created from payments or imports.',
|
|
href: '/customers/customers-list',
|
|
iconPath: accountMultipleIcon,
|
|
permission: 'READ_CUSTOMERS',
|
|
},
|
|
{
|
|
key: 'transactions',
|
|
label: 'Transactions',
|
|
description: 'Payments that can trigger review follow-up.',
|
|
href: '/transactions/transactions-list',
|
|
iconPath: icon.mdiCreditCardOutline,
|
|
permission: 'READ_TRANSACTIONS',
|
|
},
|
|
{
|
|
key: 'review_requests',
|
|
label: 'Review requests',
|
|
description: 'Messages scheduled or sent to customers.',
|
|
href: '/review_requests/review_requests-list',
|
|
iconPath: emailFastIcon,
|
|
permission: 'READ_REVIEW_REQUESTS',
|
|
},
|
|
{
|
|
key: 'email_delivery_logs',
|
|
label: 'Email delivery',
|
|
description: 'Delivery activity for review request emails.',
|
|
href: '/email_delivery_logs/email_delivery_logs-list',
|
|
iconPath: emailCheckIcon,
|
|
permission: 'READ_EMAIL_DELIVERY_LOGS',
|
|
},
|
|
]
|
|
|
|
const adminActionGroups: DashboardActionGroup[] = [
|
|
{
|
|
title: 'Customer operations',
|
|
description: 'Support customer accounts and the business profiles they manage.',
|
|
actions: [
|
|
{ label: 'Review customer accounts', href: '/users/users-list', permission: 'READ_USERS' },
|
|
{ label: 'Review business profiles', href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
|
|
{ label: 'Review end customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
|
|
],
|
|
},
|
|
{
|
|
title: 'Billing & review operations',
|
|
description: 'Monitor payment data, webhook events, review requests, and delivery health.',
|
|
actions: [
|
|
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
|
|
{ label: 'View payment events', href: '/stripe_events/stripe_events-list', permission: 'READ_STRIPE_EVENTS' },
|
|
{ label: 'View review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
|
|
{ label: 'View email delivery logs', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
|
|
{ label: 'View automation runs', href: '/cron_runs/cron_runs-list', permission: 'READ_CRON_RUNS' },
|
|
],
|
|
},
|
|
{
|
|
title: 'Platform access control',
|
|
description: 'Manage internal roles and permissions for the platform.',
|
|
actions: [
|
|
{ label: 'Manage roles', href: '/roles/roles-list', permission: 'READ_ROLES' },
|
|
{ label: 'Manage permissions', href: '/permissions/permissions-list', permission: 'READ_PERMISSIONS' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const customerActionGroups: DashboardActionGroup[] = [
|
|
{
|
|
title: 'Review automation setup',
|
|
description: 'Configure the business profile, review request templates, and payment triggers.',
|
|
actions: [
|
|
{ label: 'Open Review Flow', href: '/reviewflow' },
|
|
{ label: `Manage ${businessLabel}`, href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
|
|
{ label: 'Manage review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
|
|
],
|
|
},
|
|
{
|
|
title: 'Customer records',
|
|
description: 'Track customers and transactions that power follow-up messages.',
|
|
actions: [
|
|
{ label: 'Manage customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
|
|
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
|
|
{ label: 'View email delivery', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
|
|
],
|
|
},
|
|
{
|
|
title: 'Plan & billing',
|
|
description: 'Review plan limits, usage, and billing status for this workspace.',
|
|
actions: [
|
|
{ label: 'Open subscription', href: '/subscription' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const cards = adminPortal ? adminCards : customerCards
|
|
const actionGroups = adminPortal ? adminActionGroups : customerActionGroups
|
|
const visibleCards = cards.filter((card) => hasPermission(currentUser, card.permission))
|
|
const title = adminPortal ? 'Internal admin portal' : 'Customer workspace'
|
|
const sectionIcon = adminPortal ? icon.mdiShieldAccountVariantOutline : icon.mdiChartTimelineVariant
|
|
const showCustomerWidgets = !adminPortal && hasPermission(currentUser, 'CREATE_ROLES')
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle(title)}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton icon={sectionIcon} title={title} main>
|
|
{''}
|
|
</SectionTitleLineWithButton>
|
|
|
|
<PortalIntroCard currentUser={currentUser} adminPortal={adminPortal} />
|
|
|
|
{showCustomerWidgets && (
|
|
<WidgetCreator
|
|
currentUser={currentUser}
|
|
isFetchingQuery={isFetchingQuery}
|
|
setWidgetsRole={setWidgetsRole}
|
|
widgetsRole={widgetsRole}
|
|
/>
|
|
)}
|
|
{!!rolesWidgets.length && showCustomerWidgets && (
|
|
<p className='mb-4 text-gray-500 dark:text-gray-400'>
|
|
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
|
</p>
|
|
)}
|
|
|
|
{!adminPortal && (
|
|
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
|
{(isFetchingQuery || loading) && (
|
|
<div className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
|
|
<BaseIcon
|
|
className={`${iconsColor} animate-spin mr-5`}
|
|
w='w-16'
|
|
h='h-16'
|
|
size={48}
|
|
path={icon.mdiLoading}
|
|
/>{' '}
|
|
Loading widgets...
|
|
</div>
|
|
)}
|
|
|
|
{rolesWidgets.map((widget) => (
|
|
<SmartWidget
|
|
key={widget.id}
|
|
userId={currentUser?.id}
|
|
widget={widget}
|
|
roleId={widgetsRole?.role?.value || ''}
|
|
admin={false}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!adminPortal && !!rolesWidgets.length && <hr className='my-6' />}
|
|
|
|
<div id='dashboard' className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
|
|
{visibleCards.map((card) => (
|
|
<StatCard
|
|
key={`${card.key}-${card.label}`}
|
|
card={card}
|
|
value={counts[card.key]}
|
|
corners={corners}
|
|
cardsStyle={cardsStyle}
|
|
iconsColor={iconsColor}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className='grid grid-cols-1 gap-6 lg:grid-cols-3'>
|
|
{actionGroups.map((group) => (
|
|
<ActionGroupCard key={group.title} group={group} currentUser={currentUser} />
|
|
))}
|
|
</div>
|
|
</SectionMain>
|
|
</>
|
|
)
|
|
}
|
|
|
|
Dashboard.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
|
}
|
|
|
|
export default Dashboard
|