From 72bc7a67e21c55d7bcecc206a8a2dbf2783041b4 Mon Sep 17 00:00:00 2001 From: louiscklaw Date: Mon, 21 Apr 2025 05:56:09 +0800 Subject: [PATCH] update to mf_categories, --- .../[cat_id]/BasicDetailCard.tsx | 79 +++ .../mf_categories/[cat_id]/TitleCard.tsx | 73 +++ .../dashboard/mf_categories/[cat_id]/page.tsx | 138 +++++ .../mf_categories/[customerId]/page.tsx | 308 ----------- .../dashboard/mf_categories/create/page.tsx | 23 +- .../mf_categories/edit/[cat_id]/_PROMPT.md | 11 + .../mf_categories/edit/[cat_id]/page.tsx | 53 ++ .../mf-categories-sample-data.tsx | 90 ++++ .../src/app/dashboard/mf_categories/page.tsx | 325 +++++------- .../src/app/dashboard/mf_questions/page.tsx | 21 +- .../dashboard/mf_categories/_PROMPT.MD | 1 + .../dashboard/mf_categories/_constants.ts | 44 ++ .../mf_categories/confirm-delete-modal.tsx | 124 +++++ .../mf_categories/customer-create-form.tsx | 398 -------------- .../mf_categories/customers-filters.tsx | 241 --------- .../mf_categories/customers-pagination.tsx | 31 -- .../customers-selection-context.tsx | 43 -- .../mf_categories/customers-table.tsx | 139 ----- .../dashboard/mf_categories/helloworld.tsx | 3 - .../mf_categories/mf-categories-filters.tsx | 458 ++++++++++++++++ .../mf-categories-pagination.tsx | 49 ++ .../mf-categories-selection-context.tsx | 46 ++ .../mf_categories/mf-categories-table.tsx | 240 +++++++++ .../mf_categories/mf-category-create-form.tsx | 419 +++++++++++++++ .../mf_categories/mf-category-edit-form.tsx | 500 ++++++++++++++++++ .../dashboard/mf_categories/type.d.ts | 61 +++ 002_source/cms/src/constants.ts | 4 + 27 files changed, 2562 insertions(+), 1360 deletions(-) create mode 100644 002_source/cms/src/app/dashboard/mf_categories/[cat_id]/BasicDetailCard.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_categories/[cat_id]/TitleCard.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_categories/[cat_id]/page.tsx delete mode 100644 002_source/cms/src/app/dashboard/mf_categories/[customerId]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_categories/edit/[cat_id]/_PROMPT.md create mode 100644 002_source/cms/src/app/dashboard/mf_categories/edit/[cat_id]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_categories/mf-categories-sample-data.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/_PROMPT.MD create mode 100644 002_source/cms/src/components/dashboard/mf_categories/_constants.ts create mode 100644 002_source/cms/src/components/dashboard/mf_categories/confirm-delete-modal.tsx delete mode 100644 002_source/cms/src/components/dashboard/mf_categories/customer-create-form.tsx delete mode 100644 002_source/cms/src/components/dashboard/mf_categories/customers-filters.tsx delete mode 100644 002_source/cms/src/components/dashboard/mf_categories/customers-pagination.tsx delete mode 100644 002_source/cms/src/components/dashboard/mf_categories/customers-selection-context.tsx delete mode 100644 002_source/cms/src/components/dashboard/mf_categories/customers-table.tsx delete mode 100644 002_source/cms/src/components/dashboard/mf_categories/helloworld.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/mf-categories-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/mf-categories-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/mf-categories-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/mf-categories-table.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/mf-category-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/mf-category-edit-form.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/type.d.ts 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} + +
+
+
+ +
+ + ); +} 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 ( - - - -
- - - Customers - -
- - - - MV - -
- - Miron Vitold - } - label="Active" - size="small" - variant="outlined" - /> - - - miron.vitold@domain.com - -
-
-
- -
-
-
- - - - - - - - } - 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')} - + { + 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 - - + - + 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 ( -
- - - } spacing={4}> - - Account information - - - - - - - - - - Avatar - Min 400x400px, PNG or JPEG - - - - - - - ( - - Name - - {errors.name ? {errors.name.message} : null} - - )} - /> - - - ( - - Email address - - {errors.email ? {errors.email.message} : null} - - )} - /> - - - ( - - Phone number - - {errors.phone ? {errors.phone.message} : null} - - )} - /> - - - ( - - Company - - {errors.company ? {errors.company.message} : null} - - )} - /> - - - - - Billing information - - - ( - - Country - - {errors.billingAddress?.country ? ( - {errors.billingAddress?.country?.message} - ) : null} - - )} - /> - - - ( - - State - - {errors.billingAddress?.state ? ( - {errors.billingAddress?.state?.message} - ) : null} - - )} - /> - - - ( - - City - - {errors.billingAddress?.city ? ( - {errors.billingAddress?.city?.message} - ) : null} - - )} - /> - - - ( - - Zip code - - {errors.billingAddress?.zipCode ? ( - {errors.billingAddress?.zipCode?.message} - ) : null} - - )} - /> - - - ( - - Address - - {errors.billingAddress?.line1 ? ( - {errors.billingAddress?.line1?.message} - ) : null} - - )} - /> - - - ( - - Tax ID - - {errors.taxId ? {errors.taxId.message} : null} - - )} - /> - - - - - Shipping information - } label="Same as billing address" /> - - - Additional information - - - ( - - Timezone - - {errors.timezone ? {errors.timezone.message} : null} - - )} - /> - - - ( - - Language - - {errors.language ? {errors.language.message} : null} - - )} - /> - - - ( - - Currency - - {errors.currency ? {errors.currency.message} : null} - - )} - /> - - - - - - - - - - -
- ); -} 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 ( +
+ + + } + spacing={4} + > + + {t('create.basic-info')} + + + + + + + + + + {t('create.avatar')} + {t('create.avatarRequirements')} + + + + + + + ( + + {t('create.cat_name')} + + {errors.cat_name ? {errors.cat_name.message} : null} + + )} + /> + + {/* */} + + ( + + {t('create.pos')} + { + field.onChange(parseInt(e.target.value)); + }} + type="number" + /> + {errors.pos ? {errors.pos.message} : null} + + )} + /> + + {/* */} + + ( + + {t('create.slug')} + + {errors.slug ? {errors.slug.message} : null} + + )} + /> + + {/* */} + + ( + + {t('edit.visible')} + + + {errors.visible ? {errors.visible.message} : null} + + )} + /> + + {/* */} + + ( + + {t('create.init_answer')} + + {errors.init_answer ? {errors.init_answer.message} : null} + + )} + /> + + + + {/* */} + + {t('create.detail-information')} + + + { + return ( + + + {t('create.description')} + + + { + field.onChange({ target: { value: editor.getHTML() } }); + }} + placeholder={t('create.description.default')} + /> + + + ); + }} + /> + + + ( + + + {t('create.remarks')} + + + { + field.onChange({ target: { value: editor.getText() } }); + }} + hideToolbar + placeholder={t('create.remarks.default')} + /> + + + )} + /> + + + + {/* */} + + + + + + + {t('create.createButton')} + + + + +
{JSON.stringify({ errors }, null, 2)}
+
+
+ ); +} 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 ( +
+ + + } + spacing={4} + > + + {t('edit.basic-info')} + + + + + + + + + + {t('edit.avatar')} + {t('edit.avatarRequirements')} + + + + + + + ( + + {t('edit.cat_name')} + + {errors.cat_name ? {errors.cat_name.message} : null} + + )} + /> + + {/* */} + + ( + + {t('edit.pos')} + { + field.onChange(parseInt(e.target.value)); + }} + type="number" + /> + {errors.pos ? {errors.pos.message} : null} + + )} + /> + + {/* */} + + ( + + {t('edit.slug')} + + {errors.slug ? {errors.slug.message} : null} + + )} + /> + + {/* */} + + ( + + {t('edit.visible')} + + + {errors.visible ? {errors.visible.message} : null} + + )} + /> + + {/* */} + + ( + + {t('edit.init_answer')} + + {errors.init_answer ? {errors.init_answer.message} : null} + + )} + /> + + + + {/* */} + + {t('edit.detail-information')} + + + { + return ( + + + {t('edit.description')} + + + { + field.onChange({ target: { value: editor.getHTML() } }); + }} + placeholder={t('edit.description.default')} + /> + + + ); + }} + /> + + + ( + + + {t('edit.remarks')} + + + { + field.onChange({ target: { value: editor.getText() } }); + }} + hideToolbar + placeholder={t('edit.remarks.default')} + /> + + + )} + /> + + + + {/* */} + + + + + + + {t('edit.updateButton')} + + + +
+ ); +} 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, + // };