259 lines
14 KiB
TypeScript
259 lines
14 KiB
TypeScript
import * as icon from '@mdi/js';
|
|
import Head from 'next/head'
|
|
import React, { useCallback, useMemo } from 'react'
|
|
import axios from 'axios';
|
|
import type { ReactElement } from 'react'
|
|
import LayoutAuthenticated from '../layouts/Authenticated'
|
|
import SectionMain from '../components/SectionMain'
|
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
|
import BaseIcon from "../components/BaseIcon";
|
|
import { getPageTitle } from '../config'
|
|
import Link from "next/link";
|
|
import moment from 'moment';
|
|
|
|
import { hasPermission } from "../helpers/userPermissions";
|
|
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
|
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
|
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
|
import UserAvatar from '../components/UserAvatar';
|
|
import CardBox from '../components/CardBox';
|
|
|
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
|
import { fetch as fetchActivityItems } from '../stores/activity_feed_items/activity_feed_itemsSlice';
|
|
|
|
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 loadingMessage = 'Loading...';
|
|
|
|
const [projects, setProjects] = React.useState(loadingMessage);
|
|
const [trials, setTrials] = React.useState(loadingMessage);
|
|
const [form_submissions, setForm_submissions] = React.useState(loadingMessage);
|
|
const [users, setUsers] = React.useState(loadingMessage);
|
|
|
|
const { activity_feed_items, loading: activityLoading } = useAppSelector((state) => state.activity_feed_items);
|
|
const { currentUser } = useAppSelector((state) => state.auth);
|
|
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
|
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
|
|
|
|
const loadCounts = useCallback(async () => {
|
|
const entities = ['projects', 'trials', 'form_submissions', 'users'];
|
|
const fns = [setProjects, setTrials, setForm_submissions, setUsers];
|
|
|
|
const requests = entities.map((entity, index) => {
|
|
if (hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
|
|
return axios.get(`/${entity.toLowerCase()}/count`);
|
|
} else {
|
|
fns[index](null);
|
|
return Promise.resolve({ data: { count: null } });
|
|
}
|
|
});
|
|
|
|
const results = await Promise.allSettled(requests);
|
|
results.forEach((result, i) => {
|
|
if (result.status === 'fulfilled') {
|
|
fns[i](result.value.data.count);
|
|
} else {
|
|
fns[i](result.reason.message);
|
|
}
|
|
});
|
|
}, [currentUser]);
|
|
|
|
const currentUserMemo = useMemo(() => currentUser ? JSON.stringify(currentUser.id) : null, [currentUser]);
|
|
|
|
React.useEffect(() => {
|
|
if (!currentUser) return;
|
|
loadCounts().then();
|
|
if (hasPermission(currentUser, 'READ_ACTIVITY_FEED_ITEMS')) {
|
|
dispatch(fetchActivityItems({ query: '?limit=10&orderBy=occurred_at_DESC' }));
|
|
}
|
|
}, [currentUserMemo, dispatch, loadCounts]);
|
|
|
|
const getItemIcon = (type: string) => {
|
|
switch (type) {
|
|
case 'project_created': return icon.mdiFolderPlusOutline;
|
|
case 'trial_created': return icon.mdiFlaskPlusOutline;
|
|
case 'submission_created': return icon.mdiClipboardCheckOutline;
|
|
case 'user_created': return icon.mdiAccountPlusOutline;
|
|
case 'attachment_added': return icon.mdiPaperclip;
|
|
default: return icon.mdiBellOutline;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Home')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton icon={icon.mdiHomeOutline} title='Home' main>
|
|
{''}
|
|
</SectionTitleLineWithButton>
|
|
|
|
{/* Summary Cards */}
|
|
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6'>
|
|
<Link href={'/projects/projects-list'}>
|
|
<CardBox className="hover:shadow-lg transition-shadow cursor-pointer">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<p className="text-gray-500 dark:text-gray-400">Projects</p>
|
|
<h3 className="text-3xl font-bold">{projects}</h3>
|
|
</div>
|
|
<BaseIcon path={icon.mdiFolderOutline} size={48} className={iconsColor} />
|
|
</div>
|
|
</CardBox>
|
|
</Link>
|
|
<Link href={'/trials/trials-list'}>
|
|
<CardBox className="hover:shadow-lg transition-shadow cursor-pointer">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<p className="text-gray-500 dark:text-gray-400">Trials</p>
|
|
<h3 className="text-3xl font-bold">{trials}</h3>
|
|
</div>
|
|
<BaseIcon path={icon.mdiTestTube} size={48} className={iconsColor} />
|
|
</div>
|
|
</CardBox>
|
|
</Link>
|
|
<Link href={'/form_submissions/form_submissions-list'}>
|
|
<CardBox className="hover:shadow-lg transition-shadow cursor-pointer">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<p className="text-gray-500 dark:text-gray-400">Submissions</p>
|
|
<h3 className="text-3xl font-bold">{form_submissions}</h3>
|
|
</div>
|
|
<BaseIcon path={icon.mdiClipboardTextOutline} size={48} className={iconsColor} />
|
|
</div>
|
|
</CardBox>
|
|
</Link>
|
|
<Link href={'/users/users-list'}>
|
|
<CardBox className="hover:shadow-lg transition-shadow cursor-pointer">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<p className="text-gray-500 dark:text-gray-400">Team Members</p>
|
|
<h3 className="text-3xl font-bold">{users}</h3>
|
|
</div>
|
|
<BaseIcon path={icon.mdiAccountGroup} size={48} className={iconsColor} />
|
|
</div>
|
|
</CardBox>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Activity Feed Section */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-2">
|
|
<SectionTitleLineWithButton icon={icon.mdiTimelineTextOutline} title="Recent Activity" />
|
|
|
|
{activityLoading && <p>Loading feed...</p>}
|
|
|
|
{!activityLoading && activity_feed_items?.length === 0 && (
|
|
<CardBox>
|
|
<p className="text-center text-gray-500 py-8">No recent activity found.</p>
|
|
</CardBox>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
{activity_feed_items?.map((item: any) => (
|
|
<CardBox key={item.id} className="hover:border-emerald-500 transition-colors border-l-4 border-l-emerald-500">
|
|
<div className="flex items-start space-x-4">
|
|
<div className="flex-shrink-0">
|
|
<UserAvatar
|
|
username={item.actor_user?.firstName || 'User'}
|
|
image={item.actor_user?.avatar}
|
|
className="w-12 h-12"
|
|
/>
|
|
</div>
|
|
<div className="flex-grow">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<span className="font-bold text-lg">
|
|
{item.actor_user?.firstName} {item.actor_user?.lastName}
|
|
</span>
|
|
<span className="text-gray-500 ml-2">
|
|
{item.title}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-gray-400 flex items-center">
|
|
<BaseIcon path={icon.mdiClockOutline} size={14} className="mr-1" />
|
|
{moment(item.occurred_at).fromNow()}
|
|
</div>
|
|
</div>
|
|
<p className="text-gray-600 dark:text-gray-300 mt-1">
|
|
{item.summary}
|
|
</p>
|
|
|
|
{item.thumbnail && item.thumbnail[0] && (
|
|
<div className="mt-3">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={item.thumbnail[0].publicUrl}
|
|
alt="Activity thumbnail"
|
|
className="rounded-lg max-h-48 w-auto object-cover border dark:border-gray-700"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-3 flex items-center justify-between">
|
|
<div className="flex items-center space-x-2">
|
|
<BaseIcon
|
|
path={getItemIcon(item.item_type)}
|
|
size={18}
|
|
className="text-emerald-600"
|
|
/>
|
|
<span className="text-xs font-medium uppercase tracking-wider text-emerald-600">
|
|
{item.item_type.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
{item.link_path && (
|
|
<Link href={item.link_path} className="text-sm text-emerald-600 hover:underline font-medium">
|
|
View Details →
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<SectionTitleLineWithButton icon={icon.mdiChartPie} title="Quick Stats" />
|
|
<CardBox>
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center pb-2 border-b dark:border-gray-700">
|
|
<span className="text-gray-500">Active Projects</span>
|
|
<span className="font-bold">{projects}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center pb-2 border-b dark:border-gray-700">
|
|
<span className="text-gray-500">Ongoing Trials</span>
|
|
<span className="font-bold">{trials}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-gray-500">New Submissions</span>
|
|
<span className="font-bold text-emerald-600">+{form_submissions}</span>
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
|
|
<SectionTitleLineWithButton icon={icon.mdiInformationOutline} title="System Info" />
|
|
<CardBox className="bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800">
|
|
<p className="text-sm text-emerald-800 dark:text-emerald-200">
|
|
<strong>Trial Tracker</strong> is running in multi-tenant mode.
|
|
Farm-level data isolation is active for your account.
|
|
</p>
|
|
</CardBox>
|
|
</div>
|
|
</div>
|
|
</SectionMain>
|
|
</>
|
|
)
|
|
}
|
|
|
|
Dashboard.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
|
}
|
|
|
|
export default Dashboard |