diff --git a/app/controllers/admin_controller.ts b/app/controllers/admin_controller.ts index 4d1b3b0..0c4e3af 100644 --- a/app/controllers/admin_controller.ts +++ b/app/controllers/admin_controller.ts @@ -31,22 +31,16 @@ export default class AdminController { protected collectionsController: CollectionsController ) {} - async index({ response }: HttpContext) { + async index({ inertia }: HttpContext) { const users = await this.usersController.getAllUsersWithTotalRelations(); const linksCount = await this.linksController.getTotalLinksCount(); const collectionsCount = await this.collectionsController.getTotalCollectionsCount(); - // TODO: return view - return response.json({ + return inertia.render('admin/dashboard', { users: users.map((user) => new UserWithRelationCountDto(user).toJson()), totalLinks: linksCount, totalCollections: collectionsCount, }); - // return inertia.render('admin/dashboard', { - // users: users.map((user) => new UserWithRelationCountDto(user).toJson()), - // totalLinks: linksCount, - // totalCollections: collectionsCount, - // }); } } diff --git a/inertia/components/admin/users/th.tsx b/inertia/components/admin/users/th.tsx new file mode 100644 index 0000000..6257939 --- /dev/null +++ b/inertia/components/admin/users/th.tsx @@ -0,0 +1,28 @@ +import { Center, Group, rem, Table, Text, UnstyledButton } from '@mantine/core'; +import { PropsWithChildren } from 'react'; +import { TbChevronDown, TbChevronUp, TbSelector } from 'react-icons/tb'; +import classes from './users_table.module.css'; + +interface ThProps extends PropsWithChildren { + reversed: boolean; + sorted: boolean; + onSort(): void; +} + +export function Th({ children, reversed, sorted, onSort }: ThProps) { + const Icon = sorted ? (reversed ? TbChevronUp : TbChevronDown) : TbSelector; + return ( + + + + + {children} + +
+ +
+
+
+
+ ); +} diff --git a/inertia/components/admin/users/users_table.module.css b/inertia/components/admin/users/users_table.module.css new file mode 100644 index 0000000..2c11f9c --- /dev/null +++ b/inertia/components/admin/users/users_table.module.css @@ -0,0 +1,21 @@ +.th { + padding: 0; +} + +.control { + width: 100%; + padding: var(--mantine-spacing-xs) var(--mantine-spacing-md); + + @mixin hover { + background-color: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-6) + ); + } +} + +.icon { + width: rem(21px); + height: rem(21px); + border-radius: rem(21px); +} diff --git a/inertia/components/admin/users/users_table.tsx b/inertia/components/admin/users/users_table.tsx new file mode 100644 index 0000000..24994dd --- /dev/null +++ b/inertia/components/admin/users/users_table.tsx @@ -0,0 +1,168 @@ +import { + Badge, + ScrollArea, + Table, + Text, + TextInput, + Tooltip, + rem, +} from '@mantine/core'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TbSearch } from 'react-icons/tb'; +import { Th } from '~/components/admin/users/th'; +import { sortData } from '~/components/admin/users/utils'; +import { DATE_FORMAT } from '~/constants'; +import { User } from '~/types/app'; + +dayjs.extend(relativeTime); + +export type UserWithCounts = User & { + linksCount: number; + collectionsCount: number; +}; +export type UsersWithCounts = UserWithCounts[]; + +export interface UsersTableProps { + users: UsersWithCounts; + totalCollections: number; + totalLinks: number; +} + +export function UsersTable({ + users, + totalCollections, + totalLinks, +}: UsersTableProps) { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [sortedData, setSortedData] = useState(users); + const [sortBy, setSortBy] = useState(null); + const [reverseSortDirection, setReverseSortDirection] = useState(false); + + const setSorting = (field: keyof UserWithCounts) => { + const reversed = field === sortBy ? !reverseSortDirection : false; + setReverseSortDirection(reversed); + setSortBy(field); + setSortedData(sortData(users, { sortBy: field, reversed, search })); + }; + + const handleSearchChange = (event: React.ChangeEvent) => { + const { value } = event.currentTarget; + setSearch(value); + setSortedData( + sortData(users, { + sortBy: sortBy, + reversed: reverseSortDirection, + search: value, + }) + ); + }; + + const renderDateCell = (date: string) => ( + + {dayjs(date).fromNow()} + + ); + + const rows = sortedData.map((user) => ( + + {user.fullname} + + {user.isAdmin ? ( + + {t('admin:admin')} + + ) : ( + + {t('admin:user')} + + )} + + {user.collectionsCount} + {user.linksCount} + {renderDateCell(user.createdAt)} + {renderDateCell(user.lastSeenAt)} + + )); + + return ( + + } + value={search} + onChange={handleSearchChange} + /> + + + + + + + + + + + + + {rows.length > 0 ? ( + rows + ) : ( + + + + Nothing found + + + + )} + +
setSorting('fullname')} + > + {t('common:name')} + setSorting('isAdmin')} + > + {t('admin:role')} + setSorting('collectionsCount')} + > + {t('common:collection.collections')} ({totalCollections}) + setSorting('linksCount')} + > + {t('common:link.links')} ({totalLinks}) + setSorting('createdAt')} + > + {t('admin:created_at')} + setSorting('lastSeenAt')} + > + {t('admin:last_seen_at')} +
+
+ ); +} diff --git a/inertia/components/admin/users/utils.ts b/inertia/components/admin/users/utils.ts new file mode 100644 index 0000000..095a4e0 --- /dev/null +++ b/inertia/components/admin/users/utils.ts @@ -0,0 +1,39 @@ +import { + UsersWithCounts, + UserWithCounts, +} from '~/components/admin/users/users_table'; + +export function filterData(data: UsersWithCounts, search: string) { + const query = search.toLowerCase().trim(); + return data.filter((item) => + ['email', 'name', 'nickName', 'fullname'].some((key) => { + const value = item[key as keyof UserWithCounts]; + return typeof value === 'string' && value.toLowerCase().includes(query); + }) + ); +} + +export function sortData( + data: UsersWithCounts, + payload: { + sortBy: keyof UserWithCounts | null; + reversed: boolean; + search: string; + } +) { + const { sortBy } = payload; + + if (!sortBy) { + return filterData(data, payload.search); + } + + return filterData( + [...data].sort((a, b) => { + if (payload.reversed) { + return b[sortBy] > a[sortBy] ? 1 : -1; + } + return a[sortBy] > b[sortBy] ? 1 : -1; + }), + payload.search + ); +} diff --git a/inertia/pages/admin/dashboard.tsx b/inertia/pages/admin/dashboard.tsx new file mode 100644 index 0000000..e23d0c2 --- /dev/null +++ b/inertia/pages/admin/dashboard.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from 'react'; +import { + UsersTable, + UsersTableProps, +} from '~/components/admin/users/users_table'; +import { ContentLayout } from '~/layouts/content_layout'; + +function AdminDashboardPage(props: UsersTableProps) { + return ; +} + +AdminDashboardPage.layout = (page: ReactNode) => ( + +); +export default AdminDashboardPage; diff --git a/inertia/types/app.d.ts b/inertia/types/app.d.ts index ddeb579..4bafdba 100644 --- a/inertia/types/app.d.ts +++ b/inertia/types/app.d.ts @@ -8,13 +8,14 @@ type CommonBase = { type User = CommonBase & { email: string; - name: string; - nickName: string; fullname: string; avatarUrl: string; isAdmin: boolean; + lastSeenAt: string; }; +type Users = User[]; + type UserWithCollections = User & { collections: Collection[]; };