diff --git a/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/BasicDetailCard.tsx b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/BasicDetailCard.tsx
new file mode 100644
index 0000000..6683056
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/BasicDetailCard.tsx
@@ -0,0 +1,79 @@
+'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 { LpCategory } from '@/components/dashboard/lp/categories/type';
+
+export default function BasicDetailCard({
+ lpModel: model,
+ handleEditClick,
+}: {
+ lpModel: LpCategory;
+ 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: 'Name', value: model.cat_name },
+ { key: 'Remarks', value: model.remarks },
+ { key: 'Description', value: model.description },
+ ] satisfies { key: string; value: React.ReactNode }[]
+ ).map(
+ (item): React.JSX.Element => (
+
+ )
+ )}
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/TitleCard.tsx b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/TitleCard.tsx
new file mode 100644
index 0000000..6e38fb6
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/TitleCard.tsx
@@ -0,0 +1,73 @@
+'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 { LpCategory } from '@/components/dashboard/lp/categories/type';
+
+function getImageUrlFrRecord(record: LpCategory): string {
+ return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.cat_image}`;
+}
+
+export default function SampleTitleCard({ lpModel }: { lpModel: LpCategory }): React.JSX.Element {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+ {t('empty')}
+
+
+
+ {lpModel.cat_name}
+
+ }
+ label={lpModel.visible}
+ size="small"
+ variant="outlined"
+ />
+
+
+ {lpModel.slug}
+
+
+
+
+ }
+ variant="contained"
+ >
+ {t('list.action')}
+
+
+ >
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/page.tsx
new file mode 100644
index 0000000..c25de18
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/page.tsx
@@ -0,0 +1,138 @@
+'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 { COL_QUIZ_LP_CATEGORIES } from '@/constants';
+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 { 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 { defaultLpCategory } from '@/components/dashboard/lp/categories/_constants.ts';
+import { Notifications } from '@/components/dashboard/lp/categories/notifications';
+import type { LpCategory } from '@/components/dashboard/lp/categories/type';
+import FormLoading from '@/components/loading';
+
+import BasicDetailCard from './BasicDetailCard';
+import TitleCard from './TitleCard';
+
+export default function Page(): React.JSX.Element {
+ const { t } = useTranslation();
+ const router = useRouter();
+ //
+ const { cat_id: catId } = useParams<{ cat_id: string }>();
+ //
+ const [showLoading, setShowLoading] = React.useState(true);
+ const [showError, setShowError] = React.useState({ show: false, detail: '' });
+
+ //
+ const [showLessonCategory, setShowLessonCategory] = React.useState(defaultLpCategory);
+
+ function handleEditClick() {
+ router.push(paths.dashboard.lp_categories.edit(showLessonCategory.id));
+ }
+
+ React.useEffect(() => {
+ if (catId) {
+ pb.collection(COL_QUIZ_LP_CATEGORIES)
+ .getOne(catId)
+ .then((model: RecordModel) => {
+ setShowLessonCategory({ ...defaultLpCategory, ...model });
+ })
+ .catch((err) => {
+ logger.error(err);
+ toast(t('list.error'));
+
+ setShowError({ show: true, detail: JSON.stringify(err) });
+ })
+ .finally(() => {
+ setShowLoading(false);
+ });
+ }
+ }, [catId]);
+
+ if (showLoading) return ;
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+
+
+ {t('list.title')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/cr/categories/create/page.tsx b/002_source/cms/src/app/dashboard/cr/categories/create/page.tsx
new file mode 100644
index 0000000..4bc1776
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/categories/create/page.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+// RULES:
+// T.B.A.
+//
+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 { LpCategoryCreateForm } from '@/components/dashboard/lp/categories/lp-category-create-form';
+
+export default function Page(): React.JSX.Element {
+ // RULES: follow the name of page directory
+ const { t } = useTranslation(['lp_categories']);
+
+ return (
+
+
+
+
+
+ {t('create.title')}
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/_PROMPT.md b/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/_PROMPT.md
new file mode 100644
index 0000000..abf4465
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/_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/cr/categories/edit/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/page.tsx
new file mode 100644
index 0000000..149a75b
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/page.tsx
@@ -0,0 +1,53 @@
+'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 { LpCategoryEditForm } from '@/components/dashboard/lp/categories/lp-category-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/cr/categories/lp-categories-sample-data.tsx b/002_source/cms/src/app/dashboard/cr/categories/lp-categories-sample-data.tsx
new file mode 100644
index 0000000..0c1aa79
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/categories/lp-categories-sample-data.tsx
@@ -0,0 +1,90 @@
+import { dayjs } from '@/lib/dayjs';
+import { LessonCategory } from '@/components/dashboard/lesson_category/type';
+
+export const LpCategoriesSampleData = [
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+] satisfies LessonCategory[];
diff --git a/002_source/cms/src/app/dashboard/cr/categories/page.tsx b/002_source/cms/src/app/dashboard/cr/categories/page.tsx
new file mode 100644
index 0000000..93bb730
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/categories/page.tsx
@@ -0,0 +1,275 @@
+'use client';
+
+// RULES:
+// contains list page for lp_categories (QuizLPCategories)
+// contain definition to collection only
+//
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
+import { LoadingButton } from '@mui/lab';
+import Box from '@mui/material/Box';
+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 { 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 { defaultLpCategory } from '@/components/dashboard/lp/categories/_constants';
+import { LpCategoriesFilters } from '@/components/dashboard/lp/categories/lp-categories-filters';
+import type { Filters } from '@/components/dashboard/lp/categories/lp-categories-filters';
+import { LpCategoriesPagination } from '@/components/dashboard/lp/categories/lp-categories-pagination';
+import { LpCategoriesSelectionProvider } from '@/components/dashboard/lp/categories/lp-categories-selection-context';
+import { LpCategoriesTable } from '@/components/dashboard/lp/categories/lp-categories-table';
+import type { LpCategory } from '@/components/dashboard/lp/categories/type';
+import FormLoading from '@/components/loading';
+
+export default function Page({ searchParams }: PageProps): React.JSX.Element {
+ const { t } = useTranslation(['lp_categories']);
+ const { email, phone, sortDir, status, name, visible, type } = searchParams;
+ const router = useRouter();
+ 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 sortedLessonCategories = applySort(lessonCategoriesData, sortDir);
+ const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status });
+
+ //
+ const reloadRows = async (): Promise => {
+ try {
+ const models: ListResult = await pb
+ .collection(COL_QUIZ_LP_CATEGORIES)
+ .getList(currentPage + 1, rowsPerPage, listOption);
+ const { items, totalItems } = models;
+ const tempLessonTypes: LpCategory[] = items.map((lt) => {
+ return { ...defaultLpCategory, ...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 (visible) {
+ tempFilter.push(`visible = "${visible}"`);
+ }
+
+ if (sortDir) {
+ tempSortDir = `-created`;
+ }
+
+ if (name) {
+ tempFilter.push(`name ~ "%${name}%"`);
+ }
+
+ if (type) {
+ tempFilter.push(`type ~ "%${type}%"`);
+ }
+
+ 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,
+ // //
+ // });
+ }, [visible, sortDir, name, type]);
+
+ // return <>helloworld>;
+
+ if (showLoading) return ;
+
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+ {t('list.title')}
+
+
+ {
+ setIsLoadingAddPage(true);
+ router.push(paths.dashboard.lp_categories.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: LpCategory[], sortDir: 'asc' | 'desc' | undefined): LpCategory[] {
+ 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: LpCategory[], { email, phone, status, name, visible }: Filters): LpCategory[] {
+ 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;
+ }
+ }
+
+ if (name) {
+ if (!item.name?.toLowerCase().includes(name.toLowerCase())) {
+ return false;
+ }
+ }
+
+ if (visible) {
+ if (!item.visible?.toLowerCase().includes(visible.toLowerCase())) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+}
+
+interface PageProps {
+ searchParams: {
+ email?: string;
+ phone?: string;
+ sortDir?: 'asc' | 'desc';
+ status?: string;
+ name?: string;
+ visible?: string;
+ type?: string;
+ //
+ };
+}
diff --git a/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/BasicDetailCard.tsx b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/BasicDetailCard.tsx
new file mode 100644
index 0000000..6683056
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/BasicDetailCard.tsx
@@ -0,0 +1,79 @@
+'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 { LpCategory } from '@/components/dashboard/lp/categories/type';
+
+export default function BasicDetailCard({
+ lpModel: model,
+ handleEditClick,
+}: {
+ lpModel: LpCategory;
+ 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: 'Name', value: model.cat_name },
+ { key: 'Remarks', value: model.remarks },
+ { key: 'Description', value: model.description },
+ ] satisfies { key: string; value: React.ReactNode }[]
+ ).map(
+ (item): React.JSX.Element => (
+
+ )
+ )}
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/TitleCard.tsx b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/TitleCard.tsx
new file mode 100644
index 0000000..6e38fb6
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/TitleCard.tsx
@@ -0,0 +1,73 @@
+'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 { LpCategory } from '@/components/dashboard/lp/categories/type';
+
+function getImageUrlFrRecord(record: LpCategory): string {
+ return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.cat_image}`;
+}
+
+export default function SampleTitleCard({ lpModel }: { lpModel: LpCategory }): React.JSX.Element {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+ {t('empty')}
+
+
+
+ {lpModel.cat_name}
+
+ }
+ label={lpModel.visible}
+ size="small"
+ variant="outlined"
+ />
+
+
+ {lpModel.slug}
+
+
+
+
+ }
+ variant="contained"
+ >
+ {t('list.action')}
+
+
+ >
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/page.tsx
new file mode 100644
index 0000000..08488f3
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/page.tsx
@@ -0,0 +1,138 @@
+'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 { COL_QUIZ_LP_QUESTIONS } from '@/constants';
+import { Grid } from '@mui/material';
+import Box from '@mui/material/Box';
+import Link from '@mui/material/Link';
+import Stack from '@mui/material/Stack';
+import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
+import type { RecordModel } from 'pocketbase';
+import { useTranslation } from 'react-i18next';
+
+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 { defaultLpQuestion } from '@/components/dashboard/lp/questions/_constants.ts';
+import { Notifications } from '@/components/dashboard/lp/questions/notifications';
+import type { LpQuestion } from '@/components/dashboard/lp/questions/type';
+import FormLoading from '@/components/loading';
+
+import BasicDetailCard from './BasicDetailCard';
+import TitleCard from './TitleCard';
+
+export default function Page(): React.JSX.Element {
+ const { t } = useTranslation();
+ const router = useRouter();
+ //
+ const { cat_id: catId } = useParams<{ cat_id: string }>();
+ //
+ const [showLoading, setShowLoading] = React.useState(true);
+ const [showError, setShowError] = React.useState({ show: false, detail: '' });
+
+ //
+ const [showLessonQuestion, setShowLessonQuestion] = React.useState(defaultLpQuestion);
+
+ function handleEditClick() {
+ router.push(paths.dashboard.lp_questions.edit(showLessonQuestion.id));
+ }
+
+ React.useEffect(() => {
+ if (catId) {
+ pb.collection(COL_QUIZ_LP_QUESTIONS)
+ .getOne(catId)
+ .then((model: RecordModel) => {
+ setShowLessonQuestion({ ...defaultLpQuestion, ...model });
+ })
+ .catch((err) => {
+ logger.error(err);
+ toast(t('list.error'));
+
+ setShowError({ show: true, detail: JSON.stringify(err) });
+ })
+ .finally(() => {
+ setShowLoading(false);
+ });
+ }
+ }, [catId]);
+
+ if (showLoading) return ;
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+
+
+ {t('edit.title')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/cr/questions/create/page.tsx b/002_source/cms/src/app/dashboard/cr/questions/create/page.tsx
new file mode 100644
index 0000000..ff3c9dc
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/questions/create/page.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+// RULES:
+// T.B.A.
+//
+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 { LpQuestionCreateForm } from '@/components/dashboard/lp/questions/lp-question-create-form';
+
+export default function Page(): React.JSX.Element {
+ // RULES: follow the name of page directory
+ const { t } = useTranslation(['lp_questions']);
+
+ return (
+
+
+
+
+
+ {t('create.title')}
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/_PROMPT.md b/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/_PROMPT.md
new file mode 100644
index 0000000..abf4465
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/_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/cr/questions/edit/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/page.tsx
new file mode 100644
index 0000000..321b5a9
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/page.tsx
@@ -0,0 +1,53 @@
+'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 { LpQuestionEditForm } from '@/components/dashboard/lp/questions/lp-question-edit-form';
+
+export default function Page(): React.JSX.Element {
+ const { t } = useTranslation(['lp_questions']);
+
+ React.useEffect(() => {
+ // console.log('helloworld');
+ }, []);
+
+ return (
+
+
+
+
+
+
+ {t('edit.title')}
+
+
+
+ {t('edit.title')}
+
+
+
+
+
+ );
+}
diff --git a/002_source/cms/src/app/dashboard/cr/questions/lp-categories-sample-data.tsx b/002_source/cms/src/app/dashboard/cr/questions/lp-categories-sample-data.tsx
new file mode 100644
index 0000000..0c1aa79
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/questions/lp-categories-sample-data.tsx
@@ -0,0 +1,90 @@
+import { dayjs } from '@/lib/dayjs';
+import { LessonCategory } from '@/components/dashboard/lesson_category/type';
+
+export const LpCategoriesSampleData = [
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+] satisfies LessonCategory[];
diff --git a/002_source/cms/src/app/dashboard/cr/questions/page.tsx b/002_source/cms/src/app/dashboard/cr/questions/page.tsx
new file mode 100644
index 0000000..062b71d
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/questions/page.tsx
@@ -0,0 +1,274 @@
+'use client';
+
+// RULES:
+// contains list page for lp_questions (QuizLPQuestions)
+// contain definition to collection only
+//
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
+import { LoadingButton } from '@mui/lab';
+import Box from '@mui/material/Box';
+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 { 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 { defaultLpQuestion } from '@/components/dashboard/lp/questions/_constants';
+import { LpQuestionsFilters } from '@/components/dashboard/lp/questions/lp-questions-filters';
+import type { Filters } from '@/components/dashboard/lp/questions/lp-questions-filters';
+import { LpQuestionsPagination } from '@/components/dashboard/lp/questions/lp-questions-pagination';
+import { LpQuestionsSelectionProvider } from '@/components/dashboard/lp/questions/lp-questions-selection-context';
+import { LpQuestionsTable } from '@/components/dashboard/lp/questions/lp-questions-table';
+import type { LpQuestion } from '@/components/dashboard/lp/questions/type';
+import FormLoading from '@/components/loading';
+
+export default function Page({ searchParams }: PageProps): React.JSX.Element {
+ const { t } = useTranslation(['lp_questions']);
+ const { email, phone, sortDir, status, name, visible, type } = searchParams;
+ const router = useRouter();
+ const [lessonQuestionsData, 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 sortedLessonCategories = applySort(lessonQuestionsData, sortDir);
+ const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status });
+
+ const reloadRows = async (): Promise => {
+ try {
+ const models: ListResult = await pb
+ .collection(COL_QUIZ_LP_QUESTIONS)
+ .getList(currentPage + 1, rowsPerPage, listOption);
+ const { items, totalItems } = models;
+ const tempLessonTypes: LpQuestion[] = items.map((lt) => {
+ return { ...defaultLpQuestion, ...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 (visible) {
+ tempFilter.push(`visible = "${visible}"`);
+ }
+
+ if (sortDir) {
+ tempSortDir = `-created`;
+ }
+
+ if (name) {
+ tempFilter.push(`name ~ "%${name}%"`);
+ }
+
+ if (type) {
+ tempFilter.push(`type ~ "%${type}%"`);
+ }
+
+ 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,
+ // //
+ // });
+ }, [visible, sortDir, name, type]);
+
+ // return <>helloworld>;
+
+ if (showLoading) return ;
+
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+ {t('list.title')}
+
+
+ {
+ setIsLoadingAddPage(true);
+ router.push(paths.dashboard.lp_questions.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: LpQuestion[], sortDir: 'asc' | 'desc' | undefined): LpQuestion[] {
+ 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: LpQuestion[], { email, phone, status, name, visible }: Filters): LpQuestion[] {
+ 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;
+ }
+ }
+
+ if (name) {
+ if (!item.name?.toLowerCase().includes(name.toLowerCase())) {
+ return false;
+ }
+ }
+
+ if (visible) {
+ if (!item.visible?.toLowerCase().includes(visible.toLowerCase())) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+}
+
+interface PageProps {
+ searchParams: {
+ email?: string;
+ phone?: string;
+ sortDir?: 'asc' | 'desc';
+ status?: string;
+ name?: string;
+ visible?: string;
+ type?: string;
+ //
+ };
+}
diff --git a/002_source/cms/src/app/dashboard/cr/repomix-output.xml b/002_source/cms/src/app/dashboard/cr/repomix-output.xml
new file mode 100644
index 0000000..c11e03d
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/cr/repomix-output.xml
@@ -0,0 +1,1783 @@
+This file is a merged representation of the entire codebase, combined into a single document by Repomix.
+
+
+This section contains a summary of this file.
+
+
+This file contains a packed representation of the entire repository's contents.
+It is designed to be easily consumable by AI systems for analysis, code review,
+or other automated processes.
+
+
+
+The content is organized as follows:
+1. This summary section
+2. Repository information
+3. Directory structure
+4. Repository files, each consisting of:
+ - File path as an attribute
+ - Full contents of the file
+
+
+
+- This file should be treated as read-only. Any changes should be made to the
+ original repository files, not this packed version.
+- When processing this file, use the file path to distinguish
+ between different files in the repository.
+- Be aware that this file may contain sensitive information. Handle it with
+ the same level of security as you would the original repository.
+
+
+
+- Some files may have been excluded based on .gitignore rules and Repomix's configuration
+- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
+- Files matching patterns in .gitignore are excluded
+- Files matching default ignore patterns are excluded
+- Files are sorted by Git change count (files with more changes are at the bottom)
+
+
+
+
+
+
+
+
+
+categories/
+ [cat_id]/
+ BasicDetailCard.tsx
+ page.tsx
+ TitleCard.tsx
+ create/
+ page copy 2.tsx
+ page copy.tsx
+ page.tsx
+ edit/
+ [cat_id]/
+ _PROMPT.md
+ page copy.tsx
+ page.tsx
+ lp-categories-sample-data.tsx
+ page.tsx
+questions/
+ [cat_id]/
+ BasicDetailCard.tsx
+ page.tsx
+ TitleCard.tsx
+ create/
+ page.tsx
+ edit/
+ [cat_id]/
+ _PROMPT.md
+ page.tsx
+ lp-categories-sample-data.tsx
+ page.tsx
+
+
+
+This section contains the contents of the repository's files.
+
+
+'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 { LpCategory } from '@/components/dashboard/lp/categories/type';
+
+export default function BasicDetailCard({
+ lpModel: model,
+ handleEditClick,
+}: {
+ lpModel: LpCategory;
+ 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: 'Name', value: model.cat_name },
+ { key: 'Remarks', value: model.remarks },
+ { key: 'Description', value: model.description },
+ ] satisfies { key: string; value: React.ReactNode }[]
+ ).map(
+ (item): React.JSX.Element => (
+
+ )
+ )}
+
+
+ );
+}
+
+
+
+'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 { COL_QUIZ_LP_CATEGORIES } from '@/constants';
+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 { 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 { defaultLpCategory } from '@/components/dashboard/lp/categories/_constants.ts';
+import { Notifications } from '@/components/dashboard/lp/categories/notifications';
+import type { LpCategory } from '@/components/dashboard/lp/categories/type';
+import FormLoading from '@/components/loading';
+
+import BasicDetailCard from './BasicDetailCard';
+import TitleCard from './TitleCard';
+
+export default function Page(): React.JSX.Element {
+ const { t } = useTranslation();
+ const router = useRouter();
+ //
+ const { cat_id: catId } = useParams<{ cat_id: string }>();
+ //
+ const [showLoading, setShowLoading] = React.useState(true);
+ const [showError, setShowError] = React.useState({ show: false, detail: '' });
+
+ //
+ const [showLessonCategory, setShowLessonCategory] = React.useState(defaultLpCategory);
+
+ function handleEditClick() {
+ router.push(paths.dashboard.lp_categories.edit(showLessonCategory.id));
+ }
+
+ React.useEffect(() => {
+ if (catId) {
+ pb.collection(COL_QUIZ_LP_CATEGORIES)
+ .getOne(catId)
+ .then((model: RecordModel) => {
+ setShowLessonCategory({ ...defaultLpCategory, ...model });
+ })
+ .catch((err) => {
+ logger.error(err);
+ toast(t('list.error'));
+
+ setShowError({ show: true, detail: JSON.stringify(err) });
+ })
+ .finally(() => {
+ setShowLoading(false);
+ });
+ }
+ }, [catId]);
+
+ if (showLoading) return ;
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+
+
+ {t('list.title')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+
+
+'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 { LpCategory } from '@/components/dashboard/lp/categories/type';
+
+function getImageUrlFrRecord(record: LpCategory): string {
+ return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.cat_image}`;
+}
+
+export default function SampleTitleCard({ lpModel }: { lpModel: LpCategory }): React.JSX.Element {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+ {t('empty')}
+
+
+
+ {lpModel.cat_name}
+
+ }
+ label={lpModel.visible}
+ size="small"
+ variant="outlined"
+ />
+
+
+ {lpModel.slug}
+
+
+
+
+ }
+ variant="contained"
+ >
+ {t('list.action')}
+
+
+ >
+ );
+}
+
+
+
+'use client';
+
+// RULES:
+// T.B.A.
+//
+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 { LpCategoryCreateForm } from '@/components/dashboard/lp/categories/lp-category-create-form';
+
+export default function Page(): React.JSX.Element {
+ // RULES: follow the name of page directory
+ const { t } = useTranslation(['lp_categories']);
+
+ return (
+
+
+
+
+
+ {t('create.title')}
+
+
+
+
+
+ );
+}
+
+
+
+'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 { LessonCategoryCreateForm } from '@/components/dashboard/lesson_category/lesson-category-create-form';
+
+export default function Page(): React.JSX.Element {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+ {t('title', { ns: 'lesson_category' })}
+
+
+
+ {t('create.title', { ns: 'lesson_category' })}
+
+
+
+
+
+ );
+}
+
+
+
+'use client';
+
+// RULES:
+// T.B.A.
+//
+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 { LpCategoryCreateForm } from '@/components/dashboard/lp/categories/lp-category-create-form';
+
+export default function Page(): React.JSX.Element {
+ // RULES: follow the name of page directory
+ const { t } = useTranslation(['lp_categories']);
+
+ return (
+
+
+
+
+
+ {t('create.title')}
+
+
+
+
+
+ );
+}
+
+
+
+# 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,
+
+
+
+'use client';
+
+import * as React from 'react';
+
+export default function Page(): React.JSX.Element {
+ return <>helloworld>;
+}
+
+
+
+'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 { LpCategoryEditForm } from '@/components/dashboard/lp/categories/lp-category-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')}
+
+
+
+
+
+ );
+}
+
+
+
+import { dayjs } from '@/lib/dayjs';
+import { LessonCategory } from '@/components/dashboard/lesson_category/type';
+
+export const LpCategoriesSampleData = [
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+] satisfies LessonCategory[];
+
+
+
+'use client';
+
+// RULES:
+// contains list page for lp_categories (QuizLPCategories)
+// contain definition to collection only
+//
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
+import { LoadingButton } from '@mui/lab';
+import Box from '@mui/material/Box';
+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 { 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 { defaultLpCategory } from '@/components/dashboard/lp/categories/_constants';
+import { LpCategoriesFilters } from '@/components/dashboard/lp/categories/lp-categories-filters';
+import type { Filters } from '@/components/dashboard/lp/categories/lp-categories-filters';
+import { LpCategoriesPagination } from '@/components/dashboard/lp/categories/lp-categories-pagination';
+import { LpCategoriesSelectionProvider } from '@/components/dashboard/lp/categories/lp-categories-selection-context';
+import { LpCategoriesTable } from '@/components/dashboard/lp/categories/lp-categories-table';
+import type { LpCategory } from '@/components/dashboard/lp/categories/type';
+import FormLoading from '@/components/loading';
+
+export default function Page({ searchParams }: PageProps): React.JSX.Element {
+ const { t } = useTranslation(['lp_categories']);
+ const { email, phone, sortDir, status, name, visible, type } = searchParams;
+ const router = useRouter();
+ 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(1);
+ const [recordCount, setRecordCount] = React.useState(0);
+ const [listOption, setListOption] = React.useState({});
+ const [listSort, setListSort] = React.useState({});
+
+ //
+ const sortedLessonCategories = applySort(lessonCategoriesData, sortDir);
+ const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status });
+
+ const reloadRows = async (): Promise => {
+ try {
+ const models: ListResult = await pb
+ .collection(COL_QUIZ_LP_CATEGORIES)
+ .getList(currentPage + 1, rowsPerPage, listOption);
+ const { items, totalItems } = models;
+ const tempLessonTypes: LpCategory[] = items.map((lt) => {
+ return { ...defaultLpCategory, ...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 (visible) {
+ tempFilter.push(`visible = "${visible}"`);
+ }
+
+ if (sortDir) {
+ tempSortDir = `-created`;
+ }
+
+ if (name) {
+ tempFilter.push(`name ~ "%${name}%"`);
+ }
+
+ if (type) {
+ tempFilter.push(`type ~ "%${type}%"`);
+ }
+
+ 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,
+ // //
+ // });
+ }, [visible, sortDir, name, type]);
+
+ // return <>helloworld>;
+
+ if (showLoading) return ;
+
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+ {t('list.title')}
+
+
+ {
+ setIsLoadingAddPage(true);
+ router.push(paths.dashboard.lp_categories.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: LpCategory[], sortDir: 'asc' | 'desc' | undefined): LpCategory[] {
+ 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: LpCategory[], { email, phone, status, name, visible }: Filters): LpCategory[] {
+ 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;
+ }
+ }
+
+ if (name) {
+ if (!item.name?.toLowerCase().includes(name.toLowerCase())) {
+ return false;
+ }
+ }
+
+ if (visible) {
+ if (!item.visible?.toLowerCase().includes(visible.toLowerCase())) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+}
+
+interface PageProps {
+ searchParams: {
+ email?: string;
+ phone?: string;
+ sortDir?: 'asc' | 'desc';
+ status?: string;
+ name?: string;
+ visible?: string;
+ type?: string;
+ //
+ };
+}
+
+
+
+'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 { LpCategory } from '@/components/dashboard/lp/categories/type';
+
+export default function BasicDetailCard({
+ lpModel: model,
+ handleEditClick,
+}: {
+ lpModel: LpCategory;
+ 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: 'Name', value: model.cat_name },
+ { key: 'Remarks', value: model.remarks },
+ { key: 'Description', value: model.description },
+ ] satisfies { key: string; value: React.ReactNode }[]
+ ).map(
+ (item): React.JSX.Element => (
+
+ )
+ )}
+
+
+ );
+}
+
+
+
+'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 { COL_QUIZ_LP_QUESTIONS } from '@/constants';
+import { Grid } from '@mui/material';
+import Box from '@mui/material/Box';
+import Link from '@mui/material/Link';
+import Stack from '@mui/material/Stack';
+import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
+import type { RecordModel } from 'pocketbase';
+import { useTranslation } from 'react-i18next';
+
+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 { defaultLpQuestion } from '@/components/dashboard/lp/questions/_constants.ts';
+import { Notifications } from '@/components/dashboard/lp/questions/notifications';
+import type { LpQuestion } from '@/components/dashboard/lp/questions/type';
+import FormLoading from '@/components/loading';
+
+import BasicDetailCard from './BasicDetailCard';
+import TitleCard from './TitleCard';
+
+export default function Page(): React.JSX.Element {
+ const { t } = useTranslation();
+ const router = useRouter();
+ //
+ const { cat_id: catId } = useParams<{ cat_id: string }>();
+ //
+ const [showLoading, setShowLoading] = React.useState(true);
+ const [showError, setShowError] = React.useState({ show: false, detail: '' });
+
+ //
+ const [showLessonQuestion, setShowLessonQuestion] = React.useState(defaultLpQuestion);
+
+ function handleEditClick() {
+ router.push(paths.dashboard.lp_questions.edit(showLessonQuestion.id));
+ }
+
+ React.useEffect(() => {
+ if (catId) {
+ pb.collection(COL_QUIZ_LP_QUESTIONS)
+ .getOne(catId)
+ .then((model: RecordModel) => {
+ setShowLessonQuestion({ ...defaultLpQuestion, ...model });
+ })
+ .catch((err) => {
+ logger.error(err);
+ toast(t('list.error'));
+
+ setShowError({ show: true, detail: JSON.stringify(err) });
+ })
+ .finally(() => {
+ setShowLoading(false);
+ });
+ }
+ }, [catId]);
+
+ if (showLoading) return ;
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+
+
+ {t('edit.title')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+
+
+'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 { LpCategory } from '@/components/dashboard/lp/categories/type';
+
+function getImageUrlFrRecord(record: LpCategory): string {
+ return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.cat_image}`;
+}
+
+export default function SampleTitleCard({ lpModel }: { lpModel: LpCategory }): React.JSX.Element {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+ {t('empty')}
+
+
+
+ {lpModel.cat_name}
+
+ }
+ label={lpModel.visible}
+ size="small"
+ variant="outlined"
+ />
+
+
+ {lpModel.slug}
+
+
+
+
+ }
+ variant="contained"
+ >
+ {t('list.action')}
+
+
+ >
+ );
+}
+
+
+
+'use client';
+
+// RULES:
+// T.B.A.
+//
+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 { LpQuestionCreateForm } from '@/components/dashboard/lp/questions/lp-question-create-form';
+
+export default function Page(): React.JSX.Element {
+ // RULES: follow the name of page directory
+ const { t } = useTranslation(['lp_questions']);
+
+ return (
+
+
+
+
+
+ {t('create.title')}
+
+
+
+
+
+ );
+}
+
+
+
+# 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,
+
+
+
+'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 { LpQuestionEditForm } from '@/components/dashboard/lp/questions/lp-question-edit-form';
+
+export default function Page(): React.JSX.Element {
+ const { t } = useTranslation(['lp_questions']);
+
+ React.useEffect(() => {
+ // console.log('helloworld');
+ }, []);
+
+ return (
+
+
+
+
+
+
+ {t('edit.title')}
+
+
+
+ {t('edit.title')}
+
+
+
+
+
+ );
+}
+
+
+
+import { dayjs } from '@/lib/dayjs';
+import { LessonCategory } from '@/components/dashboard/lesson_category/type';
+
+export const LpCategoriesSampleData = [
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+ {
+ 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(),
+ collectionId: '0000000001',
+ cat_name: '',
+ pos: 99,
+ visible: 'visible',
+ lesson_id: 'lid_00001',
+ description: '',
+ remarks: '',
+ },
+] satisfies LessonCategory[];
+
+
+
+'use client';
+
+// RULES:
+// contains list page for lp_questions (QuizLPQuestions)
+// contain definition to collection only
+//
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
+import { LoadingButton } from '@mui/lab';
+import Box from '@mui/material/Box';
+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 { 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 { defaultLpQuestion } from '@/components/dashboard/lp/questions/_constants';
+import { LpQuestionsFilters } from '@/components/dashboard/lp/questions/lp-questions-filters';
+import type { Filters } from '@/components/dashboard/lp/questions/lp-questions-filters';
+import { LpQuestionsPagination } from '@/components/dashboard/lp/questions/lp-questions-pagination';
+import { LpQuestionsSelectionProvider } from '@/components/dashboard/lp/questions/lp-questions-selection-context';
+import { LpQuestionsTable } from '@/components/dashboard/lp/questions/lp-questions-table';
+import type { LpQuestion } from '@/components/dashboard/lp/questions/type';
+import FormLoading from '@/components/loading';
+
+export default function Page({ searchParams }: PageProps): React.JSX.Element {
+ const { t } = useTranslation(['lp_questions']);
+ const { email, phone, sortDir, status, name, visible, type } = searchParams;
+ const router = useRouter();
+ const [lessonQuestionsData, 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 sortedLessonCategories = applySort(lessonQuestionsData, sortDir);
+ const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status });
+
+ const reloadRows = async (): Promise => {
+ try {
+ const models: ListResult = await pb
+ .collection(COL_QUIZ_LP_QUESTIONS)
+ .getList(currentPage + 1, rowsPerPage, listOption);
+ const { items, totalItems } = models;
+ const tempLessonTypes: LpQuestion[] = items.map((lt) => {
+ return { ...defaultLpQuestion, ...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 (visible) {
+ tempFilter.push(`visible = "${visible}"`);
+ }
+
+ if (sortDir) {
+ tempSortDir = `-created`;
+ }
+
+ if (name) {
+ tempFilter.push(`name ~ "%${name}%"`);
+ }
+
+ if (type) {
+ tempFilter.push(`type ~ "%${type}%"`);
+ }
+
+ 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,
+ // //
+ // });
+ }, [visible, sortDir, name, type]);
+
+ // return <>helloworld>;
+
+ if (showLoading) return ;
+
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+
+
+
+ {t('list.title')}
+
+
+ {
+ setIsLoadingAddPage(true);
+ router.push(paths.dashboard.lp_questions.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: LpQuestion[], sortDir: 'asc' | 'desc' | undefined): LpQuestion[] {
+ 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: LpQuestion[], { email, phone, status, name, visible }: Filters): LpQuestion[] {
+ 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;
+ }
+ }
+
+ if (name) {
+ if (!item.name?.toLowerCase().includes(name.toLowerCase())) {
+ return false;
+ }
+ }
+
+ if (visible) {
+ if (!item.visible?.toLowerCase().includes(visible.toLowerCase())) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+}
+
+interface PageProps {
+ searchParams: {
+ email?: string;
+ phone?: string;
+ sortDir?: 'asc' | 'desc';
+ status?: string;
+ name?: string;
+ visible?: string;
+ type?: string;
+ //
+ };
+}
+
+
+
diff --git a/002_source/cms/src/components/dashboard/cr/categories/_PROMPT.MD b/002_source/cms/src/components/dashboard/cr/categories/_PROMPT.MD
new file mode 100644
index 0000000..93ce5a6
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/_PROMPT.MD
@@ -0,0 +1 @@
+please review and add translations, e.g. `{t('[word]')}`
diff --git a/002_source/cms/src/components/dashboard/cr/categories/_constants.ts b/002_source/cms/src/components/dashboard/cr/categories/_constants.ts
new file mode 100644
index 0000000..885067e
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/_constants.ts
@@ -0,0 +1,44 @@
+import { dayjs } from '@/lib/dayjs';
+
+import { CreateFormProps, LpCategory } from './type';
+
+export const defaultLpCategory: LpCategory = {
+ isEmpty: false,
+ id: 'default-id',
+ cat_name: 'default-category-name',
+ cat_image_url: undefined,
+ cat_image: undefined,
+ pos: 0,
+ visible: 'hidden',
+ lesson_id: 'default-lesson-id',
+ description: 'default-description',
+ remarks: 'default-remarks',
+ slug: '',
+ init_answer: {},
+ // from pocketbase
+ collectionId: '0000000000',
+ createdAt: dayjs('2099-01-01').toDate(),
+ //
+ name: '',
+ avatar: '',
+ email: '',
+ phone: '',
+ quota: 0,
+ status: 'NA',
+};
+
+// export const LpCategoryCreateFormDefault: CreateFormProps = {
+// name: '',
+// type: '',
+// pos: 1,
+// visible: 'visible',
+// description: '',
+// isActive: true,
+// order: 1,
+// imageUrl: '',
+// };
+
+export const emptyLpCategory: LpCategory = {
+ ...defaultLpCategory,
+ isEmpty: true,
+};
diff --git a/002_source/cms/src/components/dashboard/cr/categories/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/cr/categories/confirm-delete-modal.tsx
new file mode 100644
index 0000000..4d5b267
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/confirm-delete-modal.tsx
@@ -0,0 +1,125 @@
+'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';
+
+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);
+ deleteQuizLPCategories(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/cr/categories/lp-categories-filters.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-filters.tsx
new file mode 100644
index 0000000..7573cc6
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-filters.tsx
@@ -0,0 +1,456 @@
+'use client';
+
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+// import { COL_LESSON_CATEGORIES } from '@/constants';
+import GetAllCount from '@/db/QuizListenings/GetAllCount';
+import GetHiddenCount from '@/db/QuizListenings/GetHiddenCount';
+import GetVisibleCount from '@/db/QuizListenings/GetVisibleCount';
+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 { useTranslation } from 'react-i18next';
+
+import { paths } from '@/paths';
+import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button';
+import { Option } from '@/components/core/option';
+
+import { useLpCategoriesSelection } from './lp-categories-selection-context';
+import { LpCategory } from './type';
+
+export interface Filters {
+ email?: string;
+ phone?: string;
+ status?: string;
+ name?: string;
+ visible?: string;
+ type?: string;
+}
+
+export type SortDir = 'asc' | 'desc';
+
+export interface LpCategoriesFiltersProps {
+ filters?: Filters;
+ sortDir?: SortDir;
+ fullData: LpCategory[];
+}
+
+export function LpCategoriesFilters({
+ filters = {},
+ sortDir = 'desc',
+ fullData,
+}: LpCategoriesFiltersProps): React.JSX.Element {
+ const { t } = useTranslation();
+ const { email, phone, status, name, visible, type } = filters;
+
+ const [totalCount, setTotalCount] = React.useState(0);
+ const [visibleCount, setVisibleCount] = React.useState(0);
+ const [hiddenCount, setHiddenCount] = React.useState(0);
+
+ const router = useRouter();
+
+ const selection = useLpCategoriesSelection();
+
+ function getVisible(): number {
+ return fullData.reduce((count, item: LpCategory) => {
+ return item.visible === 'visible' ? count + 1 : count;
+ }, 0);
+ }
+
+ function getHidden(): number {
+ return fullData.reduce((count, item: LpCategory) => {
+ return item.visible === 'hidden' ? count + 1 : count;
+ }, 0);
+ }
+
+ // The tabs should be generated using API data.
+ const tabs = [
+ { label: t('All'), value: '', count: totalCount },
+ // { label: 'Active', value: 'active', count: 3 },
+ // { label: 'Pending', value: 'pending', count: 1 },
+ // { label: 'Blocked', value: 'blocked', count: 1 },
+ { label: t('visible'), value: 'visible', count: visibleCount },
+ { label: t('hidden'), value: 'hidden', count: hiddenCount },
+ ] 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);
+ }
+
+ if (newFilters.name) {
+ searchParams.set('name', newFilters.name);
+ }
+
+ if (newFilters.type) {
+ searchParams.set('type', newFilters.type);
+ }
+
+ if (newFilters.visible) {
+ searchParams.set('visible', newFilters.visible);
+ }
+
+ // NOTE: modify according to COLLECTION
+ router.push(`${paths.dashboard.lp_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 handleVisibleChange = React.useCallback(
+ (_: React.SyntheticEvent, value: string) => {
+ updateSearchParams({ ...filters, visible: value }, sortDir);
+ },
+ [updateSearchParams, filters, sortDir]
+ );
+
+ const handleNameChange = React.useCallback(
+ (value?: string) => {
+ updateSearchParams({ ...filters, name: value }, sortDir);
+ },
+ [updateSearchParams, filters, sortDir]
+ );
+
+ const handleTypeChange = React.useCallback(
+ (value?: string) => {
+ updateSearchParams({ ...filters, type: 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]
+ );
+
+ React.useEffect(() => {
+ const fetchCount = async (): Promise => {
+ try {
+ const tc = await GetAllCount();
+ setTotalCount(tc);
+
+ const vc = await GetVisibleCount();
+ setVisibleCount(vc);
+
+ const hc = await GetHiddenCount();
+ setHiddenCount(hc);
+ } catch (error) {
+ //
+ }
+ };
+ void fetchCount();
+ }, []);
+
+ const hasFilters = status || email || phone || visible || name || type;
+
+ return (
+
+
+ {tabs.map((tab) => (
+
+ }
+ iconPosition="end"
+ key={tab.value}
+ label={tab.label}
+ sx={{ minHeight: 'auto' }}
+ tabIndex={0}
+ value={tab.value}
+ />
+ ))}
+
+
+
+
+ {
+ handleNameChange(value as string);
+ }}
+ onFilterDelete={() => {
+ handleNameChange();
+ }}
+ popover={}
+ value={name}
+ />
+
+ {
+ handleTypeChange(value as string);
+ }}
+ onFilterDelete={() => {
+ handleTypeChange();
+ }}
+ popover={}
+ value={type}
+ />
+
+ {hasFilters ? : null}
+
+ {selection.selectedAny ? (
+
+
+ {selection.selected.size} {t('selected')}
+
+
+
+ ) : null}
+
+
+
+ );
+}
+
+function TypeFilterPopover(): React.JSX.Element {
+ const { t } = useTranslation();
+ 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 NameFilterPopover(): React.JSX.Element {
+ const { t } = useTranslation();
+ 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 EmailFilterPopover(): React.JSX.Element {
+ const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
+ const [value, setValue] = React.useState('');
+ const { t } = useTranslation();
+
+ 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('');
+ const { t } = useTranslation();
+
+ 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/cr/categories/lp-categories-pagination.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-pagination.tsx
new file mode 100644
index 0000000..21bdbef
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-pagination.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+// lp-categories-pagination.tsx
+// RULES:
+// T.B.A.
+import * as React from 'react';
+import TablePagination from '@mui/material/TablePagination';
+
+function noop(): void {
+ return undefined;
+}
+
+interface LpCategoriesPaginationProps {
+ count: number;
+ page: number;
+ //
+ setPage: (page: number) => void;
+ setRowsPerPage: (page: number) => void;
+ rowsPerPage: number;
+}
+
+export function LpCategoriesPagination({
+ count,
+ page,
+ //
+ setPage,
+ setRowsPerPage,
+ rowsPerPage,
+}: LpCategoriesPaginationProps): 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/cr/categories/lp-categories-selection-context.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-selection-context.tsx
new file mode 100644
index 0000000..a6c6502
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-selection-context.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import * as React from 'react';
+
+// import type { LessonCategory } from '@/types/lesson-type';
+import { useSelection } from '@/hooks/use-selection';
+import type { Selection } from '@/hooks/use-selection';
+
+import { LpCategory } from './type';
+
+function noop(): void {
+ return undefined;
+}
+
+export interface LpCategoriesSelectionContextValue extends Selection {}
+
+export const LpCategoriesSelectionContext = React.createContext({
+ deselectAll: noop,
+ deselectOne: noop,
+ selectAll: noop,
+ selectOne: noop,
+ selected: new Set(),
+ selectedAny: false,
+ selectedAll: false,
+});
+
+interface LpCategoriesSelectionProviderProps {
+ children: React.ReactNode;
+ lessonCategories: LpCategory[];
+}
+
+export function LpCategoriesSelectionProvider({
+ children,
+ lessonCategories = [],
+}: LpCategoriesSelectionProviderProps): React.JSX.Element {
+ const customerIds = React.useMemo(() => lessonCategories.map((customer) => customer.id), [lessonCategories]);
+ const selection = useSelection(customerIds);
+
+ return (
+ {children}
+ );
+}
+
+export function useLpCategoriesSelection(): LpCategoriesSelectionContextValue {
+ return React.useContext(LpCategoriesSelectionContext);
+}
diff --git a/002_source/cms/src/components/dashboard/cr/categories/lp-categories-table.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-table.tsx
new file mode 100644
index 0000000..26034f1
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-table.tsx
@@ -0,0 +1,241 @@
+'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 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 { useLpCategoriesSelection } from './lp-categories-selection-context';
+import type { LpCategory } from './type';
+
+function columns(handleDeleteClick: (testId: string) => void): ColumnDef[] {
+ return [
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+
+
+ {' '}
+
+ {row.cat_name}
+
+ slug: {row.cat_name}
+
+
+
+
+
+ ),
+ name: 'Name',
+ width: '200px',
+ },
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+ {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)}
+
+
+ ),
+ // NOTE: please refer to translation.json here
+ name: 'word-count',
+ width: '100px',
+ },
+ {
+ 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: (
+
+ ),
+ },
+ NA: {
+ label: 'NA',
+ 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: '100px',
+ },
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+
+ {
+ handleDeleteClick(row.id);
+ }}
+ >
+
+
+
+ ),
+ name: 'Actions',
+ hideName: true,
+ width: '100px',
+ align: 'right',
+ },
+ ];
+}
+
+export interface LessonCategoriesTableProps {
+ rows: LpCategory[];
+ reloadRows: () => void;
+}
+
+export function LpCategoriesTable({ rows, reloadRows }: LessonCategoriesTableProps): React.JSX.Element {
+ const { t } = useTranslation(['lp_categories']);
+ const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLpCategoriesSelection();
+
+ 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 ? (
+
+
+ {t('no-lesson-categories-found')}
+
+
+ ) : null}
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/cr/categories/lp-category-create-form.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-category-create-form.tsx
new file mode 100644
index 0000000..ba46ac7
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/lp-category-create-form.tsx
@@ -0,0 +1,419 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { useRouter } from 'next/navigation';
+import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { LoadingButton } from '@mui/lab';
+import { Avatar, Divider, MenuItem, Select } from '@mui/material';
+// 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 FormControl from '@mui/material/FormControl';
+import FormHelperText from '@mui/material/FormHelperText';
+import InputLabel from '@mui/material/InputLabel';
+import OutlinedInput from '@mui/material/OutlinedInput';
+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 axios from 'axios';
+import { Controller, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { z as zod } from 'zod';
+
+import { paths } from '@/paths';
+import isDevelopment from '@/lib/check-is-development';
+import { logger } from '@/lib/default-logger';
+import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
+import { pb } from '@/lib/pb';
+import { TextEditor } from '@/components/core/text-editor/text-editor';
+import { toast } from '@/components/core/toaster';
+
+import type { CreateFormProps } from './type';
+
+const schema = zod.object({
+ cat_name: zod.string().min(1, 'name-is-required').max(255),
+ cat_image: zod.array(zod.any()).optional(),
+ pos: zod.number().min(1, 'position is required').max(99),
+ init_answer: zod.string().optional(),
+ visible: zod.string(),
+ slug: zod.string().min(1, 'slug-is-required').max(255),
+ remarks: zod.string().optional(),
+ description: zod.string().optional(),
+ // NOTE: for image handling
+ avatar: zod.string().optional(),
+ // TODO: remove me
+ type: zod.string().optional(),
+ isActive: zod.boolean().optional(),
+ order: zod.number().optional(),
+});
+
+type Values = zod.infer;
+
+export const defaultValues = {
+ cat_name: '',
+ cat_image: undefined,
+ pos: 1,
+ init_answer: '',
+ visible: 'hidden',
+ slug: '',
+ remarks: '',
+ description: '',
+} satisfies Values;
+
+export function LpCategoryCreateForm(): React.JSX.Element {
+ const router = useRouter();
+ const { t } = useTranslation(['lp_categories']);
+
+ const [isCreating, setIsCreating] = React.useState(false);
+
+ const {
+ control,
+ handleSubmit,
+ formState: { errors },
+ setValue,
+ watch,
+ } = useForm({ defaultValues, resolver: zodResolver(schema) });
+
+ const onSubmit = React.useCallback(
+ async (values: Values): Promise => {
+ setIsCreating(true);
+
+ const payload: CreateFormProps = {
+ cat_name: values.cat_name,
+ cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null,
+ pos: values.pos,
+ init_answer: values.init_answer,
+ visible: values.visible,
+ slug: values.slug,
+ remarks: values.remarks,
+ description: values.description,
+ //
+ // TODO: remove me
+ type: 'type tet',
+ isActive: true,
+ order: 1,
+ };
+
+ try {
+ const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).create(payload);
+
+ logger.debug(result);
+ toast.success(t('create.success'));
+ router.push(paths.dashboard.lp_categories.list);
+ } catch (error) {
+ logger.error(error);
+ toast.error(t('create.failed'));
+ } finally {
+ setIsCreating(false);
+ }
+ },
+ // t is not necessary here
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [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/cr/categories/lp-category-edit-form.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-category-edit-form.tsx
new file mode 100644
index 0000000..c00e964
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/lp-category-edit-form.tsx
@@ -0,0 +1,500 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { useParams, useRouter } from 'next/navigation';
+//
+import { COL_QUIZ_LP_CATEGORIES } 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 { TextEditor } from '@/components/core/text-editor/text-editor';
+import { toast } from '@/components/core/toaster';
+import FormLoading from '@/components/loading';
+
+import ErrorDisplay from '../../error';
+import type { EditFormProps } from './type';
+
+// TODO: review this
+const schema = zod.object({
+ cat_name: zod.string().min(1, 'name-is-required').max(255),
+ // accept file object when user change image
+ // accept http string when user not changing image
+ cat_image: zod.union([zod.array(zod.any()), zod.string()]).optional(),
+
+ // position
+ pos: zod.number().min(1, 'position is required').max(99),
+
+ // it should be a valid JSON
+ init_answer: zod
+ .string()
+ .refine(
+ (value) => {
+ try {
+ JSON.parse(value);
+ return true;
+ } catch (error) {
+ return false;
+ }
+ },
+ { message: 'init_answer must be a valid JSON' }
+ )
+ .optional(),
+ visible: zod.string(),
+ slug: zod.string().min(0, 'slug-is-required').max(255).optional(),
+ remarks: zod.string().optional(),
+ description: zod.string().optional(),
+ // NOTE: for image handling
+ avatar: zod.string().optional(),
+});
+
+type Values = zod.infer;
+
+const defaultValues = {
+ cat_name: '',
+ cat_image: undefined,
+ pos: 1,
+ init_answer: JSON.stringify({}),
+ visible: 'hidden',
+ slug: '',
+ remarks: '',
+ description: '',
+} satisfies Values;
+
+export function LpCategoryEditForm(): React.JSX.Element {
+ const router = useRouter();
+ const { t } = useTranslation(['lp_categories']);
+
+ const { cat_id: catId } = useParams<{ cat_id: 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 tempUpdate: EditFormProps = {
+ cat_name: values.cat_name,
+ cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null,
+ pos: values.pos,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ init_answer: JSON.parse(values.init_answer || '{}'),
+
+ visible: values.visible,
+ slug: values.slug || 'not-defined',
+ remarks: values.remarks,
+ description: values.description,
+ //
+ // TODO: remove below
+ type: '',
+ };
+
+ try {
+ const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).update(catId, tempUpdate);
+ logger.debug(result);
+ toast.success(t('edit.success'));
+ router.push(paths.dashboard.lp_categories.list);
+ } catch (error) {
+ logger.error(error);
+ toast.error(t('update.failed'));
+ } finally {
+ setIsUpdating(false);
+ }
+ },
+ // t is not necessary here
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [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_QUIZ_LP_CATEGORIES).getOne(id);
+
+ reset({ ...defaultValues, ...result, init_answer: JSON.stringify(result.init_answer) });
+ setTextDescription(result.description);
+ setTextRemarks(result.remarks);
+
+ if (result.cat_image !== '') {
+ const fetchResult = await fetch(
+ `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.cat_image}`
+ );
+
+ const blob = await fetchResult.blob();
+ const url = await fileToBase64(blob);
+
+ setValue('avatar', url);
+ } else {
+ setValue('avatar', '');
+ }
+ } catch (error) {
+ logger.error(error);
+ toast(t('list.error'));
+
+ setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
+ } finally {
+ setShowLoading(false);
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [catId]
+ );
+
+ React.useEffect(() => {
+ void loadExistingData(catId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [catId]);
+
+ if (showLoading) return ;
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/cr/categories/mf-categories-filters.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-filters.tsx
new file mode 100644
index 0000000..7573cc6
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-filters.tsx
@@ -0,0 +1,456 @@
+'use client';
+
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+// import { COL_LESSON_CATEGORIES } from '@/constants';
+import GetAllCount from '@/db/QuizListenings/GetAllCount';
+import GetHiddenCount from '@/db/QuizListenings/GetHiddenCount';
+import GetVisibleCount from '@/db/QuizListenings/GetVisibleCount';
+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 { useTranslation } from 'react-i18next';
+
+import { paths } from '@/paths';
+import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button';
+import { Option } from '@/components/core/option';
+
+import { useLpCategoriesSelection } from './lp-categories-selection-context';
+import { LpCategory } from './type';
+
+export interface Filters {
+ email?: string;
+ phone?: string;
+ status?: string;
+ name?: string;
+ visible?: string;
+ type?: string;
+}
+
+export type SortDir = 'asc' | 'desc';
+
+export interface LpCategoriesFiltersProps {
+ filters?: Filters;
+ sortDir?: SortDir;
+ fullData: LpCategory[];
+}
+
+export function LpCategoriesFilters({
+ filters = {},
+ sortDir = 'desc',
+ fullData,
+}: LpCategoriesFiltersProps): React.JSX.Element {
+ const { t } = useTranslation();
+ const { email, phone, status, name, visible, type } = filters;
+
+ const [totalCount, setTotalCount] = React.useState(0);
+ const [visibleCount, setVisibleCount] = React.useState(0);
+ const [hiddenCount, setHiddenCount] = React.useState(0);
+
+ const router = useRouter();
+
+ const selection = useLpCategoriesSelection();
+
+ function getVisible(): number {
+ return fullData.reduce((count, item: LpCategory) => {
+ return item.visible === 'visible' ? count + 1 : count;
+ }, 0);
+ }
+
+ function getHidden(): number {
+ return fullData.reduce((count, item: LpCategory) => {
+ return item.visible === 'hidden' ? count + 1 : count;
+ }, 0);
+ }
+
+ // The tabs should be generated using API data.
+ const tabs = [
+ { label: t('All'), value: '', count: totalCount },
+ // { label: 'Active', value: 'active', count: 3 },
+ // { label: 'Pending', value: 'pending', count: 1 },
+ // { label: 'Blocked', value: 'blocked', count: 1 },
+ { label: t('visible'), value: 'visible', count: visibleCount },
+ { label: t('hidden'), value: 'hidden', count: hiddenCount },
+ ] 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);
+ }
+
+ if (newFilters.name) {
+ searchParams.set('name', newFilters.name);
+ }
+
+ if (newFilters.type) {
+ searchParams.set('type', newFilters.type);
+ }
+
+ if (newFilters.visible) {
+ searchParams.set('visible', newFilters.visible);
+ }
+
+ // NOTE: modify according to COLLECTION
+ router.push(`${paths.dashboard.lp_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 handleVisibleChange = React.useCallback(
+ (_: React.SyntheticEvent, value: string) => {
+ updateSearchParams({ ...filters, visible: value }, sortDir);
+ },
+ [updateSearchParams, filters, sortDir]
+ );
+
+ const handleNameChange = React.useCallback(
+ (value?: string) => {
+ updateSearchParams({ ...filters, name: value }, sortDir);
+ },
+ [updateSearchParams, filters, sortDir]
+ );
+
+ const handleTypeChange = React.useCallback(
+ (value?: string) => {
+ updateSearchParams({ ...filters, type: 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]
+ );
+
+ React.useEffect(() => {
+ const fetchCount = async (): Promise => {
+ try {
+ const tc = await GetAllCount();
+ setTotalCount(tc);
+
+ const vc = await GetVisibleCount();
+ setVisibleCount(vc);
+
+ const hc = await GetHiddenCount();
+ setHiddenCount(hc);
+ } catch (error) {
+ //
+ }
+ };
+ void fetchCount();
+ }, []);
+
+ const hasFilters = status || email || phone || visible || name || type;
+
+ return (
+
+
+ {tabs.map((tab) => (
+
+ }
+ iconPosition="end"
+ key={tab.value}
+ label={tab.label}
+ sx={{ minHeight: 'auto' }}
+ tabIndex={0}
+ value={tab.value}
+ />
+ ))}
+
+
+
+
+ {
+ handleNameChange(value as string);
+ }}
+ onFilterDelete={() => {
+ handleNameChange();
+ }}
+ popover={}
+ value={name}
+ />
+
+ {
+ handleTypeChange(value as string);
+ }}
+ onFilterDelete={() => {
+ handleTypeChange();
+ }}
+ popover={}
+ value={type}
+ />
+
+ {hasFilters ? : null}
+
+ {selection.selectedAny ? (
+
+
+ {selection.selected.size} {t('selected')}
+
+
+
+ ) : null}
+
+
+
+ );
+}
+
+function TypeFilterPopover(): React.JSX.Element {
+ const { t } = useTranslation();
+ 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 NameFilterPopover(): React.JSX.Element {
+ const { t } = useTranslation();
+ 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 EmailFilterPopover(): React.JSX.Element {
+ const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
+ const [value, setValue] = React.useState('');
+ const { t } = useTranslation();
+
+ 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('');
+ const { t } = useTranslation();
+
+ 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/cr/categories/mf-categories-pagination.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-pagination.tsx
new file mode 100644
index 0000000..bf40481
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-pagination.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import * as React from 'react';
+import TablePagination from '@mui/material/TablePagination';
+
+function noop(): void {
+ return undefined;
+}
+
+interface LessonCategoriesPaginationProps {
+ count: number;
+ page: number;
+ //
+ setPage: (page: number) => void;
+ setRowsPerPage: (page: number) => void;
+ rowsPerPage: number;
+}
+
+export function LpCategoriesPagination({
+ count,
+ page,
+ //
+ setPage,
+ setRowsPerPage,
+ rowsPerPage,
+}: 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.
+ 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/cr/categories/mf-categories-selection-context.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-selection-context.tsx
new file mode 100644
index 0000000..a6c6502
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-selection-context.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import * as React from 'react';
+
+// import type { LessonCategory } from '@/types/lesson-type';
+import { useSelection } from '@/hooks/use-selection';
+import type { Selection } from '@/hooks/use-selection';
+
+import { LpCategory } from './type';
+
+function noop(): void {
+ return undefined;
+}
+
+export interface LpCategoriesSelectionContextValue extends Selection {}
+
+export const LpCategoriesSelectionContext = React.createContext({
+ deselectAll: noop,
+ deselectOne: noop,
+ selectAll: noop,
+ selectOne: noop,
+ selected: new Set(),
+ selectedAny: false,
+ selectedAll: false,
+});
+
+interface LpCategoriesSelectionProviderProps {
+ children: React.ReactNode;
+ lessonCategories: LpCategory[];
+}
+
+export function LpCategoriesSelectionProvider({
+ children,
+ lessonCategories = [],
+}: LpCategoriesSelectionProviderProps): React.JSX.Element {
+ const customerIds = React.useMemo(() => lessonCategories.map((customer) => customer.id), [lessonCategories]);
+ const selection = useSelection(customerIds);
+
+ return (
+ {children}
+ );
+}
+
+export function useLpCategoriesSelection(): LpCategoriesSelectionContextValue {
+ return React.useContext(LpCategoriesSelectionContext);
+}
diff --git a/002_source/cms/src/components/dashboard/cr/categories/mf-categories-table.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-table.tsx
new file mode 100644
index 0000000..26034f1
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-table.tsx
@@ -0,0 +1,241 @@
+'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 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 { useLpCategoriesSelection } from './lp-categories-selection-context';
+import type { LpCategory } from './type';
+
+function columns(handleDeleteClick: (testId: string) => void): ColumnDef[] {
+ return [
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+
+
+ {' '}
+
+ {row.cat_name}
+
+ slug: {row.cat_name}
+
+
+
+
+
+ ),
+ name: 'Name',
+ width: '200px',
+ },
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+ {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)}
+
+
+ ),
+ // NOTE: please refer to translation.json here
+ name: 'word-count',
+ width: '100px',
+ },
+ {
+ 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: (
+
+ ),
+ },
+ NA: {
+ label: 'NA',
+ 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: '100px',
+ },
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+
+ {
+ handleDeleteClick(row.id);
+ }}
+ >
+
+
+
+ ),
+ name: 'Actions',
+ hideName: true,
+ width: '100px',
+ align: 'right',
+ },
+ ];
+}
+
+export interface LessonCategoriesTableProps {
+ rows: LpCategory[];
+ reloadRows: () => void;
+}
+
+export function LpCategoriesTable({ rows, reloadRows }: LessonCategoriesTableProps): React.JSX.Element {
+ const { t } = useTranslation(['lp_categories']);
+ const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLpCategoriesSelection();
+
+ 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 ? (
+
+
+ {t('no-lesson-categories-found')}
+
+
+ ) : null}
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/cr/categories/mf-category-create-form.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-category-create-form.tsx
new file mode 100644
index 0000000..ba46ac7
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/mf-category-create-form.tsx
@@ -0,0 +1,419 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { useRouter } from 'next/navigation';
+import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { LoadingButton } from '@mui/lab';
+import { Avatar, Divider, MenuItem, Select } from '@mui/material';
+// 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 FormControl from '@mui/material/FormControl';
+import FormHelperText from '@mui/material/FormHelperText';
+import InputLabel from '@mui/material/InputLabel';
+import OutlinedInput from '@mui/material/OutlinedInput';
+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 axios from 'axios';
+import { Controller, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { z as zod } from 'zod';
+
+import { paths } from '@/paths';
+import isDevelopment from '@/lib/check-is-development';
+import { logger } from '@/lib/default-logger';
+import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
+import { pb } from '@/lib/pb';
+import { TextEditor } from '@/components/core/text-editor/text-editor';
+import { toast } from '@/components/core/toaster';
+
+import type { CreateFormProps } from './type';
+
+const schema = zod.object({
+ cat_name: zod.string().min(1, 'name-is-required').max(255),
+ cat_image: zod.array(zod.any()).optional(),
+ pos: zod.number().min(1, 'position is required').max(99),
+ init_answer: zod.string().optional(),
+ visible: zod.string(),
+ slug: zod.string().min(1, 'slug-is-required').max(255),
+ remarks: zod.string().optional(),
+ description: zod.string().optional(),
+ // NOTE: for image handling
+ avatar: zod.string().optional(),
+ // TODO: remove me
+ type: zod.string().optional(),
+ isActive: zod.boolean().optional(),
+ order: zod.number().optional(),
+});
+
+type Values = zod.infer;
+
+export const defaultValues = {
+ cat_name: '',
+ cat_image: undefined,
+ pos: 1,
+ init_answer: '',
+ visible: 'hidden',
+ slug: '',
+ remarks: '',
+ description: '',
+} satisfies Values;
+
+export function LpCategoryCreateForm(): React.JSX.Element {
+ const router = useRouter();
+ const { t } = useTranslation(['lp_categories']);
+
+ const [isCreating, setIsCreating] = React.useState(false);
+
+ const {
+ control,
+ handleSubmit,
+ formState: { errors },
+ setValue,
+ watch,
+ } = useForm({ defaultValues, resolver: zodResolver(schema) });
+
+ const onSubmit = React.useCallback(
+ async (values: Values): Promise => {
+ setIsCreating(true);
+
+ const payload: CreateFormProps = {
+ cat_name: values.cat_name,
+ cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null,
+ pos: values.pos,
+ init_answer: values.init_answer,
+ visible: values.visible,
+ slug: values.slug,
+ remarks: values.remarks,
+ description: values.description,
+ //
+ // TODO: remove me
+ type: 'type tet',
+ isActive: true,
+ order: 1,
+ };
+
+ try {
+ const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).create(payload);
+
+ logger.debug(result);
+ toast.success(t('create.success'));
+ router.push(paths.dashboard.lp_categories.list);
+ } catch (error) {
+ logger.error(error);
+ toast.error(t('create.failed'));
+ } finally {
+ setIsCreating(false);
+ }
+ },
+ // t is not necessary here
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [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/cr/categories/mf-category-edit-form.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-category-edit-form.tsx
new file mode 100644
index 0000000..c00e964
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/mf-category-edit-form.tsx
@@ -0,0 +1,500 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { useParams, useRouter } from 'next/navigation';
+//
+import { COL_QUIZ_LP_CATEGORIES } 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 { TextEditor } from '@/components/core/text-editor/text-editor';
+import { toast } from '@/components/core/toaster';
+import FormLoading from '@/components/loading';
+
+import ErrorDisplay from '../../error';
+import type { EditFormProps } from './type';
+
+// TODO: review this
+const schema = zod.object({
+ cat_name: zod.string().min(1, 'name-is-required').max(255),
+ // accept file object when user change image
+ // accept http string when user not changing image
+ cat_image: zod.union([zod.array(zod.any()), zod.string()]).optional(),
+
+ // position
+ pos: zod.number().min(1, 'position is required').max(99),
+
+ // it should be a valid JSON
+ init_answer: zod
+ .string()
+ .refine(
+ (value) => {
+ try {
+ JSON.parse(value);
+ return true;
+ } catch (error) {
+ return false;
+ }
+ },
+ { message: 'init_answer must be a valid JSON' }
+ )
+ .optional(),
+ visible: zod.string(),
+ slug: zod.string().min(0, 'slug-is-required').max(255).optional(),
+ remarks: zod.string().optional(),
+ description: zod.string().optional(),
+ // NOTE: for image handling
+ avatar: zod.string().optional(),
+});
+
+type Values = zod.infer;
+
+const defaultValues = {
+ cat_name: '',
+ cat_image: undefined,
+ pos: 1,
+ init_answer: JSON.stringify({}),
+ visible: 'hidden',
+ slug: '',
+ remarks: '',
+ description: '',
+} satisfies Values;
+
+export function LpCategoryEditForm(): React.JSX.Element {
+ const router = useRouter();
+ const { t } = useTranslation(['lp_categories']);
+
+ const { cat_id: catId } = useParams<{ cat_id: 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 tempUpdate: EditFormProps = {
+ cat_name: values.cat_name,
+ cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null,
+ pos: values.pos,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ init_answer: JSON.parse(values.init_answer || '{}'),
+
+ visible: values.visible,
+ slug: values.slug || 'not-defined',
+ remarks: values.remarks,
+ description: values.description,
+ //
+ // TODO: remove below
+ type: '',
+ };
+
+ try {
+ const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).update(catId, tempUpdate);
+ logger.debug(result);
+ toast.success(t('edit.success'));
+ router.push(paths.dashboard.lp_categories.list);
+ } catch (error) {
+ logger.error(error);
+ toast.error(t('update.failed'));
+ } finally {
+ setIsUpdating(false);
+ }
+ },
+ // t is not necessary here
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [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_QUIZ_LP_CATEGORIES).getOne(id);
+
+ reset({ ...defaultValues, ...result, init_answer: JSON.stringify(result.init_answer) });
+ setTextDescription(result.description);
+ setTextRemarks(result.remarks);
+
+ if (result.cat_image !== '') {
+ const fetchResult = await fetch(
+ `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.cat_image}`
+ );
+
+ const blob = await fetchResult.blob();
+ const url = await fileToBase64(blob);
+
+ setValue('avatar', url);
+ } else {
+ setValue('avatar', '');
+ }
+ } catch (error) {
+ logger.error(error);
+ toast(t('list.error'));
+
+ setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
+ } finally {
+ setShowLoading(false);
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [catId]
+ );
+
+ React.useEffect(() => {
+ void loadExistingData(catId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [catId]);
+
+ if (showLoading) return ;
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/cr/categories/notifications.tsx b/002_source/cms/src/components/dashboard/cr/categories/notifications.tsx
new file mode 100644
index 0000000..a6c16bd
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/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/cr/categories/payments.tsx b/002_source/cms/src/components/dashboard/cr/categories/payments.tsx
new file mode 100644
index 0000000..0420d32
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/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/cr/categories/shipping-address.tsx b/002_source/cms/src/components/dashboard/cr/categories/shipping-address.tsx
new file mode 100644
index 0000000..8793e5c
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/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/cr/categories/type.d.ts b/002_source/cms/src/components/dashboard/cr/categories/type.d.ts
new file mode 100644
index 0000000..c66a11f
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/categories/type.d.ts
@@ -0,0 +1,61 @@
+export interface LpCategory {
+ isEmpty?: boolean;
+ //
+ id: string;
+ collectionId: string;
+ //
+ cat_name: string;
+ cat_image_url?: string;
+ cat_image?: string;
+ pos: number;
+ visible: string;
+ lesson_id: string;
+ description: string;
+ remarks: string;
+ slug: string;
+ init_answer: any;
+ //
+ name: string;
+ avatar: string;
+ email: string;
+ phone: string;
+ quota: number;
+ status: 'pending' | 'active' | 'blocked' | 'NA';
+ createdAt: Date;
+}
+
+export interface CreateFormProps {
+ cat_name: string;
+ cat_image: File[] | null;
+ pos: number;
+ init_answer?: string;
+ visible: string;
+ slug: string;
+ remarks?: string;
+ description?: string;
+ //
+ // TODO: to remove
+ type: string;
+ isActive: boolean;
+ order: number;
+ name?: string;
+ imageUrl?: string;
+}
+
+export interface EditFormProps {
+ cat_name: string;
+ cat_image: File[] | null;
+ pos: number;
+ init_answer: any;
+ visible: string;
+ slug: string;
+ remarks?: string;
+ description?: string;
+ //
+ // TODO: remove below
+ type: string;
+}
+
+export interface Helloworld {
+ helloworld: string;
+}
diff --git a/002_source/cms/src/components/dashboard/cr/questions/_PROMPT.MD b/002_source/cms/src/components/dashboard/cr/questions/_PROMPT.MD
new file mode 100644
index 0000000..369cdca
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/_PROMPT.MD
@@ -0,0 +1,21 @@
+please review and add translations, e.g. `{t('[word]')}`
+
+---
+
+please help to review the `tsx` file in this folder
+`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/lp/questions`
+
+it was clone from
+`category`/`categories`, `lp_category`/`lp_categories`
+please help to modify to `question`/`questions`, `lp_question`/`lp_questions`
+
+please also help to modify the name of
+`variables`, `constants`, `functions`, `classes`, components's name, paths
+
+the db fields structures are the same
+
+do not move the files
+do not create directories
+keep current folder structure is important
+
+thanks
diff --git a/002_source/cms/src/components/dashboard/cr/questions/_constants.ts b/002_source/cms/src/components/dashboard/cr/questions/_constants.ts
new file mode 100644
index 0000000..95a4d17
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/_constants.ts
@@ -0,0 +1,44 @@
+import { dayjs } from '@/lib/dayjs';
+
+import { CreateFormProps, LpQuestion } from './type';
+
+export const defaultLpQuestion: LpQuestion = {
+ isEmpty: false,
+ id: 'default-id',
+ cat_name: 'default-question-name',
+ cat_image_url: undefined,
+ cat_image: undefined,
+ pos: 0,
+ visible: 'hidden',
+ lesson_id: 'default-lesson-id',
+ description: 'default-description',
+ remarks: 'default-remarks',
+ slug: '',
+ init_answer: {},
+ // from pocketbase
+ collectionId: '0000000000',
+ createdAt: dayjs('2099-01-01').toDate(),
+ //
+ name: '',
+ avatar: '',
+ email: '',
+ phone: '',
+ quota: 0,
+ status: 'NA',
+};
+
+// export const LpCategoryCreateFormDefault: CreateFormProps = {
+// name: '',
+// type: '',
+// pos: 1,
+// visible: 'visible',
+// description: '',
+// isActive: true,
+// order: 1,
+// imageUrl: '',
+// };
+
+export const emptyLpQuestion: LpQuestion = {
+ ...defaultLpQuestion,
+ isEmpty: true,
+};
diff --git a/002_source/cms/src/components/dashboard/cr/questions/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/cr/questions/confirm-delete-modal.tsx
new file mode 100644
index 0000000..4d5b267
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/confirm-delete-modal.tsx
@@ -0,0 +1,125 @@
+'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';
+
+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);
+ deleteQuizLPCategories(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/cr/questions/lp-question-create-form.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-question-create-form.tsx
new file mode 100644
index 0000000..a39ad69
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/lp-question-create-form.tsx
@@ -0,0 +1,419 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { useRouter } from 'next/navigation';
+import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { LoadingButton } from '@mui/lab';
+import { Avatar, Divider, MenuItem, Select } from '@mui/material';
+// 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 FormControl from '@mui/material/FormControl';
+import FormHelperText from '@mui/material/FormHelperText';
+import InputLabel from '@mui/material/InputLabel';
+import OutlinedInput from '@mui/material/OutlinedInput';
+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 axios from 'axios';
+import { Controller, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { z as zod } from 'zod';
+
+import { paths } from '@/paths';
+import isDevelopment from '@/lib/check-is-development';
+import { logger } from '@/lib/default-logger';
+import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
+import { pb } from '@/lib/pb';
+import { TextEditor } from '@/components/core/text-editor/text-editor';
+import { toast } from '@/components/core/toaster';
+
+import type { CreateFormProps } from './type';
+
+const schema = zod.object({
+ cat_name: zod.string().min(1, 'name-is-required').max(255),
+ cat_image: zod.array(zod.any()).optional(),
+ pos: zod.number().min(1, 'position is required').max(99),
+ init_answer: zod.string().optional(),
+ visible: zod.string(),
+ slug: zod.string().min(1, 'slug-is-required').max(255),
+ remarks: zod.string().optional(),
+ description: zod.string().optional(),
+ // NOTE: for image handling
+ avatar: zod.string().optional(),
+ // TODO: remove me
+ type: zod.string().optional(),
+ isActive: zod.boolean().optional(),
+ order: zod.number().optional(),
+});
+
+type Values = zod.infer;
+
+export const defaultValues = {
+ cat_name: '',
+ cat_image: undefined,
+ pos: 1,
+ init_answer: '',
+ visible: 'hidden',
+ slug: '',
+ remarks: '',
+ description: '',
+} satisfies Values;
+
+export function LpQuestionCreateForm(): React.JSX.Element {
+ const router = useRouter();
+ const { t } = useTranslation(['lp_categories']);
+
+ const [isCreating, setIsCreating] = React.useState(false);
+
+ const {
+ control,
+ handleSubmit,
+ formState: { errors },
+ setValue,
+ watch,
+ } = useForm({ defaultValues, resolver: zodResolver(schema) });
+
+ const onSubmit = React.useCallback(
+ async (values: Values): Promise => {
+ setIsCreating(true);
+
+ const payload: CreateFormProps = {
+ cat_name: values.cat_name,
+ cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null,
+ pos: values.pos,
+ init_answer: values.init_answer,
+ visible: values.visible,
+ slug: values.slug,
+ remarks: values.remarks,
+ description: values.description,
+ //
+ // TODO: remove me
+ type: 'type tet',
+ isActive: true,
+ order: 1,
+ };
+
+ try {
+ const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).create(payload);
+
+ logger.debug(result);
+ toast.success(t('create.success'));
+ router.push(paths.dashboard.lp_categories.list);
+ } catch (error) {
+ logger.error(error);
+ toast.error(t('create.failed'));
+ } finally {
+ setIsCreating(false);
+ }
+ },
+ // t is not necessary here
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [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/cr/questions/lp-question-edit-form.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-question-edit-form.tsx
new file mode 100644
index 0000000..a38d9b2
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/lp-question-edit-form.tsx
@@ -0,0 +1,500 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import { useParams, useRouter } from 'next/navigation';
+//
+import { COL_QUIZ_LP_CATEGORIES } 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 { TextEditor } from '@/components/core/text-editor/text-editor';
+import { toast } from '@/components/core/toaster';
+import FormLoading from '@/components/loading';
+
+import ErrorDisplay from '../../error';
+import type { EditFormProps } from './type';
+
+// TODO: review this
+const schema = zod.object({
+ cat_name: zod.string().min(1, 'name-is-required').max(255),
+ // accept file object when user change image
+ // accept http string when user not changing image
+ cat_image: zod.union([zod.array(zod.any()), zod.string()]).optional(),
+
+ // position
+ pos: zod.number().min(1, 'position is required').max(99),
+
+ // it should be a valid JSON
+ init_answer: zod
+ .string()
+ .refine(
+ (value) => {
+ try {
+ JSON.parse(value);
+ return true;
+ } catch (error) {
+ return false;
+ }
+ },
+ { message: 'init_answer must be a valid JSON' }
+ )
+ .optional(),
+ visible: zod.string(),
+ slug: zod.string().min(0, 'slug-is-required').max(255).optional(),
+ remarks: zod.string().optional(),
+ description: zod.string().optional(),
+ // NOTE: for image handling
+ avatar: zod.string().optional(),
+});
+
+type Values = zod.infer;
+
+const defaultValues = {
+ cat_name: '',
+ cat_image: undefined,
+ pos: 1,
+ init_answer: JSON.stringify({}),
+ visible: 'hidden',
+ slug: '',
+ remarks: '',
+ description: '',
+} satisfies Values;
+
+export function LpQuestionEditForm(): React.JSX.Element {
+ const router = useRouter();
+ const { t } = useTranslation(['lp_categories']);
+
+ const { cat_id: catId } = useParams<{ cat_id: 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 tempUpdate: EditFormProps = {
+ cat_name: values.cat_name,
+ cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null,
+ pos: values.pos,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ init_answer: JSON.parse(values.init_answer || '{}'),
+
+ visible: values.visible,
+ slug: values.slug || 'not-defined',
+ remarks: values.remarks,
+ description: values.description,
+ //
+ // TODO: remove below
+ type: '',
+ };
+
+ try {
+ const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).update(catId, tempUpdate);
+ logger.debug(result);
+ toast.success(t('edit.success'));
+ router.push(paths.dashboard.lp_categories.list);
+ } catch (error) {
+ logger.error(error);
+ toast.error(t('update.failed'));
+ } finally {
+ setIsUpdating(false);
+ }
+ },
+ // t is not necessary here
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [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_QUIZ_LP_CATEGORIES).getOne(id);
+
+ reset({ ...defaultValues, ...result, init_answer: JSON.stringify(result.init_answer) });
+ setTextDescription(result.description);
+ setTextRemarks(result.remarks);
+
+ if (result.cat_image !== '') {
+ const fetchResult = await fetch(
+ `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.cat_image}`
+ );
+
+ const blob = await fetchResult.blob();
+ const url = await fileToBase64(blob);
+
+ setValue('avatar', url);
+ } else {
+ setValue('avatar', '');
+ }
+ } catch (error) {
+ logger.error(error);
+ toast(t('list.error'));
+
+ setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
+ } finally {
+ setShowLoading(false);
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [catId]
+ );
+
+ React.useEffect(() => {
+ void loadExistingData(catId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [catId]);
+
+ if (showLoading) return ;
+ if (showError.show)
+ return (
+
+ );
+
+ return (
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/cr/questions/lp-questions-filters.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-filters.tsx
new file mode 100644
index 0000000..d5b5486
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-filters.tsx
@@ -0,0 +1,456 @@
+'use client';
+
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+// import { COL_LESSON_CATEGORIES } from '@/constants';
+import GetAllCount from '@/db/QuizListenings/GetAllCount';
+import GetHiddenCount from '@/db/QuizListenings/GetHiddenCount';
+import GetVisibleCount from '@/db/QuizListenings/GetVisibleCount';
+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 { useTranslation } from 'react-i18next';
+
+import { paths } from '@/paths';
+import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button';
+import { Option } from '@/components/core/option';
+
+import { useLpQuestionsSelection } from './lp-questions-selection-context';
+import { LpQuestion } from './type';
+
+export interface Filters {
+ email?: string;
+ phone?: string;
+ status?: string;
+ name?: string;
+ visible?: string;
+ type?: string;
+}
+
+export type SortDir = 'asc' | 'desc';
+
+export interface LpQuestionsFiltersProps {
+ filters?: Filters;
+ sortDir?: SortDir;
+ fullData: LpQuestion[];
+}
+
+export function LpQuestionsFilters({
+ filters = {},
+ sortDir = 'desc',
+ fullData,
+}: LpQuestionsFiltersProps): React.JSX.Element {
+ const { t } = useTranslation();
+ const { email, phone, status, name, visible, type } = filters;
+
+ const [totalCount, setTotalCount] = React.useState(0);
+ const [visibleCount, setVisibleCount] = React.useState(0);
+ const [hiddenCount, setHiddenCount] = React.useState(0);
+
+ const router = useRouter();
+
+ const selection = useLpQuestionsSelection();
+
+ function getVisible(): number {
+ return fullData.reduce((count, item: LpQuestion) => {
+ return item.visible === 'visible' ? count + 1 : count;
+ }, 0);
+ }
+
+ function getHidden(): number {
+ return fullData.reduce((count, item: LpQuestion) => {
+ return item.visible === 'hidden' ? count + 1 : count;
+ }, 0);
+ }
+
+ // The tabs should be generated using API data.
+ const tabs = [
+ { label: t('All'), value: '', count: totalCount },
+ // { label: 'Active', value: 'active', count: 3 },
+ // { label: 'Pending', value: 'pending', count: 1 },
+ // { label: 'Blocked', value: 'blocked', count: 1 },
+ { label: t('visible'), value: 'visible', count: visibleCount },
+ { label: t('hidden'), value: 'hidden', count: hiddenCount },
+ ] 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);
+ }
+
+ if (newFilters.name) {
+ searchParams.set('name', newFilters.name);
+ }
+
+ if (newFilters.type) {
+ searchParams.set('type', newFilters.type);
+ }
+
+ if (newFilters.visible) {
+ searchParams.set('visible', newFilters.visible);
+ }
+
+ // NOTE: modify according to COLLECTION
+ router.push(`${paths.dashboard.lp_questions.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 handleVisibleChange = React.useCallback(
+ (_: React.SyntheticEvent, value: string) => {
+ updateSearchParams({ ...filters, visible: value }, sortDir);
+ },
+ [updateSearchParams, filters, sortDir]
+ );
+
+ const handleNameChange = React.useCallback(
+ (value?: string) => {
+ updateSearchParams({ ...filters, name: value }, sortDir);
+ },
+ [updateSearchParams, filters, sortDir]
+ );
+
+ const handleTypeChange = React.useCallback(
+ (value?: string) => {
+ updateSearchParams({ ...filters, type: 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]
+ );
+
+ React.useEffect(() => {
+ const fetchCount = async (): Promise => {
+ try {
+ const tc = await GetAllCount();
+ setTotalCount(tc);
+
+ const vc = await GetVisibleCount();
+ setVisibleCount(vc);
+
+ const hc = await GetHiddenCount();
+ setHiddenCount(hc);
+ } catch (error) {
+ //
+ }
+ };
+ void fetchCount();
+ }, []);
+
+ const hasFilters = status || email || phone || visible || name || type;
+
+ return (
+
+
+ {tabs.map((tab) => (
+
+ }
+ iconPosition="end"
+ key={tab.value}
+ label={tab.label}
+ sx={{ minHeight: 'auto' }}
+ tabIndex={0}
+ value={tab.value}
+ />
+ ))}
+
+
+
+
+ {
+ handleNameChange(value as string);
+ }}
+ onFilterDelete={() => {
+ handleNameChange();
+ }}
+ popover={}
+ value={name}
+ />
+
+ {
+ handleTypeChange(value as string);
+ }}
+ onFilterDelete={() => {
+ handleTypeChange();
+ }}
+ popover={}
+ value={type}
+ />
+
+ {hasFilters ? : null}
+
+ {selection.selectedAny ? (
+
+
+ {selection.selected.size} {t('selected')}
+
+
+
+ ) : null}
+
+
+
+ );
+}
+
+function TypeFilterPopover(): React.JSX.Element {
+ const { t } = useTranslation();
+ 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 NameFilterPopover(): React.JSX.Element {
+ const { t } = useTranslation();
+ 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 EmailFilterPopover(): React.JSX.Element {
+ const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
+ const [value, setValue] = React.useState('');
+ const { t } = useTranslation();
+
+ 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('');
+ const { t } = useTranslation();
+
+ 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/cr/questions/lp-questions-pagination.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-pagination.tsx
new file mode 100644
index 0000000..deef393
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-pagination.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import * as React from 'react';
+import TablePagination from '@mui/material/TablePagination';
+
+function noop(): void {
+ return undefined;
+}
+
+interface LessonQuestionsPaginationProps {
+ count: number;
+ page: number;
+ //
+ setPage: (page: number) => void;
+ setRowsPerPage: (page: number) => void;
+ rowsPerPage: number;
+}
+
+export function LpQuestionsPagination({
+ count,
+ page,
+ //
+ setPage,
+ setRowsPerPage,
+ rowsPerPage,
+}: LessonQuestionsPaginationProps): 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/cr/questions/lp-questions-selection-context.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-selection-context.tsx
new file mode 100644
index 0000000..8d98b5a
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-selection-context.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import * as React from 'react';
+
+// import type { LessonCategory } from '@/types/lesson-type';
+import { useSelection } from '@/hooks/use-selection';
+import type { Selection } from '@/hooks/use-selection';
+
+import type { LpQuestion } from './type';
+
+function noop(): void {
+ return undefined;
+}
+
+export interface LpQuestionsSelectionContextValue extends Selection {}
+
+export const LpQuestionsSelectionContext = React.createContext({
+ deselectAll: noop,
+ deselectOne: noop,
+ selectAll: noop,
+ selectOne: noop,
+ selected: new Set(),
+ selectedAny: false,
+ selectedAll: false,
+});
+
+interface LpQuestionsSelectionProviderProps {
+ children: React.ReactNode;
+ lessonQuestions: LpQuestion[];
+}
+
+export function LpQuestionsSelectionProvider({
+ children,
+ lessonQuestions = [],
+}: LpQuestionsSelectionProviderProps): React.JSX.Element {
+ const customerIds = React.useMemo(() => lessonQuestions.map((customer) => customer.id), [lessonQuestions]);
+ const selection = useSelection(customerIds);
+
+ return (
+ {children}
+ );
+}
+
+export function useLpQuestionsSelection(): LpQuestionsSelectionContextValue {
+ return React.useContext(LpQuestionsSelectionContext);
+}
diff --git a/002_source/cms/src/components/dashboard/cr/questions/lp-questions-table.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-table.tsx
new file mode 100644
index 0000000..e78356c
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-table.tsx
@@ -0,0 +1,241 @@
+'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 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 { useLpQuestionsSelection } from './lp-questions-selection-context';
+import type { LpQuestion } from './type';
+
+function columns(handleDeleteClick: (testId: string) => void): ColumnDef[] {
+ return [
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+
+
+ {' '}
+
+ {row.cat_name}
+
+ slug: {row.cat_name}
+
+
+
+
+
+ ),
+ name: 'Name',
+ width: '200px',
+ },
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+ {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)}
+
+
+ ),
+ // NOTE: please refer to translation.json here
+ name: 'word-count',
+ width: '100px',
+ },
+ {
+ 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: (
+
+ ),
+ },
+ NA: {
+ label: 'NA',
+ 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: '100px',
+ },
+ {
+ formatter: (row): React.JSX.Element => (
+
+
+
+
+ {
+ handleDeleteClick(row.id);
+ }}
+ >
+
+
+
+ ),
+ name: 'Actions',
+ hideName: true,
+ width: '100px',
+ align: 'right',
+ },
+ ];
+}
+
+export interface LessonQuestionsTableProps {
+ rows: LpQuestion[];
+ reloadRows: () => void;
+}
+
+export function LpQuestionsTable({ rows, reloadRows }: LessonQuestionsTableProps): React.JSX.Element {
+ const { t } = useTranslation(['lp_categories']);
+ const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLpQuestionsSelection();
+
+ 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 ? (
+
+
+ {t('no-lesson-categories-found')}
+
+
+ ) : null}
+
+ );
+}
diff --git a/002_source/cms/src/components/dashboard/cr/questions/notifications.tsx b/002_source/cms/src/components/dashboard/cr/questions/notifications.tsx
new file mode 100644
index 0000000..a6c16bd
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/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/cr/questions/payments.tsx b/002_source/cms/src/components/dashboard/cr/questions/payments.tsx
new file mode 100644
index 0000000..0420d32
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/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/cr/questions/shipping-address.tsx b/002_source/cms/src/components/dashboard/cr/questions/shipping-address.tsx
new file mode 100644
index 0000000..8793e5c
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/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/cr/questions/type.d.ts b/002_source/cms/src/components/dashboard/cr/questions/type.d.ts
new file mode 100644
index 0000000..8975fb4
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/cr/questions/type.d.ts
@@ -0,0 +1,61 @@
+export interface LpQuestion {
+ isEmpty?: boolean;
+ //
+ id: string;
+ collectionId: string;
+ //
+ cat_name: string;
+ cat_image_url?: string;
+ cat_image?: string;
+ pos: number;
+ visible: string;
+ lesson_id: string;
+ description: string;
+ remarks: string;
+ slug: string;
+ init_answer: any;
+ //
+ name: string;
+ avatar: string;
+ email: string;
+ phone: string;
+ quota: number;
+ status: 'pending' | 'active' | 'blocked' | 'NA';
+ createdAt: Date;
+}
+
+export interface CreateFormProps {
+ cat_name: string;
+ cat_image: File[] | null;
+ pos: number;
+ init_answer?: string;
+ visible: string;
+ slug: string;
+ remarks?: string;
+ description?: string;
+ //
+ // TODO: to remove
+ type: string;
+ isActive: boolean;
+ order: number;
+ name?: string;
+ imageUrl?: string;
+}
+
+export interface EditFormProps {
+ cat_name: string;
+ cat_image: File[] | null;
+ pos: number;
+ init_answer: any;
+ visible: string;
+ slug: string;
+ remarks?: string;
+ description?: string;
+ //
+ // TODO: remove below
+ type: string;
+}
+
+export interface Helloworld {
+ helloworld: string;
+}