From 07b7fed48024d0a2c8217411809b0ab649cc0521 Mon Sep 17 00:00:00 2001 From: louiscklaw Date: Wed, 16 Apr 2025 02:38:53 +0800 Subject: [PATCH] update build ok, --- .../lesson-categories-sample-data.tsx | 56 ++++ .../app/dashboard/lesson_categories/page.tsx | 90 +++++++ .../dashboard/lesson_category/interfaces.ts | 10 + .../lesson-categories-filters.tsx | 244 ++++++++++++++++++ .../lesson-categories-pagination.tsx | 30 +++ .../lesson-categories-selection-context.tsx | 47 ++++ .../lesson-categories-table.tsx | 129 +++++++++ 7 files changed, 606 insertions(+) create mode 100644 002_source/cms/src/app/dashboard/lesson_categories/lesson-categories-sample-data.tsx create mode 100644 002_source/cms/src/app/dashboard/lesson_categories/page.tsx create mode 100644 002_source/cms/src/components/dashboard/lesson_category/interfaces.ts create mode 100644 002_source/cms/src/components/dashboard/lesson_category/lesson-categories-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/lesson_category/lesson-categories-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/lesson_category/lesson-categories-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/lesson_category/lesson-categories-table.tsx diff --git a/002_source/cms/src/app/dashboard/lesson_categories/lesson-categories-sample-data.tsx b/002_source/cms/src/app/dashboard/lesson_categories/lesson-categories-sample-data.tsx new file mode 100644 index 0000000..f44c56d --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_categories/lesson-categories-sample-data.tsx @@ -0,0 +1,56 @@ +import { dayjs } from '@/lib/dayjs'; +// import type { LessonCategory } from '@/components/dashboard/lesson_category/lesson-categories-table'; +import type { LessonCategory } from '@/components/dashboard/lesson_category/interfaces'; + +export const lessonCategoriesSampleData = [ + { + id: 'USR-005', + name: 'Fran Perez', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + }, + { + id: 'USR-003', + name: 'Carson Darrin', + avatar: '/assets/avatar-3.png', + email: 'carson.darrin@domain.com', + phone: '(715) 278-5041', + quota: 10, + status: 'blocked', + createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-002', + name: 'Siegbert Gottfried', + avatar: '/assets/avatar-2.png', + email: 'siegbert.gottfried@domain.com', + phone: '(603) 766-0431', + quota: 0, + status: 'pending', + createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-001', + name: 'Miron Vitold', + avatar: '/assets/avatar-1.png', + email: 'miron.vitold@domain.com', + phone: '(425) 434-5535', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), + }, +] satisfies LessonCategory[]; diff --git a/002_source/cms/src/app/dashboard/lesson_categories/page.tsx b/002_source/cms/src/app/dashboard/lesson_categories/page.tsx new file mode 100644 index 0000000..02c1520 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_categories/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import * as React from 'react'; +import type { Metadata } from 'next'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; + +import { config } from '@/config'; +import type { LessonCategory } from '@/components/dashboard/lesson_category/interfaces'; +import { LessonCategoriesFilters } from '@/components/dashboard/lesson_category/lesson-categories-filters'; +import type { Filters } from '@/components/dashboard/lesson_category/lesson-categories-filters'; +import { LessonCategoriesPagination } from '@/components/dashboard/lesson_category/lesson-categories-pagination'; +import { LessonCategoriesSelectionProvider } from '@/components/dashboard/lesson_category/lesson-categories-selection-context'; +import { LessonCategoriesTable } from '@/components/dashboard/lesson_category/lesson-categories-table'; + +import { lessonCategoriesSampleData } from './lesson-categories-sample-data'; + +interface PageProps { + searchParams: { + email?: string; + phone?: string; + sortDir?: 'asc' | 'desc'; + status?: string; + name?: string; + visible?: string; + type?: string; + // + }; +} + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + const { email, phone, sortDir, status } = searchParams; + + const sortedLessonCategories = applySort(lessonCategoriesSampleData, sortDir); + const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status }); + + return ( + + Page + + ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: LessonCategory[], sortDir: 'asc' | 'desc' | undefined): LessonCategory[] { + return row.sort((a, b) => { + if (sortDir === 'asc') { + return a.createdAt.getTime() - b.createdAt.getTime(); + } + + return b.createdAt.getTime() - a.createdAt.getTime(); + }); +} + +function applyFilters(row: LessonCategory[], { email, phone, status }: Filters): LessonCategory[] { + return row.filter((item) => { + if (email) { + if (!item.email?.toLowerCase().includes(email.toLowerCase())) { + return false; + } + } + + if (phone) { + if (!item.phone?.toLowerCase().includes(phone.toLowerCase())) { + return false; + } + } + + if (status) { + if (item.status !== status) { + return false; + } + } + + return true; + }); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/interfaces.ts b/002_source/cms/src/components/dashboard/lesson_category/interfaces.ts new file mode 100644 index 0000000..72c8441 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/interfaces.ts @@ -0,0 +1,10 @@ +export interface LessonCategory { + id: string; + name: string; + avatar?: string; + email: string; + phone?: string; + quota: number; + status: 'pending' | 'active' | 'blocked'; + createdAt: Date; +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-filters.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-filters.tsx new file mode 100644 index 0000000..ab380d4 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-filters.tsx @@ -0,0 +1,244 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Typography from '@mui/material/Typography'; + +import { paths } from '@/paths'; +import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button'; +import { Option } from '@/components/core/option'; + +import { useLessonCategoriesSelection } from './lesson-categories-selection-context'; + +// The tabs should be generated using API data. +const tabs = [ + { label: 'All', value: '', count: 5 }, + { label: 'Active', value: 'active', count: 3 }, + { label: 'Pending', value: 'pending', count: 1 }, + { label: 'Blocked', value: 'blocked', count: 1 }, +] as const; + +export interface Filters { + email?: string; + phone?: string; + status?: string; +} + +export type SortDir = 'asc' | 'desc'; + +export interface LessonCategoriesFiltersProps { + filters?: Filters; + sortDir?: SortDir; +} + +export function LessonCategoriesFilters({ + filters = {}, + sortDir = 'desc', +}: LessonCategoriesFiltersProps): React.JSX.Element { + const { email, phone, status } = filters; + + const router = useRouter(); + + const selection = useLessonCategoriesSelection(); + + const updateSearchParams = React.useCallback( + (newFilters: Filters, newSortDir: SortDir): void => { + const searchParams = new URLSearchParams(); + + if (newSortDir === 'asc') { + searchParams.set('sortDir', newSortDir); + } + + if (newFilters.status) { + searchParams.set('status', newFilters.status); + } + + if (newFilters.email) { + searchParams.set('email', newFilters.email); + } + + if (newFilters.phone) { + searchParams.set('phone', newFilters.phone); + } + + router.push(`${paths.dashboard.lesson_categories.list}?${searchParams.toString()}`); + }, + [router] + ); + + const handleClearFilters = React.useCallback(() => { + updateSearchParams({}, sortDir); + }, [updateSearchParams, sortDir]); + + const handleStatusChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, status: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleEmailChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, email: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handlePhoneChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, phone: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleSortChange = React.useCallback( + (event: SelectChangeEvent) => { + updateSearchParams(filters, event.target.value as SortDir); + }, + [updateSearchParams, filters] + ); + + const hasFilters = status || email || phone; + + return ( +
+ + {tabs.map((tab) => ( + } + iconPosition="end" + key={tab.value} + label={tab.label} + sx={{ minHeight: 'auto' }} + tabIndex={0} + value={tab.value} + /> + ))} + + + + + { + handleEmailChange(value as string); + }} + onFilterDelete={() => { + handleEmailChange(); + }} + popover={} + value={email} + /> + { + handlePhoneChange(value as string); + }} + onFilterDelete={() => { + handlePhoneChange(); + }} + popover={} + value={phone} + /> + {hasFilters ? : null} + + {selection.selectedAny ? ( + + + {selection.selected.size} selected + + + + ) : null} + + +
+ ); +} + +function EmailFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function PhoneFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-pagination.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-pagination.tsx new file mode 100644 index 0000000..a78bea3 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-pagination.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface LessonCategoriesPaginationProps { + count: number; + page: number; +} + +export function LessonCategoriesPagination({ count, page }: LessonCategoriesPaginationProps): React.JSX.Element { + // You should implement the pagination using a similar logic as the filters. + // Note that when page change, you should keep the filter search params. + + return ( + + ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-selection-context.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-selection-context.tsx new file mode 100644 index 0000000..af5f802 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-selection-context.tsx @@ -0,0 +1,47 @@ +'use client'; + +import * as React from 'react'; + +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; +// import type { LessonCategory } from './lesson-categories-table'; +import type { LessonCategory } from '@/components/dashboard/lesson_category/interfaces'; + +function noop(): void { + return undefined; +} + +export interface LessonCategoriesSelectionContextValue extends Selection {} + +export const LessonCategoriesSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface LessonCategoriesSelectionProviderProps { + children: React.ReactNode; + lessonCategories: LessonCategory[]; +} + +export function LessonCategoriesSelectionProvider({ + children, + lessonCategories = [], +}: LessonCategoriesSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => lessonCategories.map((customer) => customer.id), [lessonCategories]); + const selection = useSelection(customerIds); + + return ( + + {children} + + ); +} + +export function useLessonCategoriesSelection(): LessonCategoriesSelectionContextValue { + return React.useContext(LessonCategoriesSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-table.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-table.tsx new file mode 100644 index 0000000..12fdaed --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-table.tsx @@ -0,0 +1,129 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import LinearProgress from '@mui/material/LinearProgress'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock'; +import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; + +import { paths } from '@/paths'; +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; + +import { LessonCategory } from './interfaces'; +import { useLessonCategoriesSelection } from './lesson-categories-selection-context'; + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {' '} +
+ + {row.name} + + + {row.email} + +
+
+ ), + name: 'Name', + width: '250px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)} + + + ), + name: 'Quota', + width: '250px', + }, + { field: 'phone', name: 'Phone number', width: '150px' }, + { + formatter(row) { + return dayjs(row.createdAt).format('MMM D, YYYY h:mm A'); + }, + name: 'Created at', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + active: { label: 'Active', icon: }, + blocked: { label: 'Blocked', icon: }, + pending: { label: 'Pending', icon: }, + } as const; + const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null }; + + return ; + }, + name: 'Status', + width: '150px', + }, + { + formatter: (): React.JSX.Element => ( + + + + ), + name: 'Actions', + hideName: true, + width: '100px', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface LessonCategoriesTableProps { + rows: LessonCategory[]; +} + +export function LessonCategoriesTable({ rows }: LessonCategoriesTableProps): React.JSX.Element { + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLessonCategoriesSelection(); + + return ( + + + columns={columns} + onDeselectAll={deselectAll} + onDeselectOne={(_, row) => { + deselectOne(row.id); + }} + onSelectAll={selectAll} + onSelectOne={(_, row) => { + selectOne(row.id); + }} + rows={rows} + selectable + selected={selected} + /> + {!rows.length ? ( + + + No lesson categories found + + + ) : null} + + ); +}