diff --git a/002_source/cms/src/app/dashboard/students/SampleCustomers.tsx b/002_source/cms/src/app/dashboard/students/SampleCustomers.tsx
new file mode 100644
index 0000000..b991279
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/students/SampleCustomers.tsx
@@ -0,0 +1,157 @@
+// src/app/dashboard/customers/page.tsx
+'use client';
+import type { Customer } from '@/components/dashboard/customer/type.d';
+import { dayjs } from '@/lib/dayjs';
+
+export const SampleCustomers = [
+ {
+ 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(),
+ },
+ {
+ 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(),
+ },
+ {
+ 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 Customer[];
diff --git a/002_source/cms/src/app/dashboard/students/[customerId]/BasicDetailCard.tsx b/002_source/cms/src/app/dashboard/students/[customerId]/BasicDetailCard.tsx
new file mode 100644
index 0000000..fdb0dac
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/students/[customerId]/BasicDetailCard.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import * as React from 'react';
+import Avatar from '@mui/material/Avatar';
+import Card from '@mui/material/Card';
+import CardHeader from '@mui/material/CardHeader';
+import Chip from '@mui/material/Chip';
+import Divider from '@mui/material/Divider';
+import IconButton from '@mui/material/IconButton';
+import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
+import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
+import { useTranslation } from 'react-i18next';
+
+import { PropertyItem } from '@/components/core/property-item';
+import { PropertyList } from '@/components/core/property-list';
+// import { CrCategory } from '@/components/dashboard/cr/categories/type';
+import type { Customer } from '@/components/dashboard/customer/type.d';
+
+export default function BasicDetailCard({
+ lpModel: model,
+ handleEditClick,
+}: {
+ lpModel: Customer;
+ handleEditClick: () => void;
+}): React.JSX.Element {
+ const { t } = useTranslation();
+
+ return (
+
+ {
+ handleEditClick();
+ }}
+ >
+
+
+ }
+ avatar={
+
+
+
+ }
+ title={t('list.basic-details')}
+ />
+ }
+ orientation="vertical"
+ sx={{ '--PropertyItem-padding': '12px 24px' }}
+ >
+ {(
+ [
+ {
+ key: 'Customer ID',
+ value: (
+
+ ),
+ },
+ { key: 'Email', value: model.email },
+ { key: 'Quota', value: model.quota },
+ { key: 'Status', value: model.status },
+ ] satisfies { key: string; value: React.ReactNode }[]
+ ).map(
+ (item): React.JSX.Element => (
+
+ )
+ )}
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/students/[customerId]/TitleCard.tsx b/002_source/cms/src/app/dashboard/students/[customerId]/TitleCard.tsx
new file mode 100644
index 0000000..971bfa7
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/students/[customerId]/TitleCard.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import * as React from 'react';
+import { Button } from '@mui/material';
+import Avatar from '@mui/material/Avatar';
+import Chip from '@mui/material/Chip';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
+import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
+import { useTranslation } from 'react-i18next';
+import type { Customer } from '@/components/dashboard/customer/type.d';
+
+// import type { CrCategory } from '@/components/dashboard/cr/categories/type';
+
+function getImageUrlFrRecord(record: Customer): string {
+ // TODO: fix this
+ // `http://127.0.0.1:8090/api/files/${'record.collectionId'}/${'record.id'}/${'record.cat_image'}`;
+ return 'getImageUrlFrRecord(helloworld)';
+}
+
+export default function SampleTitleCard({ lpModel }: { lpModel: Customer }): React.JSX.Element {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+ {t('empty')}
+
+
+
+ {lpModel.email}
+
+ }
+ label={lpModel.quota}
+ size="small"
+ variant="outlined"
+ />
+
+
+ {lpModel.status}
+
+
+
+
+ }
+ variant="contained"
+ >
+ {t('list.action')}
+
+
+ >
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/students/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/students/[customerId]/page.tsx
new file mode 100644
index 0000000..6196a4a
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/students/[customerId]/page.tsx
@@ -0,0 +1,142 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { useParams, useRouter } from 'next/navigation';
+import SampleAddressCard from '@/app/dashboard/Sample/AddressCard';
+import { SampleNotifications } from '@/app/dashboard/Sample/Notifications';
+import SamplePaymentCard from '@/app/dashboard/Sample/PaymentCard';
+import SampleSecurityCard from '@/app/dashboard/Sample/SecurityCard';
+
+import Box from '@mui/material/Box';
+import Link from '@mui/material/Link';
+import Stack from '@mui/material/Stack';
+import Grid from '@mui/material/Unstable_Grid2';
+import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
+import type { RecordModel } from 'pocketbase';
+import { useTranslation } from 'react-i18next';
+
+import { config } from '@/config';
+import { paths } from '@/paths';
+import { logger } from '@/lib/default-logger';
+import { pb } from '@/lib/pb';
+import { toast } from '@/components/core/toaster';
+
+import ErrorDisplay from '@/components/dashboard/error';
+
+import { Notifications } from '@/components/dashboard/customer/notifications';
+import FormLoading from '@/components/loading';
+import BasicDetailCard from './BasicDetailCard';
+import TitleCard from './TitleCard';
+import { defaultCustomer } from '@/components/dashboard/customer/_constants';
+import type { Customer } from '@/components/dashboard/customer/type.d';
+import { COL_CUSTOMERS } from '@/constants';
+
+export default function Page(): React.JSX.Element {
+ const { t } = useTranslation();
+ const router = useRouter();
+ //
+ const { customerId } = useParams<{ customerId: string }>();
+ //
+ const [showLoading, setShowLoading] = React.useState(true);
+ const [showError, setShowError] = React.useState({ show: false, detail: '' });
+ //
+ const [showLessonCategory, setShowLessonCategory] = React.useState(defaultCustomer);
+
+ function handleEditClick(): void {
+ router.push(paths.dashboard.customers.edit(showLessonCategory.id));
+ }
+
+ React.useEffect(() => {
+ if (customerId) {
+ pb.collection(COL_CUSTOMERS)
+ .getOne(customerId)
+ .then((model: RecordModel) => {
+ setShowLessonCategory({ ...defaultCustomer, ...model });
+ })
+ .catch((err) => {
+ logger.error(err);
+ toast(t('list.error'));
+
+ setShowError({ show: true, detail: JSON.stringify(err) });
+ })
+ .finally(() => {
+ setShowLoading(false);
+ });
+ }
+ }, [customerId]);
+
+ // return <>{JSON.stringify({ showError, showLessonCategory }, null, 2)}>;
+
+ if (showLoading) return ;
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/students/_GUIDELINES.md b/002_source/cms/src/app/dashboard/students/_GUIDELINES.md
new file mode 100644
index 0000000..1353901
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/students/_GUIDELINES.md
@@ -0,0 +1,49 @@
+# GUIDELINES
+
+this folder is part of nextjs typescript project and containing page definition for `Customer` / `Customers` record:
+
+- list (./page.tsx)
+- view (./[customerId]/page.tsx)
+- create (./create/page.tsx)
+- edit (./[customerId]/page.tsx)
+- translation provided by react-i18next
+
+the `@` sign refer to `/002_source/002_source/cms/src`
+
+## Assumption and Requirements
+
+- let one file contains one component only.
+- type information defined in `/002_source/cms/src/db/Customers/type.d.tsx`
+- it mainly consume the db drivers `Customres` in `/002_source/cms/src/db/Customers`
+
+simple template:
+
+```typescript
+// src/app/dashboard/customers/page.tsx
+'use client';
+
+// RULES:
+// contains list page for customers (Customers)
+// contain definition to collection only
+//
+import statements here ...
+...
+...
+...
+
+export default function Page({ searchParams }: PageProps): React.JSX.Element {
+ // ...content
+ // use direct return of pb.collection (e.g. return pb.collection(xxx))
+
+ return (
+ <>
+ {* page content *}
+ >
+ )
+}
+
+
+interface PageProps {
+ searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string };
+}
+```
diff --git a/002_source/cms/src/app/dashboard/students/create/page.tsx b/002_source/cms/src/app/dashboard/students/create/page.tsx
new file mode 100644
index 0000000..a0460ab
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/students/create/page.tsx
@@ -0,0 +1,48 @@
+import * as React from 'react';
+import type { Metadata } from 'next';
+import RouterLink from 'next/link';
+import Box from '@mui/material/Box';
+import Link from '@mui/material/Link';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
+
+import { config } from '@/config';
+import { paths } from '@/paths';
+import { CustomerCreateForm } from '@/components/dashboard/customer/customer-create-form';
+
+export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
+
+export default function Page(): React.JSX.Element {
+ return (
+
+
+
+
+
+ Create customer
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/students/edit/[customerId]/_PROMPT.md b/002_source/cms/src/app/dashboard/students/edit/[customerId]/_PROMPT.md
new file mode 100644
index 0000000..abf4465
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/students/edit/[customerId]/_PROMPT.md
@@ -0,0 +1,11 @@
+# task
+
+## instruction
+
+with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/_helloworld/page.tsx`
+
+with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx`
+
+please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_categories/edit/page.tsx`
+
+please draft a tsx for showing error to user thanks,
diff --git a/002_source/cms/src/app/dashboard/students/edit/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/students/edit/[customerId]/page.tsx
new file mode 100644
index 0000000..e4e23bc
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/students/edit/[customerId]/page.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import Box from '@mui/material/Box';
+import Link from '@mui/material/Link';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
+import { useTranslation } from 'react-i18next';
+
+import { paths } from '@/paths';
+import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form';
+import { CustomerEditForm } from '@/components/dashboard/customer/customer-edit-form';
+
+export default function Page(): React.JSX.Element {
+ const { t } = useTranslation(['lp_categories']);
+
+ React.useEffect(() => {
+ // console.log('helloworld');
+ }, []);
+
+ return (
+
+
+
+
+
+
+ {t('edit.title')}
+
+
+
+ {t('edit.title')}
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/students/page.tsx b/002_source/cms/src/app/dashboard/students/page.tsx
new file mode 100644
index 0000000..4137d42
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/students/page.tsx
@@ -0,0 +1,256 @@
+// src/app/dashboard/customers/page.tsx
+'use client';
+
+// RULES:
+// contains list page for customers (Customers)
+// contain definition to collection only
+//
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { COL_CUSTOMERS } from '@/constants';
+import { LoadingButton } from '@mui/lab';
+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 type { ListResult, RecordModel } from 'pocketbase';
+
+import { config } from '@/config';
+import { CustomersFilters } from '@/components/dashboard/customer/customers-filters';
+// import type { Filters } from '@/components/dashboard/customer/customers-filters';
+import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination';
+import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context';
+import { CustomersTable } from '@/components/dashboard/customer/customers-table';
+import type { Customer, Filters } from '@/components/dashboard/customer/type.d';
+import { SampleCustomers } from './SampleCustomers';
+import { useTranslation } from 'react-i18next';
+
+import { paths } from '@/paths';
+import isDevelopment from '@/lib/check-is-development';
+import { logger } from '@/lib/default-logger';
+import { pb } from '@/lib/pb';
+import { toast } from '@/components/core/toaster';
+import ErrorDisplay from '@/components/dashboard/error';
+import { defaultCustomer } from '@/components/dashboard/customer/_constants';
+import FormLoading from '@/components/loading';
+
+export default function Page({ searchParams }: PageProps): React.JSX.Element {
+ const { t } = useTranslation(['customers']);
+ const router = useRouter();
+
+ const { email, phone, sortDir, status } = searchParams;
+
+ const [lessonCategoriesData, setLessonCategoriesData] = React.useState([]);
+ //
+
+ const [isLoadingAddPage, setIsLoadingAddPage] = React.useState(false);
+ const [showLoading, setShowLoading] = React.useState(true);
+ const [showError, setShowError] = React.useState({ show: false, detail: '' });
+ //
+ const [rowsPerPage, setRowsPerPage] = React.useState(5);
+ //
+ const [f, setF] = React.useState([]);
+ const [currentPage, setCurrentPage] = React.useState(0);
+ //
+ const [recordCount, setRecordCount] = React.useState(0);
+ const [listOption, setListOption] = React.useState({});
+ const [listSort, setListSort] = React.useState({});
+
+ //
+ // const sortedCustomers = applySort(SampleCustomers, sortDir);
+ // const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status });
+
+ //
+ const reloadRows = async (): Promise => {
+ try {
+ const models: ListResult = await pb
+ .collection(COL_CUSTOMERS)
+ .getList(currentPage + 1, rowsPerPage, listOption);
+ const { items, totalItems } = models;
+ const tempLessonTypes: Customer[] = items.map((lt) => {
+ return { ...defaultCustomer, ...lt };
+ });
+
+ setLessonCategoriesData(tempLessonTypes);
+ setRecordCount(totalItems);
+ setF(tempLessonTypes);
+ // console.log({ currentPage, f });
+ } catch (error) {
+ //
+ logger.error(error);
+ setShowError({
+ //
+ show: true,
+ detail: JSON.stringify(error, null, 2),
+ });
+ } finally {
+ setShowLoading(false);
+ }
+ };
+
+ const [lastListOption, setLastListOption] = React.useState({});
+ const isFirstRun = React.useRef(false);
+ React.useEffect(() => {
+ if (!isFirstRun.current) {
+ isFirstRun.current = true;
+ } else if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) {
+ // reset page number as tab changes
+ setLastListOption(listOption);
+ setCurrentPage(0);
+ void reloadRows();
+ } else {
+ void reloadRows();
+ }
+ }, [currentPage, rowsPerPage, listOption]);
+
+ React.useEffect(() => {
+ let tempFilter = [],
+ tempSortDir = '';
+
+ if (status) {
+ tempFilter.push(`status = "${status}"`);
+ }
+
+ if (sortDir) {
+ tempSortDir = `-created`;
+ }
+
+ if (email) {
+ tempFilter.push(`email ~ "%${email}%"`);
+ }
+ if (phone) {
+ tempFilter.push(`phone ~ "%${phone}%"`);
+ }
+
+ let preFinalListOption = {};
+ if (tempFilter.length > 0) {
+ preFinalListOption = { filter: tempFilter.join(' && ') };
+ }
+ if (tempSortDir.length > 0) {
+ preFinalListOption = { ...preFinalListOption, sort: tempSortDir };
+ }
+ setListOption(preFinalListOption);
+ // setListOption({
+ // filter: tempFilter.join(' && '),
+ // sort: tempSortDir,
+ // //
+ // });
+ }, [sortDir, email, phone, status]);
+
+ if (showLoading) return ;
+
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+ {t('list.title')}
+
+
+ {
+ setIsLoadingAddPage(true);
+ router.push(paths.dashboard.customers.create);
+ }}
+ startIcon={}
+ variant="contained"
+ >
+ {t('list.add')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {JSON.stringify(f, null, 2)}
+
+
+ );
+}
+
+// Sorting and filtering has to be done on the server.
+
+function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Customer[] {
+ 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: Customer[], { email, phone, status }: Filters): Customer[] {
+ 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;
+ });
+}
+
+interface PageProps {
+ searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string };
+}
diff --git a/002_source/cms/src/components/dashboard/student/_GUIDELINES.md b/002_source/cms/src/components/dashboard/student/_GUIDELINES.md
new file mode 100644
index 0000000..709449f
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/_GUIDELINES.md
@@ -0,0 +1,25 @@
+# GUIDELINES & KEY COMPONENTS
+
+- `_constants.ts` contains the constant for
+
+ - default value (defaultValue)
+ - empty value (emptyValue)
+
+- `customers-table.tsx`
+
+- `confirm-delete-modal.tsx` - delete modal component when click delete button on list
+
+ - `customers-filters.tsx`
+ - `customers-pagination.tsx`
+ - `email-filter-popover.tsx`
+ - `phone-filter-popover.tsx`
+ - `customers-selection-context.tsx`
+
+- `customer-create-form.tsx` - form to create a new customer
+- `customer-edit-form.tsx` - form to edit a existing customer
+
+- `type.d.tsx` - contains type definition
+
+- `notifications.tsx` - constants used for demonstration
+- `payments.tsx` - constants used for demonstration
+- `shipping-address.tsx` - constants used for demonstration
diff --git a/002_source/cms/src/components/dashboard/student/_constants.ts b/002_source/cms/src/components/dashboard/student/_constants.ts
new file mode 100644
index 0000000..503e5e3
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/_constants.ts
@@ -0,0 +1,21 @@
+// RULES:
+// default variable value for customer
+// empty valur for customer
+
+import { dayjs } from '@/lib/dayjs';
+import type { Customer } from './type.d';
+
+export const defaultCustomer: Customer = {
+ id: '',
+ name: '',
+ avatar: undefined,
+ email: '',
+ phone: undefined,
+ quota: 0,
+ status: 'pending',
+ createdAt: dayjs().toDate(),
+};
+
+export const emptyLpCategory: Customer = {
+ ...defaultCustomer,
+};
diff --git a/002_source/cms/src/components/dashboard/student/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/student/confirm-delete-modal.tsx
new file mode 100644
index 0000000..ba9aad5
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/confirm-delete-modal.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { COL_LESSON_TYPES } from '@/constants';
+import deleteQuizLPCategories from '@/db/QuizListenings/Delete';
+import { LoadingButton } from '@mui/lab';
+import { Button, Container, Modal, Paper } from '@mui/material';
+import Avatar from '@mui/material/Avatar';
+import Box from '@mui/material/Box';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import { Note as NoteIcon } from '@phosphor-icons/react/dist/ssr/Note';
+import PocketBase from 'pocketbase';
+import { useTranslation } from 'react-i18next';
+
+import { logger } from '@/lib/default-logger';
+import { toast } from '@/components/core/toaster';
+import { deleteCustomer } from '@/db/Customers/Delete';
+
+export default function ConfirmDeleteModal({
+ open,
+ setOpen,
+ idToDelete,
+ reloadRows,
+}: {
+ open: boolean;
+ setOpen: (b: boolean) => void;
+ idToDelete: string;
+ reloadRows: () => void;
+}): React.JSX.Element {
+ const { t } = useTranslation();
+
+ // const handleClose = () => setOpen(false);
+ function handleClose(): void {
+ setOpen(false);
+ }
+
+ const [isDeleteing, setIsDeleteing] = React.useState(false);
+ const style = {
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ };
+
+ function handleUserConfirmDelete(): void {
+ if (idToDelete) {
+ setIsDeleteing(true);
+
+ // RULES: delete
+ deleteCustomer(idToDelete)
+ .then(() => {
+ reloadRows();
+ handleClose();
+ toast(t('delete.success'));
+ })
+ .catch((err) => {
+ // console.error(err)
+ logger.error(err);
+ toast(t('delete.error'));
+ })
+ .finally(() => {
+ setIsDeleteing(false);
+ });
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {t('Delete Lesson Type ?')}
+
+ {t('Are you sure you want to delete lesson type ?')}
+
+
+
+
+ {
+ handleUserConfirmDelete();
+ }}
+ loading={isDeleteing}
+ >
+ {t('Delete')}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/customer-create-form.tsx b/002_source/cms/src/components/dashboard/student/customer-create-form.tsx
new file mode 100644
index 0000000..046f16e
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/customer-create-form.tsx
@@ -0,0 +1,529 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { useRouter } from 'next/navigation';
+import { zodResolver } from '@hookform/resolvers/zod';
+import Avatar from '@mui/material/Avatar';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Card from '@mui/material/Card';
+import CardActions from '@mui/material/CardActions';
+import CardContent from '@mui/material/CardContent';
+import Checkbox from '@mui/material/Checkbox';
+import Divider from '@mui/material/Divider';
+import FormControl from '@mui/material/FormControl';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import FormHelperText from '@mui/material/FormHelperText';
+import InputLabel from '@mui/material/InputLabel';
+import OutlinedInput from '@mui/material/OutlinedInput';
+import Select from '@mui/material/Select';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import Grid from '@mui/material/Unstable_Grid2';
+import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
+import { Controller, useForm } from 'react-hook-form';
+import { z as zod } from 'zod';
+
+import { paths } from '@/paths';
+import { logger } from '@/lib/default-logger';
+import { Option } from '@/components/core/option';
+import { toast } from '@/components/core/toaster';
+import { createCustomer } from '@/db/Customers/Create';
+import isDevelopment from '@/lib/check-is-development';
+
+function fileToBase64(file: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => {
+ resolve(reader.result as string);
+ };
+ reader.onerror = () => {
+ reject(new Error('Error converting file to base64'));
+ };
+ });
+}
+
+const schema = zod.object({
+ avatar: zod.string().optional(),
+ name: zod.string().min(1, 'Name is required').max(255),
+ email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
+ phone: zod.string().min(1, 'Phone is required').max(15),
+ company: zod.string().max(255),
+ billingAddress: zod.object({
+ country: zod.string().min(1, 'Country is required').max(255),
+ state: zod.string().min(1, 'State is required').max(255),
+ city: zod.string().min(1, 'City is required').max(255),
+ zipCode: zod.string().min(1, 'Zip code is required').max(255),
+ line1: zod.string().min(1, 'Street line 1 is required').max(255),
+ line2: zod.string().max(255).optional(),
+ }),
+ taxId: zod.string().max(255).optional(),
+ timezone: zod.string().min(1, 'Timezone is required').max(255),
+ language: zod.string().min(1, 'Language is required').max(255),
+ currency: zod.string().min(1, 'Currency is required').max(255),
+});
+
+type Values = zod.infer;
+
+const defaultValues = {
+ avatar: '',
+ name: 'new name',
+ email: '123@123.com',
+ phone: '91234567',
+ company: '',
+ billingAddress: {
+ country: 'US',
+ state: '00000',
+ city: 'NY',
+ zipCode: '00000',
+ line1: 'test line 1',
+ line2: 'test line 2',
+ },
+ taxId: '12345',
+ timezone: 'new_york',
+ language: 'en',
+ currency: 'USD',
+} satisfies Values;
+
+export function CustomerCreateForm(): React.JSX.Element {
+ const router = useRouter();
+
+ const {
+ control,
+ handleSubmit,
+ formState: { errors },
+ setValue,
+ watch,
+ } = useForm({ defaultValues, resolver: zodResolver(schema) });
+
+ const onSubmit = React.useCallback(
+ async (values: Values): Promise => {
+ try {
+ // Use standard create method from db/Customers/Create
+ const record = await createCustomer(values);
+ toast.success('Customer created');
+ router.push(paths.dashboard.customers.details(record.id));
+ } catch (err) {
+ logger.error(err);
+ toast.error('Failed to create customer');
+ }
+ },
+ [router]
+ );
+
+ const avatarInputRef = React.useRef(null);
+ const avatar = watch('avatar');
+
+ const handleAvatarChange = React.useCallback(
+ async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+
+ if (file) {
+ const url = await fileToBase64(file);
+ setValue('avatar', url);
+ }
+ },
+ [setValue]
+ );
+
+ return (
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/customer-edit-form.tsx b/002_source/cms/src/components/dashboard/student/customer-edit-form.tsx
new file mode 100644
index 0000000..8e9d211
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/customer-edit-form.tsx
@@ -0,0 +1,604 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { useParams, useRouter } from 'next/navigation';
+//
+import { COL_CUSTOMERS } from '@/constants';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { LoadingButton } from '@mui/lab';
+//
+import Avatar from '@mui/material/Avatar';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Card from '@mui/material/Card';
+import CardActions from '@mui/material/CardActions';
+import CardContent from '@mui/material/CardContent';
+import Divider from '@mui/material/Divider';
+import FormControl from '@mui/material/FormControl';
+import FormHelperText from '@mui/material/FormHelperText';
+import InputLabel from '@mui/material/InputLabel';
+import MenuItem from '@mui/material/MenuItem';
+import OutlinedInput from '@mui/material/OutlinedInput';
+import Select from '@mui/material/Select';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import Grid from '@mui/material/Unstable_Grid2';
+//
+import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
+//
+import { Controller, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { z as zod } from 'zod';
+
+import { paths } from '@/paths';
+import { logger } from '@/lib/default-logger';
+import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
+import { pb } from '@/lib/pb';
+import { toast } from '@/components/core/toaster';
+import FormLoading from '@/components/loading';
+
+// import ErrorDisplay from '../../error';
+import ErrorDisplay from '../error';
+import isDevelopment from '@/lib/check-is-development';
+
+// TODO: review this
+const schema = zod.object({
+ name: zod.string().min(1, 'Name is required').max(255),
+ email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
+ phone: zod.string().min(1, 'Phone is required').max(25),
+ company: zod.string().max(255).optional(),
+ billingAddress: zod.object({
+ country: zod.string().min(1, 'Country is required').max(255),
+ state: zod.string().min(1, 'State is required').max(255),
+ city: zod.string().min(1, 'City is required').max(255),
+ zipCode: zod.string().min(1, 'Zip code is required').max(255),
+ line1: zod.string().min(1, 'Street line 1 is required').max(255),
+ line2: zod.string().max(255).optional(),
+ }),
+ taxId: zod.string().max(255).optional(),
+ timezone: zod.string().min(1, 'Timezone is required').max(255),
+ language: zod.string().min(1, 'Language is required').max(255),
+ currency: zod.string().min(1, 'Currency is required').max(255),
+ avatar: zod.string().optional(),
+});
+
+type Values = zod.infer;
+
+const defaultValues = {
+ name: '',
+ email: '',
+ phone: '',
+ company: '',
+ billingAddress: {
+ country: '',
+ state: '',
+ city: '',
+ zipCode: '',
+ line1: '',
+ line2: '',
+ },
+ taxId: '',
+ timezone: '',
+ language: '',
+ currency: '',
+ avatar: '',
+} satisfies Values;
+
+export function CustomerEditForm(): React.JSX.Element {
+ const router = useRouter();
+ const { t } = useTranslation(['lp_categories']);
+
+ const { customerId } = useParams<{ customerId: string }>();
+ //
+ const [isUpdating, setIsUpdating] = React.useState(false);
+ const [showLoading, setShowLoading] = React.useState(false);
+ //
+ const [showError, setShowError] = React.useState({ show: false, detail: '' });
+
+ const {
+ control,
+ handleSubmit,
+ formState: { errors },
+ setValue,
+ reset,
+ watch,
+ } = useForm({ defaultValues, resolver: zodResolver(schema) });
+
+ const onSubmit = React.useCallback(
+ async (values: Values): Promise => {
+ setIsUpdating(true);
+
+ const updateData = {
+ name: values.name,
+ email: values.email,
+ phone: values.phone,
+ company: values.company,
+ billingAddress: values.billingAddress,
+ taxId: values.taxId,
+ timezone: values.timezone,
+ language: values.language,
+ currency: values.currency,
+ avatar: values.avatar ? await base64ToFile(values.avatar) : null,
+ };
+
+ try {
+ await pb.collection(COL_CUSTOMERS).update(customerId, updateData);
+ toast.success('Customer updated successfully');
+ router.push(paths.dashboard.customers.list);
+ } catch (error) {
+ logger.error(error);
+ toast.error('Failed to update customer');
+ } finally {
+ setIsUpdating(false);
+ }
+ },
+ [customerId, router]
+ );
+
+ const avatarInputRef = React.useRef(null);
+ const avatar = watch('avatar');
+
+ const handleAvatarChange = React.useCallback(
+ async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+
+ if (file) {
+ const url = await fileToBase64(file);
+ setValue('avatar', url);
+ }
+ },
+ [setValue]
+ );
+
+ // TODO: need to align with save form
+ // use trycatch
+ const [textDescription, setTextDescription] = React.useState('');
+ const [textRemarks, setTextRemarks] = React.useState('');
+
+ // load existing data when user arrive
+ const loadExistingData = React.useCallback(
+ async (id: string) => {
+ setShowLoading(true);
+
+ try {
+ const result = await pb.collection(COL_CUSTOMERS).getOne(id);
+ reset({ ...defaultValues, ...result });
+ console.log({ result });
+
+ if (result.avatar_file) {
+ const fetchResult = await fetch(
+ `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar_file}`
+ );
+ const blob = await fetchResult.blob();
+ const url = await fileToBase64(blob);
+ setValue('avatar', url);
+ }
+ } catch (error) {
+ logger.error(error);
+ toast.error('Failed to load customer data');
+ setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
+ } finally {
+ setShowLoading(false);
+ }
+ },
+ [reset, setValue]
+ );
+
+ React.useEffect(() => {
+ void loadExistingData(customerId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [customerId]);
+
+ if (showLoading) return ;
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/customers-filters.tsx b/002_source/cms/src/components/dashboard/student/customers-filters.tsx
new file mode 100644
index 0000000..56a7e4f
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/customers-filters.tsx
@@ -0,0 +1,246 @@
+'use client';
+// RULES:
+// T.B.A.
+//
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { getAllCustomersCount } from '@/db/Customers/GetAllCount';
+
+import Button from '@mui/material/Button';
+import Chip from '@mui/material/Chip';
+import Divider from '@mui/material/Divider';
+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 { useTranslation } from 'react-i18next';
+
+import { paths } from '@/paths';
+import { FilterButton } from '@/components/core/filter-button';
+import { Option } from '@/components/core/option';
+
+import { useCustomersSelection } from './customers-selection-context';
+import GetBlockedCount from '@/db/Customers/GetBlockedCount';
+import GetPendingCount from '@/db/Customers/GetPendingCount';
+import GetActiveCount from '@/db/Customers/GetActiveCount';
+import PhoneFilterPopover from './phone-filter-popover';
+import EmailFilterPopover from './email-filter-popover';
+import type { CustomersFiltersProps, Filters, SortDir } from './type.d';
+
+export function CustomersFilters({
+ filters = {},
+ sortDir = 'desc',
+ fullData,
+}: CustomersFiltersProps): React.JSX.Element {
+ const { t } = useTranslation();
+
+ const { email, phone, status } = filters;
+
+ const [totalCount, setTotalCount] = React.useState(0);
+ const [activeCount, setActiveCount] = React.useState(0);
+ const [pendingCount, setPendingCount] = React.useState(0);
+ const [blockedCount, setBlockedCount] = React.useState(0);
+
+ const router = useRouter();
+
+ const selection = useCustomersSelection();
+
+ // function getVisible(): number {
+ // return fullData.reduce((count, item: CrQuestion) => {
+ // return item.visible === 'visible' ? count + 1 : count;
+ // }, 0);
+ // }
+
+ // function getHidden(): number {
+ // return fullData.reduce((count, item: CrQuestion) => {
+ // return item.visible === 'hidden' ? count + 1 : count;
+ // }, 0);
+ // }
+
+ // The tabs should be generated using API data.
+ const tabs = [
+ { label: 'All', value: '', count: totalCount },
+ { label: 'Active', value: 'active', count: activeCount },
+ { label: 'Pending', value: 'pending', count: pendingCount },
+ { label: 'Blocked', value: 'blocked', count: blockedCount },
+ ] as const;
+
+ 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.customers.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;
+
+ React.useEffect(() => {
+ const fetchCount = async (): Promise => {
+ try {
+ const tc = await getAllCustomersCount();
+ setTotalCount(tc);
+
+ const bc = await GetBlockedCount();
+ setBlockedCount(bc);
+ const pc = await GetPendingCount();
+ setPendingCount(pc);
+ const ac = await GetActiveCount();
+ setActiveCount(ac);
+ } catch (error) {
+ //
+ }
+ };
+ void fetchCount();
+ }, []);
+
+ 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}
+
+
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/customers-pagination.tsx b/002_source/cms/src/components/dashboard/student/customers-pagination.tsx
new file mode 100644
index 0000000..a7a89b6
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/customers-pagination.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import * as React from 'react';
+import TablePagination from '@mui/material/TablePagination';
+
+function noop(): void {
+ return undefined;
+}
+
+interface CustomersPaginationProps {
+ count: number;
+ page: number;
+ //
+ setPage: (page: number) => void;
+ setRowsPerPage: (page: number) => void;
+ rowsPerPage: number;
+}
+
+export function CustomersPagination({
+ count,
+ page,
+ //
+ setPage,
+ setRowsPerPage,
+ rowsPerPage,
+}: CustomersPaginationProps): 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.
+ const handleChangePage = (event: unknown, newPage: number) => {
+ setPage(newPage);
+ };
+
+ const handleChangeRowsPerPage = (event: React.ChangeEvent) => {
+ setRowsPerPage(parseInt(event.target.value));
+ // console.log(parseInt(event.target.value));
+ };
+
+ return (
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/customers-selection-context.tsx b/002_source/cms/src/components/dashboard/student/customers-selection-context.tsx
new file mode 100644
index 0000000..72cd8ba
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/customers-selection-context.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import * as React from 'react';
+
+import { useSelection } from '@/hooks/use-selection';
+import type { Selection } from '@/hooks/use-selection';
+
+import type { Customer } from './type.d';
+
+function noop(): void {
+ return undefined;
+}
+
+export interface CustomersSelectionContextValue extends Selection {}
+
+export const CustomersSelectionContext = React.createContext({
+ deselectAll: noop,
+ deselectOne: noop,
+ selectAll: noop,
+ selectOne: noop,
+ selected: new Set(),
+ selectedAny: false,
+ selectedAll: false,
+});
+
+interface CustomersSelectionProviderProps {
+ children: React.ReactNode;
+ customers: Customer[];
+}
+
+export function CustomersSelectionProvider({
+ children,
+ customers = [],
+}: CustomersSelectionProviderProps): React.JSX.Element {
+ const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]);
+ const selection = useSelection(customerIds);
+
+ return {children};
+}
+
+export function useCustomersSelection(): CustomersSelectionContextValue {
+ return React.useContext(CustomersSelectionContext);
+}
diff --git a/002_source/cms/src/components/dashboard/student/customers-table.tsx b/002_source/cms/src/components/dashboard/student/customers-table.tsx
new file mode 100644
index 0000000..f064433
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/customers-table.tsx
@@ -0,0 +1,222 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { LoadingButton } from '@mui/lab';
+import Avatar from '@mui/material/Avatar';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+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 { Images as ImagesIcon } from '@phosphor-icons/react/dist/ssr/Images';
+import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus';
+import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
+import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple';
+import { useTranslation } from 'react-i18next';
+import { toast } from 'sonner';
+
+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 ConfirmDeleteModal from './confirm-delete-modal';
+import { useCustomersSelection } from './customers-selection-context';
+import type { Customer } from './type.d';
+
+function columns(handleDeleteClick: (testId: string) => void): ColumnDef[] {
+ return [
+ {
+ 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: '150px',
+ },
+ { field: 'phone', name: 'Phone number', width: '150px' },
+
+ {
+ formatter: (row): React.JSX.Element => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+
+ 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(row) {
+ return dayjs(row.createdAt).format('MMM D, YYYY');
+ },
+ name: 'Created at',
+ width: '150px',
+ },
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+
+ {
+ handleDeleteClick(row.id);
+ }}
+ >
+
+
+
+ ),
+ name: 'Actions',
+ hideName: true,
+ align: 'right',
+ },
+ ];
+}
+
+export interface CustomersTableProps {
+ rows: Customer[];
+ reloadRows: () => void;
+}
+
+export function CustomersTable({ rows, reloadRows }: CustomersTableProps): React.JSX.Element {
+ const { t } = useTranslation(['customers']);
+ const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection();
+
+ const [idToDelete, setIdToDelete] = React.useState('');
+ const [open, setOpen] = React.useState(false);
+
+ function handleDeleteClick(testId: string): void {
+ setOpen(true);
+ setIdToDelete(testId);
+ }
+
+ return (
+
+
+
+ columns={columns(handleDeleteClick)}
+ onDeselectAll={deselectAll}
+ onDeselectOne={(_, row) => {
+ deselectOne(row.id);
+ }}
+ onSelectAll={selectAll}
+ onSelectOne={(_, row) => {
+ selectOne(row.id);
+ }}
+ rows={rows}
+ selectable
+ selected={selected}
+ />
+ {!rows.length ? (
+
+
+ {/* TODO: update this */}
+ {t('no-record-found')}
+
+
+ ) : null}
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/email-filter-popover.tsx b/002_source/cms/src/components/dashboard/student/email-filter-popover.tsx
new file mode 100644
index 0000000..2636af0
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/email-filter-popover.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import * as React from 'react';
+
+import Button from '@mui/material/Button';
+import FormControl from '@mui/material/FormControl';
+import OutlinedInput from '@mui/material/OutlinedInput';
+
+import { FilterPopover, useFilterContext } from '@/components/core/filter-button';
+
+// EmailFilterPopover -> email-filter-popover.tsx
+export default 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}
+ />
+
+
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/helloworld.tsx b/002_source/cms/src/components/dashboard/student/helloworld.tsx
new file mode 100644
index 0000000..3989cb1
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/helloworld.tsx
@@ -0,0 +1,3 @@
+const helloworld = 'helloworld';
+
+export { helloworld };
diff --git a/002_source/cms/src/components/dashboard/student/notifications.tsx b/002_source/cms/src/components/dashboard/student/notifications.tsx
new file mode 100644
index 0000000..a6c16bd
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/notifications.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import * as React from 'react';
+import Avatar from '@mui/material/Avatar';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Card from '@mui/material/Card';
+import CardContent from '@mui/material/CardContent';
+import CardHeader from '@mui/material/CardHeader';
+import Chip from '@mui/material/Chip';
+import Select from '@mui/material/Select';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple';
+
+import { dayjs } from '@/lib/dayjs';
+import { DataTable } from '@/components/core/data-table';
+import type { ColumnDef } from '@/components/core/data-table';
+import { Option } from '@/components/core/option';
+
+export interface Notification {
+ id: string;
+ type: string;
+ status: 'delivered' | 'pending' | 'failed';
+ createdAt: Date;
+}
+
+const columns = [
+ {
+ formatter: (row): React.JSX.Element => (
+
+ {row.type}
+
+ ),
+ name: 'Type',
+ width: '300px',
+ },
+ {
+ formatter: (row): React.JSX.Element => {
+ const mapping = {
+ delivered: { label: 'Delivered', color: 'success' },
+ pending: { label: 'Pending', color: 'warning' },
+ failed: { label: 'Failed', color: 'error' },
+ } as const;
+ const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' };
+
+ return ;
+ },
+ name: 'Status',
+ width: '200px',
+ },
+ {
+ formatter: (row): React.JSX.Element => (
+
+ {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
+
+ ),
+ name: 'Date',
+ align: 'right',
+ },
+] satisfies ColumnDef[];
+
+export interface NotificationsProps {
+ notifications: Notification[];
+}
+
+export function Notifications({ notifications }: NotificationsProps): React.JSX.Element {
+ return (
+
+
+
+
+ }
+ title="Notifications"
+ />
+
+
+
+
+
+ } variant="contained">
+ Send email
+
+
+
+
+
+ columns={columns} rows={notifications} />
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/payments.tsx b/002_source/cms/src/components/dashboard/student/payments.tsx
new file mode 100644
index 0000000..0420d32
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/payments.tsx
@@ -0,0 +1,138 @@
+'use client';
+
+import * as React from 'react';
+import Avatar from '@mui/material/Avatar';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Card from '@mui/material/Card';
+import CardContent from '@mui/material/CardContent';
+import CardHeader from '@mui/material/CardHeader';
+import Chip from '@mui/material/Chip';
+import Divider from '@mui/material/Divider';
+import Link from '@mui/material/Link';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
+import { ShoppingCartSimple as ShoppingCartSimpleIcon } from '@phosphor-icons/react/dist/ssr/ShoppingCartSimple';
+
+import { dayjs } from '@/lib/dayjs';
+import type { ColumnDef } from '@/components/core/data-table';
+import { DataTable } from '@/components/core/data-table';
+
+export interface Payment {
+ currency: string;
+ amount: number;
+ invoiceId: string;
+ status: 'pending' | 'completed' | 'canceled' | 'refunded';
+ createdAt: Date;
+}
+
+const columns = [
+ {
+ formatter: (row): React.JSX.Element => (
+
+ {new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)}
+
+ ),
+ name: 'Amount',
+ width: '200px',
+ },
+ {
+ formatter: (row): React.JSX.Element => {
+ const mapping = {
+ pending: { label: 'Pending', color: 'warning' },
+ completed: { label: 'Completed', color: 'success' },
+ canceled: { label: 'Canceled', color: 'error' },
+ refunded: { label: 'Refunded', color: 'error' },
+ } as const;
+ const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' };
+
+ return ;
+ },
+ name: 'Status',
+ width: '200px',
+ },
+ {
+ formatter: (row): React.JSX.Element => {
+ return {row.invoiceId};
+ },
+ name: 'Invoice ID',
+ width: '150px',
+ },
+ {
+ formatter: (row): React.JSX.Element => (
+
+ {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
+
+ ),
+ name: 'Date',
+ align: 'right',
+ },
+] satisfies ColumnDef[];
+
+export interface PaymentsProps {
+ ordersValue: number;
+ payments: Payment[];
+ refundsValue: number;
+ totalOrders: number;
+}
+
+export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element {
+ return (
+
+ }>
+ Create Payment
+
+ }
+ avatar={
+
+
+
+ }
+ title="Payments"
+ />
+
+
+
+ }
+ spacing={3}
+ sx={{ justifyContent: 'space-between', p: 2 }}
+ >
+
+
+ Total orders
+
+ {new Intl.NumberFormat('en-US').format(totalOrders)}
+
+
+
+ Orders value
+
+
+ {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)}
+
+
+
+
+ Refunds
+
+
+ {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)}
+
+
+
+
+
+
+ columns={columns} rows={payments} />
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/phone-filter-popover.tsx b/002_source/cms/src/components/dashboard/student/phone-filter-popover.tsx
new file mode 100644
index 0000000..08cbad7
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/phone-filter-popover.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import * as React from 'react';
+
+import Button from '@mui/material/Button';
+import FormControl from '@mui/material/FormControl';
+import OutlinedInput from '@mui/material/OutlinedInput';
+
+import { FilterPopover, useFilterContext } from '@/components/core/filter-button';
+
+// phone-filter-popover.tsx
+export default 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/student/shipping-address.tsx b/002_source/cms/src/components/dashboard/student/shipping-address.tsx
new file mode 100644
index 0000000..8793e5c
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/shipping-address.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react';
+import Button from '@mui/material/Button';
+import Card from '@mui/material/Card';
+import CardContent from '@mui/material/CardContent';
+import Chip from '@mui/material/Chip';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
+
+export interface Address {
+ id: string;
+ country: string;
+ state: string;
+ city: string;
+ zipCode: string;
+ street: string;
+ primary?: boolean;
+}
+
+export interface ShippingAddressProps {
+ address: Address;
+}
+
+export function ShippingAddress({ address }: ShippingAddressProps): React.ReactElement {
+ return (
+
+
+
+
+ {address.street},
+
+ {address.city}, {address.state}, {address.country},
+
+ {address.zipCode}
+
+
+ {address.primary ? : }
+ }>
+ Edit
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/student/type.d.tsx b/002_source/cms/src/components/dashboard/student/type.d.tsx
new file mode 100644
index 0000000..a9c7cde
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/student/type.d.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+export type SortDir = 'asc' | 'desc';
+
+export interface Customer {
+ id: string;
+ name: string;
+ avatar?: string;
+ email: string;
+ phone?: string;
+ quota: number;
+ status: 'pending' | 'active' | 'blocked';
+ createdAt: Date;
+ updatedAt?: Date;
+}
+
+export interface CreateFormProps {
+ name: string;
+ email: string;
+ phone?: string;
+ company?: string;
+ billingAddress?: {
+ country: string;
+ state: string;
+ city: string;
+ zipCode: string;
+ line1: string;
+ line2?: string;
+ };
+ taxId?: string;
+ timezone: string;
+ language: string;
+ currency: string;
+ avatar?: string;
+ // quota?: number;
+ // status?: 'pending' | 'active' | 'blocked';
+}
+
+export interface EditFormProps {
+ name: string;
+ email: string;
+ phone?: string;
+ company?: string;
+ billingAddress?: {
+ country: string;
+ state: string;
+ city: string;
+ zipCode: string;
+ line1: string;
+ line2?: string;
+ };
+ taxId?: string;
+ timezone: string;
+ language: string;
+ currency: string;
+ avatar?: string;
+ // quota?: number;
+ // status?: 'pending' | 'active' | 'blocked';
+}
+export interface CustomersFiltersProps {
+ filters?: Filters;
+ sortDir?: SortDir;
+ fullData: Customer[];
+}
+export interface Filters {
+ email?: string;
+ phone?: string;
+ status?: string;
+}