diff --git a/002_source/cms/src/app/dashboard/mf_categories/[cat_id]/BasicDetailCard.tsx b/002_source/cms/src/app/dashboard/mf_categories/[cat_id]/BasicDetailCard.tsx
new file mode 100644
index 0000000..02c98d1
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/mf_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/mf_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/mf_categories/[cat_id]/TitleCard.tsx b/002_source/cms/src/app/dashboard/mf_categories/[cat_id]/TitleCard.tsx
new file mode 100644
index 0000000..d2d5ed5
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/mf_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/mf_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/mf_categories/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/mf_categories/[cat_id]/page.tsx
new file mode 100644
index 0000000..cb708b5
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/mf_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 { COL_LISTENINGS_PRACTICE_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/mf_categories/_constants.ts';
+import { Notifications } from '@/components/dashboard/mf_categories/notifications';
+import type { LpCategory } from '@/components/dashboard/mf_categories/type';
+import FormLoading from '@/components/loading';
+
+import SampleAddressCard from '../../Sample/AddressCard';
+import { SampleNotifications } from '../../Sample/Notifications';
+import SamplePaymentCard from '../../Sample/SamplePaymentCard';
+import SampleSecurityCard from '../../Sample/SampleSecurityCard';
+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.mf_categories.edit(showLessonCategory.id));
+ }
+
+ React.useEffect(() => {
+ if (catId) {
+ pb.collection(COL_LISTENINGS_PRACTICE_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/mf_categories/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/mf_categories/[customerId]/page.tsx
deleted file mode 100644
index edf064e..0000000
--- a/002_source/cms/src/app/dashboard/mf_categories/[customerId]/page.tsx
+++ /dev/null
@@ -1,308 +0,0 @@
-import * as React from 'react';
-import type { Metadata } from 'next';
-import RouterLink from 'next/link';
-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 IconButton from '@mui/material/IconButton';
-import LinearProgress from '@mui/material/LinearProgress';
-import Link from '@mui/material/Link';
-import Stack from '@mui/material/Stack';
-import Typography from '@mui/material/Typography';
-import Grid from '@mui/material/Unstable_Grid2';
-import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
-import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
-import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
-import { CreditCard as CreditCardIcon } from '@phosphor-icons/react/dist/ssr/CreditCard';
-import { House as HouseIcon } from '@phosphor-icons/react/dist/ssr/House';
-import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
-import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
-import { ShieldWarning as ShieldWarningIcon } from '@phosphor-icons/react/dist/ssr/ShieldWarning';
-import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
-
-import { config } from '@/config';
-import { paths } from '@/paths';
-import { dayjs } from '@/lib/dayjs';
-import { PropertyItem } from '@/components/core/property-item';
-import { PropertyList } from '@/components/core/property-list';
-import { Notifications } from '@/components/dashboard/customer/notifications';
-import { Payments } from '@/components/dashboard/customer/payments';
-import type { Address } from '@/components/dashboard/customer/shipping-address';
-import { ShippingAddress } from '@/components/dashboard/customer/shipping-address';
-
-export const metadata = { title: `Details | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
-
-export default function Page(): React.JSX.Element {
- return (
-
-
-
-
-
-
-
- MV
-
-
-
- Miron Vitold
- }
- label="Active"
- size="small"
- variant="outlined"
- />
-
-
- miron.vitold@domain.com
-
-
-
-
- } variant="contained">
- Action
-
-
-
-
-
-
-
-
-
-
-
- }
- avatar={
-
-
-
- }
- title="Basic details"
- />
- }
- orientation="vertical"
- sx={{ '--PropertyItem-padding': '12px 24px' }}
- >
- {(
- [
- { key: 'Customer ID', value: },
- { key: 'Name', value: 'Miron Vitold' },
- { key: 'Email', value: 'miron.vitold@domain.com' },
- { key: 'Phone', value: '(425) 434-5535' },
- { key: 'Company', value: 'Devias IO' },
- {
- key: 'Quota',
- value: (
-
-
-
- 50%
-
-
- ),
- },
- ] satisfies { key: string; value: React.ReactNode }[]
- ).map(
- (item): React.JSX.Element => (
-
- )
- )}
-
-
-
-
-
-
- }
- title="Security"
- />
-
-
-
-
-
-
- A deleted customer cannot be restored. All data will be permanently removed.
-
-
-
-
-
-
-
-
-
-
- }>
- Edit
-
- }
- avatar={
-
-
-
- }
- title="Billing details"
- />
-
-
- } sx={{ '--PropertyItem-padding': '16px' }}>
- {(
- [
- { key: 'Credit card', value: '**** 4142' },
- { key: 'Country', value: 'United States' },
- { key: 'State', value: 'Michigan' },
- { key: 'City', value: 'Southfield' },
- { key: 'Address', value: '1721 Bartlett Avenue, 48034' },
- { key: 'Tax ID', value: 'EU87956621' },
- ] satisfies { key: string; value: React.ReactNode }[]
- ).map(
- (item): React.JSX.Element => (
-
- )
- )}
-
-
-
-
-
- }>
- Add
-
- }
- avatar={
-
-
-
- }
- title="Shipping addresses"
- />
-
-
- {(
- [
- {
- id: 'ADR-001',
- country: 'United States',
- state: 'Michigan',
- city: 'Lansing',
- zipCode: '48933',
- street: '480 Haven Lane',
- primary: true,
- },
- {
- id: 'ADR-002',
- country: 'United States',
- state: 'Missouri',
- city: 'Springfield',
- zipCode: '65804',
- street: '4807 Lighthouse Drive',
- },
- ] satisfies Address[]
- ).map((address) => (
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/002_source/cms/src/app/dashboard/mf_categories/create/page.tsx b/002_source/cms/src/app/dashboard/mf_categories/create/page.tsx
index a0460ab..009dfcb 100644
--- a/002_source/cms/src/app/dashboard/mf_categories/create/page.tsx
+++ b/002_source/cms/src/app/dashboard/mf_categories/create/page.tsx
@@ -1,19 +1,24 @@
+'use client';
+
+// RULES:
+// T.B.A.
+//
import * as React from 'react';
-import type { Metadata } from 'next';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
+import { useTranslation } from 'react-i18next';
-import { config } from '@/config';
import { paths } from '@/paths';
-import { CustomerCreateForm } from '@/components/dashboard/customer/customer-create-form';
-
-export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
+import { LpCategoryCreateForm } from '@/components/dashboard/mf_categories/mf-category-create-form';
export default function Page(): React.JSX.Element {
+ // RULES: follow the name of page directory
+ const { t } = useTranslation(['lp_categories']);
+
return (
- Customers
+ {t('title')}
- Create customer
+ {t('create.title')}
-
+
);
diff --git a/002_source/cms/src/app/dashboard/mf_categories/edit/[cat_id]/_PROMPT.md b/002_source/cms/src/app/dashboard/mf_categories/edit/[cat_id]/_PROMPT.md
new file mode 100644
index 0000000..abf4465
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/mf_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/mf_categories/edit/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/mf_categories/edit/[cat_id]/page.tsx
new file mode 100644
index 0000000..87eba1f
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/mf_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/mf_categories/mf-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/mf_categories/mf-categories-sample-data.tsx b/002_source/cms/src/app/dashboard/mf_categories/mf-categories-sample-data.tsx
new file mode 100644
index 0000000..0c1aa79
--- /dev/null
+++ b/002_source/cms/src/app/dashboard/mf_categories/mf-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/mf_categories/page.tsx b/002_source/cms/src/app/dashboard/mf_categories/page.tsx
index 552f8dd..f6f17d9 100644
--- a/002_source/cms/src/app/dashboard/mf_categories/page.tsx
+++ b/002_source/cms/src/app/dashboard/mf_categories/page.tsx
@@ -1,186 +1,93 @@
+'use client';
+
+// RULES:
+// contains list page for mf_categories (QuizLPCMFategories)
+// contain definition to collection only
+//
import * as React from 'react';
-import type { Metadata } from 'next';
+import { useRouter } from 'next/navigation';
+import { COL_LISTENINGS_PRACTICE_CATEGORIES, COL_MF_CATEGORIES } from '@/constants';
+import { LoadingButton } from '@mui/lab';
import Box from '@mui/material/Box';
-import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
+import type { ListResult, RecordModel } from 'pocketbase';
+import { useTranslation } from 'react-i18next';
-import { config } from '@/config';
-import { dayjs } from '@/lib/dayjs';
-import { CustomersFilters } from '@/components/dashboard/customer/customers-filters';
-import type { Filters } from '@/components/dashboard/customer/customers-filters';
-import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination';
-import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context';
-import { CustomersTable } from '@/components/dashboard/customer/customers-table';
-import type { Customer } from '@/components/dashboard/customer/customers-table';
-
-export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
-
-const customers = [
- {
- id: 'USR-005',
- name: 'Fran Perez',
- avatar: '/assets/avatar-5.png',
- email: 'fran.perez@domain.com',
- phone: '(815) 704-0045',
- quota: 50,
- status: 'active',
- createdAt: dayjs().subtract(1, 'hour').toDate(),
- },
- {
- id: 'USR-004',
- name: 'Penjani Inyene',
- avatar: '/assets/avatar-4.png',
- email: 'penjani.inyene@domain.com',
- phone: '(803) 937-8925',
- quota: 100,
- status: 'active',
- createdAt: dayjs().subtract(3, 'hour').toDate(),
- },
- {
- id: 'USR-003',
- name: 'Carson Darrin',
- avatar: '/assets/avatar-3.png',
- email: 'carson.darrin@domain.com',
- phone: '(715) 278-5041',
- quota: 10,
- status: 'blocked',
- createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(),
- },
- {
- id: 'USR-002',
- name: 'Siegbert Gottfried',
- avatar: '/assets/avatar-2.png',
- email: 'siegbert.gottfried@domain.com',
- phone: '(603) 766-0431',
- quota: 0,
- status: 'pending',
- createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(),
- },
- {
- id: 'USR-001',
- name: 'Miron Vitold',
- avatar: '/assets/avatar-1.png',
- email: 'miron.vitold@domain.com',
- phone: '(425) 434-5535',
- quota: 50,
- status: 'active',
- createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(),
- },
- {
- id: 'USR-005',
- name: 'Fran Perez',
- avatar: '/assets/avatar-5.png',
- email: 'fran.perez@domain.com',
- phone: '(815) 704-0045',
- quota: 50,
- status: 'active',
- createdAt: dayjs().subtract(1, 'hour').toDate(),
- },
- {
- id: 'USR-004',
- name: 'Penjani Inyene',
- avatar: '/assets/avatar-4.png',
- email: 'penjani.inyene@domain.com',
- phone: '(803) 937-8925',
- quota: 100,
- status: 'active',
- createdAt: dayjs().subtract(3, 'hour').toDate(),
- },
- {
- id: 'USR-003',
- name: 'Carson Darrin',
- avatar: '/assets/avatar-3.png',
- email: 'carson.darrin@domain.com',
- phone: '(715) 278-5041',
- quota: 10,
- status: 'blocked',
- createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(),
- },
- {
- id: 'USR-002',
- name: 'Siegbert Gottfried',
- avatar: '/assets/avatar-2.png',
- email: 'siegbert.gottfried@domain.com',
- phone: '(603) 766-0431',
- quota: 0,
- status: 'pending',
- createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(),
- },
- {
- id: 'USR-001',
- name: 'Miron Vitold',
- avatar: '/assets/avatar-1.png',
- email: 'miron.vitold@domain.com',
- phone: '(425) 434-5535',
- quota: 50,
- status: 'active',
- createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(),
- },
- {
- id: 'USR-005',
- name: 'Fran Perez',
- avatar: '/assets/avatar-5.png',
- email: 'fran.perez@domain.com',
- phone: '(815) 704-0045',
- quota: 50,
- status: 'active',
- createdAt: dayjs().subtract(1, 'hour').toDate(),
- },
- {
- id: 'USR-004',
- name: 'Penjani Inyene',
- avatar: '/assets/avatar-4.png',
- email: 'penjani.inyene@domain.com',
- phone: '(803) 937-8925',
- quota: 100,
- status: 'active',
- createdAt: dayjs().subtract(3, 'hour').toDate(),
- },
- {
- id: 'USR-003',
- name: 'Carson Darrin',
- avatar: '/assets/avatar-3.png',
- email: 'carson.darrin@domain.com',
- phone: '(715) 278-5041',
- quota: 10,
- status: 'blocked',
- createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(),
- },
- {
- id: 'USR-002',
- name: 'Siegbert Gottfried',
- avatar: '/assets/avatar-2.png',
- email: 'siegbert.gottfried@domain.com',
- phone: '(603) 766-0431',
- quota: 0,
- status: 'pending',
- createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(),
- },
- {
- id: 'USR-001',
- name: 'Miron Vitold',
- avatar: '/assets/avatar-1.png',
- email: 'miron.vitold@domain.com',
- phone: '(425) 434-5535',
- quota: 50,
- status: 'active',
- createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(),
- },
-] satisfies Customer[];
-
-interface PageProps {
- searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string };
-}
+import { paths } from '@/paths';
+import { pb } from '@/lib/pb';
+import ErrorDisplay from '@/components/dashboard/error';
+import { defaultLpCategory } from '@/components/dashboard/mf_categories/_constants';
+import { LpCategoriesFilters } from '@/components/dashboard/mf_categories/mf-categories-filters';
+import type { Filters } from '@/components/dashboard/mf_categories/mf-categories-filters';
+import { LpCategoriesPagination } from '@/components/dashboard/mf_categories/mf-categories-pagination';
+import { LpCategoriesSelectionProvider } from '@/components/dashboard/mf_categories/mf-categories-selection-context';
+import { LpCategoriesTable } from '@/components/dashboard/mf_categories/mf-categories-table';
+import type { LpCategory } from '@/components/dashboard/mf_categories/type';
+import FormLoading from '@/components/loading';
export default function Page({ searchParams }: PageProps): React.JSX.Element {
- const { email, phone, sortDir, status } = searchParams;
+ const { t } = useTranslation(['lp_categories']);
+ const { email, phone, sortDir, status, name, visible, type } = searchParams;
+ const router = useRouter();
+ const [lessonCategoriesData, setLessonCategoriesData] = React.useState([]);
+ //
- const sortedCustomers = applySort(customers, sortDir);
- const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status });
+ 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([]);
+
+ // currentPage 0 -> human page 1 (first human page)
+ 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_MF_CATEGORIES)
+ .getList(currentPage + 1, rowsPerPage, {});
+ const { items, totalItems } = models;
+ const tempLessonTypes: LpCategory[] = items.map((lt) => {
+ return { ...defaultLpCategory, ...lt };
+ });
+
+ setLessonCategoriesData(tempLessonTypes);
+ setRecordCount(totalItems);
+ setF(tempLessonTypes);
+ } catch (error) {
+ //
+ setShowError({ show: true, detail: JSON.stringify(error) });
+ } finally {
+ setShowLoading(false);
+ }
+ };
+
+ React.useEffect(() => {
+ void reloadRows();
+ }, [currentPage, rowsPerPage, listOption]);
+
+ if (showLoading) return ;
+
+ if (showError.show)
+ return (
+
+ );
return (
-
+
- Customers
+ {t('list.title')}
- } variant="contained">
- Add
-
+ {
+ setIsLoadingAddPage(true);
+ router.push(paths.dashboard.mf_categories.create);
+ }}
+ startIcon={}
+ variant="contained"
+ >
+ {t('list.add')}
+
-
+
-
+
-
+
-
+
-
+
);
@@ -220,7 +152,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
// Sorting and filtering has to be done on the server.
-function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Customer[] {
+function applySort(row: LpCategory[], sortDir: 'asc' | 'desc' | undefined): LpCategory[] {
return row.sort((a, b) => {
if (sortDir === 'asc') {
return a.createdAt.getTime() - b.createdAt.getTime();
@@ -230,7 +162,7 @@ function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Custom
});
}
-function applyFilters(row: Customer[], { email, phone, status }: Filters): Customer[] {
+function applyFilters(row: LpCategory[], { email, phone, status, name, visible }: Filters): LpCategory[] {
return row.filter((item) => {
if (email) {
if (!item.email?.toLowerCase().includes(email.toLowerCase())) {
@@ -250,6 +182,31 @@ function applyFilters(row: Customer[], { email, phone, status }: Filters): Custo
}
}
+ 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/mf_questions/page.tsx b/002_source/cms/src/app/dashboard/mf_questions/page.tsx
index 552f8dd..aedee94 100644
--- a/002_source/cms/src/app/dashboard/mf_questions/page.tsx
+++ b/002_source/cms/src/app/dashboard/mf_questions/page.tsx
@@ -192,25 +192,38 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
}}
>
-
+
Customers
- } variant="contained">
+ }
+ variant="contained"
+ >
Add
-
+
-
+
diff --git a/002_source/cms/src/components/dashboard/mf_categories/_PROMPT.MD b/002_source/cms/src/components/dashboard/mf_categories/_PROMPT.MD
new file mode 100644
index 0000000..93ce5a6
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_categories/_PROMPT.MD
@@ -0,0 +1 @@
+please review and add translations, e.g. `{t('[word]')}`
diff --git a/002_source/cms/src/components/dashboard/mf_categories/_constants.ts b/002_source/cms/src/components/dashboard/mf_categories/_constants.ts
new file mode 100644
index 0000000..885067e
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_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/mf_categories/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/mf_categories/confirm-delete-modal.tsx
new file mode 100644
index 0000000..83f4787
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_categories/confirm-delete-modal.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { COL_LESSON_TYPES } from '@/constants';
+import deleteQuizListening 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';
+
+const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL);
+
+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 performDelete(id: string): Promise {
+ return deleteQuizListening(id)
+ .then(() => {
+ toast(t('dashboard.lessonTypes.delete.success'));
+ reloadRows();
+ })
+ .catch((err) => {
+ logger.error(err);
+ toast(t('dashboard.lessonTypes.delete.error'));
+ })
+ .finally(() => {});
+ }
+
+ function handleUserConfirmDelete(): void {
+ if (idToDelete) {
+ setIsDeleteing(true);
+ performDelete(idToDelete)
+ .then(() => {
+ handleClose();
+ })
+ .catch((err) => {
+ // console.error(err)
+ logger.error(err);
+ toast(t('dashboard.lessonTypes.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/mf_categories/customer-create-form.tsx b/002_source/cms/src/components/dashboard/mf_categories/customer-create-form.tsx
deleted file mode 100644
index 7be8fc8..0000000
--- a/002_source/cms/src/components/dashboard/mf_categories/customer-create-form.tsx
+++ /dev/null
@@ -1,398 +0,0 @@
-'use client';
-
-import * as React from 'react';
-import RouterLink from 'next/link';
-import { useRouter } from 'next/navigation';
-import { zodResolver } from '@hookform/resolvers/zod';
-import Avatar from '@mui/material/Avatar';
-import Box from '@mui/material/Box';
-import Button from '@mui/material/Button';
-import Card from '@mui/material/Card';
-import CardActions from '@mui/material/CardActions';
-import CardContent from '@mui/material/CardContent';
-import Checkbox from '@mui/material/Checkbox';
-import Divider from '@mui/material/Divider';
-import FormControl from '@mui/material/FormControl';
-import FormControlLabel from '@mui/material/FormControlLabel';
-import FormHelperText from '@mui/material/FormHelperText';
-import InputLabel from '@mui/material/InputLabel';
-import OutlinedInput from '@mui/material/OutlinedInput';
-import Select from '@mui/material/Select';
-import Stack from '@mui/material/Stack';
-import Typography from '@mui/material/Typography';
-import Grid from '@mui/material/Unstable_Grid2';
-import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
-import { Controller, useForm } from 'react-hook-form';
-import { z as zod } from 'zod';
-
-import { paths } from '@/paths';
-import { logger } from '@/lib/default-logger';
-import { Option } from '@/components/core/option';
-import { toast } from '@/components/core/toaster';
-
-function fileToBase64(file: Blob): Promise {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = () => {
- resolve(reader.result as string);
- };
- reader.onerror = () => {
- reject(new Error('Error converting file to base64'));
- };
- });
-}
-
-const schema = zod.object({
- avatar: zod.string().optional(),
- name: zod.string().min(1, 'Name is required').max(255),
- email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
- phone: zod.string().min(1, 'Phone is required').max(15),
- company: zod.string().max(255),
- billingAddress: zod.object({
- country: zod.string().min(1, 'Country is required').max(255),
- state: zod.string().min(1, 'State is required').max(255),
- city: zod.string().min(1, 'City is required').max(255),
- zipCode: zod.string().min(1, 'Zip code is required').max(255),
- line1: zod.string().min(1, 'Street line 1 is required').max(255),
- line2: zod.string().max(255).optional(),
- }),
- taxId: zod.string().max(255).optional(),
- timezone: zod.string().min(1, 'Timezone is required').max(255),
- language: zod.string().min(1, 'Language is required').max(255),
- currency: zod.string().min(1, 'Currency is required').max(255),
-});
-
-type Values = zod.infer;
-
-const defaultValues = {
- avatar: '',
- name: '',
- email: '',
- phone: '',
- company: '',
- billingAddress: { country: '', state: '', city: '', zipCode: '', line1: '', line2: '' },
- taxId: '',
- timezone: 'new_york',
- language: 'en',
- currency: 'USD',
-} satisfies Values;
-
-export function CustomerCreateForm(): React.JSX.Element {
- const router = useRouter();
-
- const {
- control,
- handleSubmit,
- formState: { errors },
- setValue,
- watch,
- } = useForm({ defaultValues, resolver: zodResolver(schema) });
-
- const onSubmit = React.useCallback(
- async (_: Values): Promise => {
- try {
- // Make API request
- toast.success('Customer updated');
- router.push(paths.dashboard.customers.details('1'));
- } catch (err) {
- logger.error(err);
- toast.error('Something went wrong!');
- }
- },
- [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/mf_categories/customers-filters.tsx b/002_source/cms/src/components/dashboard/mf_categories/customers-filters.tsx
deleted file mode 100644
index 1567e3b..0000000
--- a/002_source/cms/src/components/dashboard/mf_categories/customers-filters.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-'use client';
-
-import * as React from 'react';
-import { useRouter } from 'next/navigation';
-import Button from '@mui/material/Button';
-import Chip from '@mui/material/Chip';
-import Divider from '@mui/material/Divider';
-import FormControl from '@mui/material/FormControl';
-import OutlinedInput from '@mui/material/OutlinedInput';
-import Select from '@mui/material/Select';
-import type { SelectChangeEvent } from '@mui/material/Select';
-import Stack from '@mui/material/Stack';
-import Tab from '@mui/material/Tab';
-import Tabs from '@mui/material/Tabs';
-import Typography from '@mui/material/Typography';
-
-import { paths } from '@/paths';
-import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button';
-import { Option } from '@/components/core/option';
-
-import { useCustomersSelection } from './customers-selection-context';
-
-// The tabs should be generated using API data.
-const tabs = [
- { label: 'All', value: '', count: 5 },
- { label: 'Active', value: 'active', count: 3 },
- { label: 'Pending', value: 'pending', count: 1 },
- { label: 'Blocked', value: 'blocked', count: 1 },
-] as const;
-
-export interface Filters {
- email?: string;
- phone?: string;
- status?: string;
-}
-
-export type SortDir = 'asc' | 'desc';
-
-export interface CustomersFiltersProps {
- filters?: Filters;
- sortDir?: SortDir;
-}
-
-export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFiltersProps): React.JSX.Element {
- const { email, phone, status } = filters;
-
- const router = useRouter();
-
- const selection = useCustomersSelection();
-
- const updateSearchParams = React.useCallback(
- (newFilters: Filters, newSortDir: SortDir): void => {
- const searchParams = new URLSearchParams();
-
- if (newSortDir === 'asc') {
- searchParams.set('sortDir', newSortDir);
- }
-
- if (newFilters.status) {
- searchParams.set('status', newFilters.status);
- }
-
- if (newFilters.email) {
- searchParams.set('email', newFilters.email);
- }
-
- if (newFilters.phone) {
- searchParams.set('phone', newFilters.phone);
- }
-
- router.push(`${paths.dashboard.customers.list}?${searchParams.toString()}`);
- },
- [router]
- );
-
- const handleClearFilters = React.useCallback(() => {
- updateSearchParams({}, sortDir);
- }, [updateSearchParams, sortDir]);
-
- const handleStatusChange = React.useCallback(
- (_: React.SyntheticEvent, value: string) => {
- updateSearchParams({ ...filters, status: value }, sortDir);
- },
- [updateSearchParams, filters, sortDir]
- );
-
- const handleEmailChange = React.useCallback(
- (value?: string) => {
- updateSearchParams({ ...filters, email: value }, sortDir);
- },
- [updateSearchParams, filters, sortDir]
- );
-
- const handlePhoneChange = React.useCallback(
- (value?: string) => {
- updateSearchParams({ ...filters, phone: value }, sortDir);
- },
- [updateSearchParams, filters, sortDir]
- );
-
- const handleSortChange = React.useCallback(
- (event: SelectChangeEvent) => {
- updateSearchParams(filters, event.target.value as SortDir);
- },
- [updateSearchParams, filters]
- );
-
- const hasFilters = status || email || phone;
-
- return (
-
-
- {tabs.map((tab) => (
- }
- iconPosition="end"
- key={tab.value}
- label={tab.label}
- sx={{ minHeight: 'auto' }}
- tabIndex={0}
- value={tab.value}
- />
- ))}
-
-
-
-
- {
- handleEmailChange(value as string);
- }}
- onFilterDelete={() => {
- handleEmailChange();
- }}
- popover={}
- value={email}
- />
- {
- handlePhoneChange(value as string);
- }}
- onFilterDelete={() => {
- handlePhoneChange();
- }}
- popover={}
- value={phone}
- />
- {hasFilters ? : null}
-
- {selection.selectedAny ? (
-
-
- {selection.selected.size} selected
-
-
-
- ) : null}
-
-
-
- );
-}
-
-function EmailFilterPopover(): React.JSX.Element {
- const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
- const [value, setValue] = React.useState('');
-
- React.useEffect(() => {
- setValue((initialValue as string | undefined) ?? '');
- }, [initialValue]);
-
- return (
-
-
- {
- setValue(event.target.value);
- }}
- onKeyUp={(event) => {
- if (event.key === 'Enter') {
- onApply(value);
- }
- }}
- value={value}
- />
-
-
-
- );
-}
-
-function PhoneFilterPopover(): React.JSX.Element {
- const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
- const [value, setValue] = React.useState('');
-
- React.useEffect(() => {
- setValue((initialValue as string | undefined) ?? '');
- }, [initialValue]);
-
- return (
-
-
- {
- setValue(event.target.value);
- }}
- onKeyUp={(event) => {
- if (event.key === 'Enter') {
- onApply(value);
- }
- }}
- value={value}
- />
-
-
-
- );
-}
diff --git a/002_source/cms/src/components/dashboard/mf_categories/customers-pagination.tsx b/002_source/cms/src/components/dashboard/mf_categories/customers-pagination.tsx
deleted file mode 100644
index ab01272..0000000
--- a/002_source/cms/src/components/dashboard/mf_categories/customers-pagination.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-'use client';
-
-import * as React from 'react';
-import TablePagination from '@mui/material/TablePagination';
-
-function noop(): void {
- return undefined;
-}
-
-interface CustomersPaginationProps {
- count: number;
- page: number;
-}
-
-export function CustomersPagination({ count, page }: CustomersPaginationProps): React.JSX.Element {
- // You should implement the pagination using a similar logic as the filters.
- // Note that when page change, you should keep the filter search params.
-
- return (
-
- );
-}
diff --git a/002_source/cms/src/components/dashboard/mf_categories/customers-selection-context.tsx b/002_source/cms/src/components/dashboard/mf_categories/customers-selection-context.tsx
deleted file mode 100644
index 023dbc0..0000000
--- a/002_source/cms/src/components/dashboard/mf_categories/customers-selection-context.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-'use client';
-
-import * as React from 'react';
-
-import { useSelection } from '@/hooks/use-selection';
-import type { Selection } from '@/hooks/use-selection';
-
-import type { Customer } from './customers-table';
-
-function noop(): void {
- return undefined;
-}
-
-export interface CustomersSelectionContextValue extends Selection {}
-
-export const CustomersSelectionContext = React.createContext({
- deselectAll: noop,
- deselectOne: noop,
- selectAll: noop,
- selectOne: noop,
- selected: new Set(),
- selectedAny: false,
- selectedAll: false,
-});
-
-interface CustomersSelectionProviderProps {
- children: React.ReactNode;
- customers: Customer[];
-}
-
-export function CustomersSelectionProvider({
- children,
- customers = [],
-}: CustomersSelectionProviderProps): React.JSX.Element {
- const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]);
- const selection = useSelection(customerIds);
-
- return {children};
-}
-
-export function useCustomersSelection(): CustomersSelectionContextValue {
- return React.useContext(CustomersSelectionContext);
-}
diff --git a/002_source/cms/src/components/dashboard/mf_categories/customers-table.tsx b/002_source/cms/src/components/dashboard/mf_categories/customers-table.tsx
deleted file mode 100644
index bf9b01a..0000000
--- a/002_source/cms/src/components/dashboard/mf_categories/customers-table.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-'use client';
-
-import * as React from 'react';
-import RouterLink from 'next/link';
-import Avatar from '@mui/material/Avatar';
-import Box from '@mui/material/Box';
-import Chip from '@mui/material/Chip';
-import IconButton from '@mui/material/IconButton';
-import LinearProgress from '@mui/material/LinearProgress';
-import Link from '@mui/material/Link';
-import Stack from '@mui/material/Stack';
-import Typography from '@mui/material/Typography';
-import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
-import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock';
-import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus';
-import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
-
-import { paths } from '@/paths';
-import { dayjs } from '@/lib/dayjs';
-import { DataTable } from '@/components/core/data-table';
-import type { ColumnDef } from '@/components/core/data-table';
-
-import { useCustomersSelection } from './customers-selection-context';
-
-export interface Customer {
- id: string;
- name: string;
- avatar?: string;
- email: string;
- phone?: string;
- quota: number;
- status: 'pending' | 'active' | 'blocked';
- createdAt: Date;
-}
-
-const columns = [
- {
- formatter: (row): React.JSX.Element => (
-
- {' '}
-
-
- {row.name}
-
-
- {row.email}
-
-
-
- ),
- name: 'Name',
- width: '250px',
- },
- {
- formatter: (row): React.JSX.Element => (
-
-
-
- {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)}
-
-
- ),
- name: 'Quota',
- width: '250px',
- },
- { field: 'phone', name: 'Phone number', width: '150px' },
- {
- formatter(row) {
- return dayjs(row.createdAt).format('MMM D, YYYY h:mm A');
- },
- name: 'Created at',
- width: '200px',
- },
- {
- formatter: (row): React.JSX.Element => {
- const mapping = {
- active: { label: 'Active', icon: },
- blocked: { label: 'Blocked', icon: },
- pending: { label: 'Pending', icon: },
- } as const;
- const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null };
-
- return ;
- },
- name: 'Status',
- width: '150px',
- },
- {
- formatter: (): React.JSX.Element => (
-
-
-
- ),
- name: 'Actions',
- hideName: true,
- width: '100px',
- align: 'right',
- },
-] satisfies ColumnDef[];
-
-export interface CustomersTableProps {
- rows: Customer[];
-}
-
-export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element {
- const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection();
-
- return (
-
-
- columns={columns}
- onDeselectAll={deselectAll}
- onDeselectOne={(_, row) => {
- deselectOne(row.id);
- }}
- onSelectAll={selectAll}
- onSelectOne={(_, row) => {
- selectOne(row.id);
- }}
- rows={rows}
- selectable
- selected={selected}
- />
- {!rows.length ? (
-
-
- No customers found
-
-
- ) : null}
-
- );
-}
diff --git a/002_source/cms/src/components/dashboard/mf_categories/helloworld.tsx b/002_source/cms/src/components/dashboard/mf_categories/helloworld.tsx
deleted file mode 100644
index 3989cb1..0000000
--- a/002_source/cms/src/components/dashboard/mf_categories/helloworld.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-const helloworld = 'helloworld';
-
-export { helloworld };
diff --git a/002_source/cms/src/components/dashboard/mf_categories/mf-categories-filters.tsx b/002_source/cms/src/components/dashboard/mf_categories/mf-categories-filters.tsx
new file mode 100644
index 0000000..a3200c6
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_categories/mf-categories-filters.tsx
@@ -0,0 +1,458 @@
+'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 { pb } from '@/lib/pb';
+import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button';
+import { Option } from '@/components/core/option';
+
+// import { LessonCategory } from '../mf_categories/type';
+import { useLpCategoriesSelection } from './mf-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.mf_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/mf_categories/mf-categories-pagination.tsx b/002_source/cms/src/components/dashboard/mf_categories/mf-categories-pagination.tsx
new file mode 100644
index 0000000..bf40481
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_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/mf_categories/mf-categories-selection-context.tsx b/002_source/cms/src/components/dashboard/mf_categories/mf-categories-selection-context.tsx
new file mode 100644
index 0000000..a6c6502
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_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/mf_categories/mf-categories-table.tsx b/002_source/cms/src/components/dashboard/mf_categories/mf-categories-table.tsx
new file mode 100644
index 0000000..cfb69cd
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_categories/mf-categories-table.tsx
@@ -0,0 +1,240 @@
+'use client';
+
+import * as React from 'react';
+import RouterLink from 'next/link';
+import Avatar from '@mui/material/Avatar';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import IconButton from '@mui/material/IconButton';
+import LinearProgress from '@mui/material/LinearProgress';
+import Link from '@mui/material/Link';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
+import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock';
+import { Images as ImagesIcon } from '@phosphor-icons/react/dist/ssr/Images';
+import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus';
+import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
+import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple';
+import { useTranslation } from 'react-i18next';
+import { toast } from 'sonner';
+
+import { paths } from '@/paths';
+import { dayjs } from '@/lib/dayjs';
+import { DataTable } from '@/components/core/data-table';
+import type { ColumnDef } from '@/components/core/data-table';
+
+import ConfirmDeleteModal from './confirm-delete-modal';
+import { useLpCategoriesSelection } from './mf-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/mf_categories/mf-category-create-form.tsx b/002_source/cms/src/components/dashboard/mf_categories/mf-category-create-form.tsx
new file mode 100644
index 0000000..bb6b7d0
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_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_LISTENINGS_PRACTICE_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_LISTENINGS_PRACTICE_CATEGORIES).create(payload);
+
+ logger.debug(result);
+ toast.success(t('create.success'));
+ router.push(paths.dashboard.mf_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/mf_categories/mf-category-edit-form.tsx b/002_source/cms/src/components/dashboard/mf_categories/mf-category-edit-form.tsx
new file mode 100644
index 0000000..ace1ccc
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_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_LISTENINGS_PRACTICE_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_LISTENINGS_PRACTICE_CATEGORIES).update(catId, tempUpdate);
+ logger.debug(result);
+ toast.success(t('edit.success'));
+ router.push(paths.dashboard.mf_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_LISTENINGS_PRACTICE_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/mf_categories/type.d.ts b/002_source/cms/src/components/dashboard/mf_categories/type.d.ts
new file mode 100644
index 0000000..c66a11f
--- /dev/null
+++ b/002_source/cms/src/components/dashboard/mf_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/constants.ts b/002_source/cms/src/constants.ts
index f86281d..5c8cb4d 100644
--- a/002_source/cms/src/constants.ts
+++ b/002_source/cms/src/constants.ts
@@ -10,6 +10,7 @@ const COL_USER_METAS = 'UserMetas';
// do not use LP_CATEGORIES
const COL_LISTENINGS_PRACTICE_CATEGORIES = 'QuizLPCategories';
const COL_QUIZ_LP_QUESTIONS = 'QuizLPQuestions';
+const COL_MF_CATEGORIES = 'QuizMFCategories';
export {
COL_LESSON_TYPES,
@@ -19,7 +20,10 @@ export {
NS_LESSON_CATEGORY,
COL_USERS,
COL_USER_METAS,
+ //
COL_LISTENINGS_PRACTICE_CATEGORIES,
COL_QUIZ_LP_QUESTIONS,
//
+ COL_MF_CATEGORIES,
+ //
};