From 375b0a359335798b7543d73a5919b90bfe619c98 Mon Sep 17 00:00:00 2001 From: louiscklaw Date: Tue, 22 Apr 2025 15:13:33 +0800 Subject: [PATCH] update init cr, no modification done yet, --- .../categories/[cat_id]/BasicDetailCard.tsx | 79 + .../cr/categories/[cat_id]/TitleCard.tsx | 73 + .../dashboard/cr/categories/[cat_id]/page.tsx | 138 ++ .../dashboard/cr/categories/create/page.tsx | 53 + .../cr/categories/edit/[cat_id]/_PROMPT.md | 11 + .../cr/categories/edit/[cat_id]/page.tsx | 53 + .../categories/lp-categories-sample-data.tsx | 90 + .../src/app/dashboard/cr/categories/page.tsx | 275 +++ .../cr/questions/[cat_id]/BasicDetailCard.tsx | 79 + .../cr/questions/[cat_id]/TitleCard.tsx | 73 + .../dashboard/cr/questions/[cat_id]/page.tsx | 138 ++ .../dashboard/cr/questions/create/page.tsx | 53 + .../cr/questions/edit/[cat_id]/_PROMPT.md | 11 + .../cr/questions/edit/[cat_id]/page.tsx | 53 + .../questions/lp-categories-sample-data.tsx | 90 + .../src/app/dashboard/cr/questions/page.tsx | 274 +++ .../src/app/dashboard/cr/repomix-output.xml | 1783 +++++++++++++++++ .../dashboard/cr/categories/_PROMPT.MD | 1 + .../dashboard/cr/categories/_constants.ts | 44 + .../cr/categories/confirm-delete-modal.tsx | 125 ++ .../cr/categories/lp-categories-filters.tsx | 456 +++++ .../categories/lp-categories-pagination.tsx | 52 + .../lp-categories-selection-context.tsx | 46 + .../cr/categories/lp-categories-table.tsx | 241 +++ .../cr/categories/lp-category-create-form.tsx | 419 ++++ .../cr/categories/lp-category-edit-form.tsx | 500 +++++ .../cr/categories/mf-categories-filters.tsx | 456 +++++ .../categories/mf-categories-pagination.tsx | 49 + .../mf-categories-selection-context.tsx | 46 + .../cr/categories/mf-categories-table.tsx | 241 +++ .../cr/categories/mf-category-create-form.tsx | 419 ++++ .../cr/categories/mf-category-edit-form.tsx | 500 +++++ .../dashboard/cr/categories/notifications.tsx | 101 + .../dashboard/cr/categories/payments.tsx | 138 ++ .../cr/categories/shipping-address.tsx | 46 + .../dashboard/cr/categories/type.d.ts | 61 + .../dashboard/cr/questions/_PROMPT.MD | 21 + .../dashboard/cr/questions/_constants.ts | 44 + .../cr/questions/confirm-delete-modal.tsx | 125 ++ .../cr/questions/lp-question-create-form.tsx | 419 ++++ .../cr/questions/lp-question-edit-form.tsx | 500 +++++ .../cr/questions/lp-questions-filters.tsx | 456 +++++ .../cr/questions/lp-questions-pagination.tsx | 49 + .../lp-questions-selection-context.tsx | 46 + .../cr/questions/lp-questions-table.tsx | 241 +++ .../dashboard/cr/questions/notifications.tsx | 101 + .../dashboard/cr/questions/payments.tsx | 138 ++ .../cr/questions/shipping-address.tsx | 46 + .../dashboard/cr/questions/type.d.ts | 61 + 49 files changed, 9514 insertions(+) create mode 100644 002_source/cms/src/app/dashboard/cr/categories/[cat_id]/BasicDetailCard.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/categories/[cat_id]/TitleCard.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/categories/[cat_id]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/categories/create/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/_PROMPT.md create mode 100644 002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/categories/lp-categories-sample-data.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/categories/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/questions/[cat_id]/BasicDetailCard.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/questions/[cat_id]/TitleCard.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/questions/[cat_id]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/questions/create/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/_PROMPT.md create mode 100644 002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/questions/lp-categories-sample-data.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/questions/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr/repomix-output.xml create mode 100644 002_source/cms/src/components/dashboard/cr/categories/_PROMPT.MD create mode 100644 002_source/cms/src/components/dashboard/cr/categories/_constants.ts create mode 100644 002_source/cms/src/components/dashboard/cr/categories/confirm-delete-modal.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/lp-categories-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/lp-categories-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/lp-categories-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/lp-categories-table.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/lp-category-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/lp-category-edit-form.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/mf-categories-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/mf-categories-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/mf-categories-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/mf-categories-table.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/mf-category-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/mf-category-edit-form.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/notifications.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/payments.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/shipping-address.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/categories/type.d.ts create mode 100644 002_source/cms/src/components/dashboard/cr/questions/_PROMPT.MD create mode 100644 002_source/cms/src/components/dashboard/cr/questions/_constants.ts create mode 100644 002_source/cms/src/components/dashboard/cr/questions/confirm-delete-modal.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/lp-question-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/lp-question-edit-form.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/lp-questions-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/lp-questions-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/lp-questions-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/lp-questions-table.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/notifications.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/payments.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/shipping-address.tsx create mode 100644 002_source/cms/src/components/dashboard/cr/questions/type.d.ts diff --git a/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/BasicDetailCard.tsx b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/BasicDetailCard.tsx new file mode 100644 index 0000000..6683056 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/BasicDetailCard.tsx @@ -0,0 +1,79 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User'; +import { useTranslation } from 'react-i18next'; + +import { PropertyItem } from '@/components/core/property-item'; +import { PropertyList } from '@/components/core/property-list'; +import { LpCategory } from '@/components/dashboard/lp/categories/type'; + +export default function BasicDetailCard({ + lpModel: model, + handleEditClick, +}: { + lpModel: LpCategory; + handleEditClick: () => void; +}): React.JSX.Element { + const { t } = useTranslation(); + + return ( + + { + handleEditClick(); + }} + > + + + } + avatar={ + + + + } + title={t('list.basic-details')} + /> + } + orientation="vertical" + sx={{ '--PropertyItem-padding': '12px 24px' }} + > + {( + [ + { + key: 'Customer ID', + value: ( + + ), + }, + { key: 'Name', value: model.cat_name }, + { key: 'Remarks', value: model.remarks }, + { key: 'Description', value: model.description }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + ); +} diff --git a/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/TitleCard.tsx b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/TitleCard.tsx new file mode 100644 index 0000000..6e38fb6 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/TitleCard.tsx @@ -0,0 +1,73 @@ +'use client'; + +import * as React from 'react'; +import { Button } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { useTranslation } from 'react-i18next'; + +import { LpCategory } from '@/components/dashboard/lp/categories/type'; + +function getImageUrlFrRecord(record: LpCategory): string { + return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.cat_image}`; +} + +export default function SampleTitleCard({ lpModel }: { lpModel: LpCategory }): React.JSX.Element { + const { t } = useTranslation(); + + return ( + <> + + + {t('empty')} + +
+ + {lpModel.cat_name} + + } + label={lpModel.visible} + size="small" + variant="outlined" + /> + + + {lpModel.slug} + +
+
+
+ +
+ + ); +} diff --git a/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/page.tsx new file mode 100644 index 0000000..c25de18 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/categories/[cat_id]/page.tsx @@ -0,0 +1,138 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import SampleAddressCard from '@/app/dashboard/Sample/AddressCard'; +import { SampleNotifications } from '@/app/dashboard/Sample/Notifications'; +import SamplePaymentCard from '@/app/dashboard/Sample/PaymentCard'; +import SampleSecurityCard from '@/app/dashboard/Sample/SecurityCard'; +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Grid from '@mui/material/Unstable_Grid2'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import type { RecordModel } from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; +import ErrorDisplay from '@/components/dashboard/error'; +import { defaultLpCategory } from '@/components/dashboard/lp/categories/_constants.ts'; +import { Notifications } from '@/components/dashboard/lp/categories/notifications'; +import type { LpCategory } from '@/components/dashboard/lp/categories/type'; +import FormLoading from '@/components/loading'; + +import BasicDetailCard from './BasicDetailCard'; +import TitleCard from './TitleCard'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + const router = useRouter(); + // + const { cat_id: catId } = useParams<{ cat_id: string }>(); + // + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + + // + const [showLessonCategory, setShowLessonCategory] = React.useState(defaultLpCategory); + + function handleEditClick() { + router.push(paths.dashboard.lp_categories.edit(showLessonCategory.id)); + } + + React.useEffect(() => { + if (catId) { + pb.collection(COL_QUIZ_LP_CATEGORIES) + .getOne(catId) + .then((model: RecordModel) => { + setShowLessonCategory({ ...defaultLpCategory, ...model }); + }) + .catch((err) => { + logger.error(err); + toast(t('list.error')); + + setShowError({ show: true, detail: JSON.stringify(err) }); + }) + .finally(() => { + setShowLoading(false); + }); + } + }, [catId]); + + if (showLoading) return ; + if (showError.show) + return ( + + ); + + return ( + + + +
+ + + {t('list.title')} + +
+ + + +
+ + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/cr/categories/create/page.tsx b/002_source/cms/src/app/dashboard/cr/categories/create/page.tsx new file mode 100644 index 0000000..4bc1776 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/categories/create/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +// RULES: +// T.B.A. +// +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LpCategoryCreateForm } from '@/components/dashboard/lp/categories/lp-category-create-form'; + +export default function Page(): React.JSX.Element { + // RULES: follow the name of page directory + const { t } = useTranslation(['lp_categories']); + + return ( + + + +
+ + + {t('title')} + +
+
+ {t('create.title')} +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/_PROMPT.md b/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/_PROMPT.md new file mode 100644 index 0000000..abf4465 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/_PROMPT.md @@ -0,0 +1,11 @@ +# task + +## instruction + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/_helloworld/page.tsx` + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx` + +please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_categories/edit/page.tsx` + +please draft a tsx for showing error to user thanks, diff --git a/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/page.tsx new file mode 100644 index 0000000..149a75b --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/categories/edit/[cat_id]/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LpCategoryEditForm } from '@/components/dashboard/lp/categories/lp-category-edit-form'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(['lp_categories']); + + React.useEffect(() => { + // console.log('helloworld'); + }, []); + + return ( + + + +
+ + + {t('edit.title')} + +
+
+ {t('edit.title')} +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/cr/categories/lp-categories-sample-data.tsx b/002_source/cms/src/app/dashboard/cr/categories/lp-categories-sample-data.tsx new file mode 100644 index 0000000..0c1aa79 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/categories/lp-categories-sample-data.tsx @@ -0,0 +1,90 @@ +import { dayjs } from '@/lib/dayjs'; +import { LessonCategory } from '@/components/dashboard/lesson_category/type'; + +export const LpCategoriesSampleData = [ + { + id: 'USR-005', + name: 'Fran Perez', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-003', + name: 'Carson Darrin', + avatar: '/assets/avatar-3.png', + email: 'carson.darrin@domain.com', + phone: '(715) 278-5041', + quota: 10, + status: 'blocked', + createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-002', + name: 'Siegbert Gottfried', + avatar: '/assets/avatar-2.png', + email: 'siegbert.gottfried@domain.com', + phone: '(603) 766-0431', + quota: 0, + status: 'pending', + createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-001', + name: 'Miron Vitold', + avatar: '/assets/avatar-1.png', + email: 'miron.vitold@domain.com', + phone: '(425) 434-5535', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, +] satisfies LessonCategory[]; diff --git a/002_source/cms/src/app/dashboard/cr/categories/page.tsx b/002_source/cms/src/app/dashboard/cr/categories/page.tsx new file mode 100644 index 0000000..93bb730 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/categories/page.tsx @@ -0,0 +1,275 @@ +'use client'; + +// RULES: +// contains list page for lp_categories (QuizLPCategories) +// contain definition to collection only +// +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import { LoadingButton } from '@mui/lab'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import type { ListResult, RecordModel } from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import isDevelopment from '@/lib/check-is-development'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; +import ErrorDisplay from '@/components/dashboard/error'; +import { defaultLpCategory } from '@/components/dashboard/lp/categories/_constants'; +import { LpCategoriesFilters } from '@/components/dashboard/lp/categories/lp-categories-filters'; +import type { Filters } from '@/components/dashboard/lp/categories/lp-categories-filters'; +import { LpCategoriesPagination } from '@/components/dashboard/lp/categories/lp-categories-pagination'; +import { LpCategoriesSelectionProvider } from '@/components/dashboard/lp/categories/lp-categories-selection-context'; +import { LpCategoriesTable } from '@/components/dashboard/lp/categories/lp-categories-table'; +import type { LpCategory } from '@/components/dashboard/lp/categories/type'; +import FormLoading from '@/components/loading'; + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + const { t } = useTranslation(['lp_categories']); + const { email, phone, sortDir, status, name, visible, type } = searchParams; + const router = useRouter(); + const [lessonCategoriesData, setLessonCategoriesData] = React.useState([]); + // + + const [isLoadingAddPage, setIsLoadingAddPage] = React.useState(false); + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + // + const [rowsPerPage, setRowsPerPage] = React.useState(5); + // + const [f, setF] = React.useState([]); + const [currentPage, setCurrentPage] = React.useState(0); + // + const [recordCount, setRecordCount] = React.useState(0); + const [listOption, setListOption] = React.useState({}); + const [listSort, setListSort] = React.useState({}); + + // + const sortedLessonCategories = applySort(lessonCategoriesData, sortDir); + const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status }); + + // + const reloadRows = async (): Promise => { + try { + const models: ListResult = await pb + .collection(COL_QUIZ_LP_CATEGORIES) + .getList(currentPage + 1, rowsPerPage, listOption); + const { items, totalItems } = models; + const tempLessonTypes: LpCategory[] = items.map((lt) => { + return { ...defaultLpCategory, ...lt }; + }); + + setLessonCategoriesData(tempLessonTypes); + setRecordCount(totalItems); + setF(tempLessonTypes); + // console.log({ currentPage, f }); + } catch (error) { + // + logger.error(error); + setShowError({ + // + show: true, + detail: JSON.stringify(error, null, 2), + }); + } finally { + setShowLoading(false); + } + }; + + const [lastListOption, setLastListOption] = React.useState({}); + const isFirstRun = React.useRef(false); + React.useEffect(() => { + if (!isFirstRun.current) { + isFirstRun.current = true; + } else { + if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) { + // reset page number as tab changes + setLastListOption(listOption); + setCurrentPage(0); + void reloadRows(); + } else { + void reloadRows(); + } + } + }, [currentPage, rowsPerPage, listOption]); + + React.useEffect(() => { + let tempFilter = [], + tempSortDir = ''; + + if (visible) { + tempFilter.push(`visible = "${visible}"`); + } + + if (sortDir) { + tempSortDir = `-created`; + } + + if (name) { + tempFilter.push(`name ~ "%${name}%"`); + } + + if (type) { + tempFilter.push(`type ~ "%${type}%"`); + } + + let preFinalListOption = {}; + if (tempFilter.length > 0) { + preFinalListOption = { filter: tempFilter.join(' && ') }; + } + if (tempSortDir.length > 0) { + preFinalListOption = { ...preFinalListOption, sort: tempSortDir }; + } + setListOption(preFinalListOption); + // setListOption({ + // filter: tempFilter.join(' && '), + // sort: tempSortDir, + // // + // }); + }, [visible, sortDir, name, type]); + + // return <>helloworld; + + if (showLoading) return ; + + if (showError.show) + return ( + + ); + + return ( + + + + + {t('list.title')} + + + { + setIsLoadingAddPage(true); + router.push(paths.dashboard.lp_categories.create); + }} + startIcon={} + variant="contained" + > + {t('list.add')} + + + + + + + + + + + + + + + + +
{JSON.stringify(f, null, 2)}
+
+
+ ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: LpCategory[], sortDir: 'asc' | 'desc' | undefined): LpCategory[] { + return row.sort((a, b) => { + if (sortDir === 'asc') { + return a.createdAt.getTime() - b.createdAt.getTime(); + } + + return b.createdAt.getTime() - a.createdAt.getTime(); + }); +} + +function applyFilters(row: LpCategory[], { email, phone, status, name, visible }: Filters): LpCategory[] { + return row.filter((item) => { + if (email) { + if (!item.email?.toLowerCase().includes(email.toLowerCase())) { + return false; + } + } + + if (phone) { + if (!item.phone?.toLowerCase().includes(phone.toLowerCase())) { + return false; + } + } + + if (status) { + if (item.status !== status) { + return false; + } + } + + if (name) { + if (!item.name?.toLowerCase().includes(name.toLowerCase())) { + return false; + } + } + + if (visible) { + if (!item.visible?.toLowerCase().includes(visible.toLowerCase())) { + return false; + } + } + + return true; + }); +} + +interface PageProps { + searchParams: { + email?: string; + phone?: string; + sortDir?: 'asc' | 'desc'; + status?: string; + name?: string; + visible?: string; + type?: string; + // + }; +} diff --git a/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/BasicDetailCard.tsx b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/BasicDetailCard.tsx new file mode 100644 index 0000000..6683056 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/BasicDetailCard.tsx @@ -0,0 +1,79 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User'; +import { useTranslation } from 'react-i18next'; + +import { PropertyItem } from '@/components/core/property-item'; +import { PropertyList } from '@/components/core/property-list'; +import { LpCategory } from '@/components/dashboard/lp/categories/type'; + +export default function BasicDetailCard({ + lpModel: model, + handleEditClick, +}: { + lpModel: LpCategory; + handleEditClick: () => void; +}): React.JSX.Element { + const { t } = useTranslation(); + + return ( + + { + handleEditClick(); + }} + > + + + } + avatar={ + + + + } + title={t('list.basic-details')} + /> + } + orientation="vertical" + sx={{ '--PropertyItem-padding': '12px 24px' }} + > + {( + [ + { + key: 'Customer ID', + value: ( + + ), + }, + { key: 'Name', value: model.cat_name }, + { key: 'Remarks', value: model.remarks }, + { key: 'Description', value: model.description }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + ); +} diff --git a/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/TitleCard.tsx b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/TitleCard.tsx new file mode 100644 index 0000000..6e38fb6 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/TitleCard.tsx @@ -0,0 +1,73 @@ +'use client'; + +import * as React from 'react'; +import { Button } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { useTranslation } from 'react-i18next'; + +import { LpCategory } from '@/components/dashboard/lp/categories/type'; + +function getImageUrlFrRecord(record: LpCategory): string { + return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.cat_image}`; +} + +export default function SampleTitleCard({ lpModel }: { lpModel: LpCategory }): React.JSX.Element { + const { t } = useTranslation(); + + return ( + <> + + + {t('empty')} + +
+ + {lpModel.cat_name} + + } + label={lpModel.visible} + size="small" + variant="outlined" + /> + + + {lpModel.slug} + +
+
+
+ +
+ + ); +} diff --git a/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/page.tsx new file mode 100644 index 0000000..08488f3 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/questions/[cat_id]/page.tsx @@ -0,0 +1,138 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import SampleAddressCard from '@/app/dashboard/Sample/AddressCard'; +import { SampleNotifications } from '@/app/dashboard/Sample/Notifications'; +import SamplePaymentCard from '@/app/dashboard/Sample/PaymentCard'; +import SampleSecurityCard from '@/app/dashboard/Sample/SecurityCard'; +import { COL_QUIZ_LP_QUESTIONS } from '@/constants'; +import { Grid } from '@mui/material'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import type { RecordModel } from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; +import ErrorDisplay from '@/components/dashboard/error'; +import { defaultLpQuestion } from '@/components/dashboard/lp/questions/_constants.ts'; +import { Notifications } from '@/components/dashboard/lp/questions/notifications'; +import type { LpQuestion } from '@/components/dashboard/lp/questions/type'; +import FormLoading from '@/components/loading'; + +import BasicDetailCard from './BasicDetailCard'; +import TitleCard from './TitleCard'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + const router = useRouter(); + // + const { cat_id: catId } = useParams<{ cat_id: string }>(); + // + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + + // + const [showLessonQuestion, setShowLessonQuestion] = React.useState(defaultLpQuestion); + + function handleEditClick() { + router.push(paths.dashboard.lp_questions.edit(showLessonQuestion.id)); + } + + React.useEffect(() => { + if (catId) { + pb.collection(COL_QUIZ_LP_QUESTIONS) + .getOne(catId) + .then((model: RecordModel) => { + setShowLessonQuestion({ ...defaultLpQuestion, ...model }); + }) + .catch((err) => { + logger.error(err); + toast(t('list.error')); + + setShowError({ show: true, detail: JSON.stringify(err) }); + }) + .finally(() => { + setShowLoading(false); + }); + } + }, [catId]); + + if (showLoading) return ; + if (showError.show) + return ( + + ); + + return ( + + + +
+ + + {t('edit.title')} + +
+ + + +
+ + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/cr/questions/create/page.tsx b/002_source/cms/src/app/dashboard/cr/questions/create/page.tsx new file mode 100644 index 0000000..ff3c9dc --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/questions/create/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +// RULES: +// T.B.A. +// +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LpQuestionCreateForm } from '@/components/dashboard/lp/questions/lp-question-create-form'; + +export default function Page(): React.JSX.Element { + // RULES: follow the name of page directory + const { t } = useTranslation(['lp_questions']); + + return ( + + + +
+ + + {t('title')} + +
+
+ {t('create.title')} +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/_PROMPT.md b/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/_PROMPT.md new file mode 100644 index 0000000..abf4465 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/_PROMPT.md @@ -0,0 +1,11 @@ +# task + +## instruction + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/_helloworld/page.tsx` + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx` + +please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_categories/edit/page.tsx` + +please draft a tsx for showing error to user thanks, diff --git a/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/page.tsx b/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/page.tsx new file mode 100644 index 0000000..321b5a9 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/questions/edit/[cat_id]/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LpQuestionEditForm } from '@/components/dashboard/lp/questions/lp-question-edit-form'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(['lp_questions']); + + React.useEffect(() => { + // console.log('helloworld'); + }, []); + + return ( + + + +
+ + + {t('edit.title')} + +
+
+ {t('edit.title')} +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/cr/questions/lp-categories-sample-data.tsx b/002_source/cms/src/app/dashboard/cr/questions/lp-categories-sample-data.tsx new file mode 100644 index 0000000..0c1aa79 --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/questions/lp-categories-sample-data.tsx @@ -0,0 +1,90 @@ +import { dayjs } from '@/lib/dayjs'; +import { LessonCategory } from '@/components/dashboard/lesson_category/type'; + +export const LpCategoriesSampleData = [ + { + id: 'USR-005', + name: 'Fran Perez', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-003', + name: 'Carson Darrin', + avatar: '/assets/avatar-3.png', + email: 'carson.darrin@domain.com', + phone: '(715) 278-5041', + quota: 10, + status: 'blocked', + createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-002', + name: 'Siegbert Gottfried', + avatar: '/assets/avatar-2.png', + email: 'siegbert.gottfried@domain.com', + phone: '(603) 766-0431', + quota: 0, + status: 'pending', + createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-001', + name: 'Miron Vitold', + avatar: '/assets/avatar-1.png', + email: 'miron.vitold@domain.com', + phone: '(425) 434-5535', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, +] satisfies LessonCategory[]; diff --git a/002_source/cms/src/app/dashboard/cr/questions/page.tsx b/002_source/cms/src/app/dashboard/cr/questions/page.tsx new file mode 100644 index 0000000..062b71d --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/questions/page.tsx @@ -0,0 +1,274 @@ +'use client'; + +// RULES: +// contains list page for lp_questions (QuizLPQuestions) +// contain definition to collection only +// +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { COL_QUIZ_LP_QUESTIONS } from '@/constants'; +import { LoadingButton } from '@mui/lab'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import type { ListResult, RecordModel } from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import isDevelopment from '@/lib/check-is-development'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; +import ErrorDisplay from '@/components/dashboard/error'; +import { defaultLpQuestion } from '@/components/dashboard/lp/questions/_constants'; +import { LpQuestionsFilters } from '@/components/dashboard/lp/questions/lp-questions-filters'; +import type { Filters } from '@/components/dashboard/lp/questions/lp-questions-filters'; +import { LpQuestionsPagination } from '@/components/dashboard/lp/questions/lp-questions-pagination'; +import { LpQuestionsSelectionProvider } from '@/components/dashboard/lp/questions/lp-questions-selection-context'; +import { LpQuestionsTable } from '@/components/dashboard/lp/questions/lp-questions-table'; +import type { LpQuestion } from '@/components/dashboard/lp/questions/type'; +import FormLoading from '@/components/loading'; + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + const { t } = useTranslation(['lp_questions']); + const { email, phone, sortDir, status, name, visible, type } = searchParams; + const router = useRouter(); + const [lessonQuestionsData, setLessonCategoriesData] = React.useState([]); + // + + const [isLoadingAddPage, setIsLoadingAddPage] = React.useState(false); + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + // + const [rowsPerPage, setRowsPerPage] = React.useState(5); + // + const [f, setF] = React.useState([]); + const [currentPage, setCurrentPage] = React.useState(0); + // + const [recordCount, setRecordCount] = React.useState(0); + const [listOption, setListOption] = React.useState({}); + const [listSort, setListSort] = React.useState({}); + + // + const sortedLessonCategories = applySort(lessonQuestionsData, sortDir); + const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status }); + + const reloadRows = async (): Promise => { + try { + const models: ListResult = await pb + .collection(COL_QUIZ_LP_QUESTIONS) + .getList(currentPage + 1, rowsPerPage, listOption); + const { items, totalItems } = models; + const tempLessonTypes: LpQuestion[] = items.map((lt) => { + return { ...defaultLpQuestion, ...lt }; + }); + + setLessonCategoriesData(tempLessonTypes); + setRecordCount(totalItems); + setF(tempLessonTypes); + // console.log({ currentPage, f }); + } catch (error) { + // + logger.error(error); + setShowError({ + // + show: true, + detail: JSON.stringify(error, null, 2), + }); + } finally { + setShowLoading(false); + } + }; + + const [lastListOption, setLastListOption] = React.useState({}); + const isFirstRun = React.useRef(false); + React.useEffect(() => { + if (!isFirstRun.current) { + isFirstRun.current = true; + } else { + if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) { + // reset page number as tab changes + setLastListOption(listOption); + setCurrentPage(0); + void reloadRows(); + } else { + void reloadRows(); + } + } + }, [currentPage, rowsPerPage, listOption]); + + React.useEffect(() => { + let tempFilter = [], + tempSortDir = ''; + + if (visible) { + tempFilter.push(`visible = "${visible}"`); + } + + if (sortDir) { + tempSortDir = `-created`; + } + + if (name) { + tempFilter.push(`name ~ "%${name}%"`); + } + + if (type) { + tempFilter.push(`type ~ "%${type}%"`); + } + + let preFinalListOption = {}; + if (tempFilter.length > 0) { + preFinalListOption = { filter: tempFilter.join(' && ') }; + } + if (tempSortDir.length > 0) { + preFinalListOption = { ...preFinalListOption, sort: tempSortDir }; + } + setListOption(preFinalListOption); + // setListOption({ + // filter: tempFilter.join(' && '), + // sort: tempSortDir, + // // + // }); + }, [visible, sortDir, name, type]); + + // return <>helloworld; + + if (showLoading) return ; + + if (showError.show) + return ( + + ); + + return ( + + + + + {t('list.title')} + + + { + setIsLoadingAddPage(true); + router.push(paths.dashboard.lp_questions.create); + }} + startIcon={} + variant="contained" + > + {t('list.add')} + + + + + + + + + + + + + + + + +
{JSON.stringify(f, null, 2)}
+
+
+ ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: LpQuestion[], sortDir: 'asc' | 'desc' | undefined): LpQuestion[] { + return row.sort((a, b) => { + if (sortDir === 'asc') { + return a.createdAt.getTime() - b.createdAt.getTime(); + } + + return b.createdAt.getTime() - a.createdAt.getTime(); + }); +} + +function applyFilters(row: LpQuestion[], { email, phone, status, name, visible }: Filters): LpQuestion[] { + return row.filter((item) => { + if (email) { + if (!item.email?.toLowerCase().includes(email.toLowerCase())) { + return false; + } + } + + if (phone) { + if (!item.phone?.toLowerCase().includes(phone.toLowerCase())) { + return false; + } + } + + if (status) { + if (item.status !== status) { + return false; + } + } + + if (name) { + if (!item.name?.toLowerCase().includes(name.toLowerCase())) { + return false; + } + } + + if (visible) { + if (!item.visible?.toLowerCase().includes(visible.toLowerCase())) { + return false; + } + } + + return true; + }); +} + +interface PageProps { + searchParams: { + email?: string; + phone?: string; + sortDir?: 'asc' | 'desc'; + status?: string; + name?: string; + visible?: string; + type?: string; + // + }; +} diff --git a/002_source/cms/src/app/dashboard/cr/repomix-output.xml b/002_source/cms/src/app/dashboard/cr/repomix-output.xml new file mode 100644 index 0000000..c11e03d --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr/repomix-output.xml @@ -0,0 +1,1783 @@ +This file is a merged representation of the entire codebase, combined into a single document by Repomix. + + +This section contains a summary of this file. + + +This file contains a packed representation of the entire repository's contents. +It is designed to be easily consumable by AI systems for analysis, code review, +or other automated processes. + + + +The content is organized as follows: +1. This summary section +2. Repository information +3. Directory structure +4. Repository files, each consisting of: + - File path as an attribute + - Full contents of the file + + + +- This file should be treated as read-only. Any changes should be made to the + original repository files, not this packed version. +- When processing this file, use the file path to distinguish + between different files in the repository. +- Be aware that this file may contain sensitive information. Handle it with + the same level of security as you would the original repository. + + + +- Some files may have been excluded based on .gitignore rules and Repomix's configuration +- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files +- Files matching patterns in .gitignore are excluded +- Files matching default ignore patterns are excluded +- Files are sorted by Git change count (files with more changes are at the bottom) + + + + + + + + + +categories/ + [cat_id]/ + BasicDetailCard.tsx + page.tsx + TitleCard.tsx + create/ + page copy 2.tsx + page copy.tsx + page.tsx + edit/ + [cat_id]/ + _PROMPT.md + page copy.tsx + page.tsx + lp-categories-sample-data.tsx + page.tsx +questions/ + [cat_id]/ + BasicDetailCard.tsx + page.tsx + TitleCard.tsx + create/ + page.tsx + edit/ + [cat_id]/ + _PROMPT.md + page.tsx + lp-categories-sample-data.tsx + page.tsx + + + +This section contains the contents of the repository's files. + + +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User'; +import { useTranslation } from 'react-i18next'; + +import { PropertyItem } from '@/components/core/property-item'; +import { PropertyList } from '@/components/core/property-list'; +import { LpCategory } from '@/components/dashboard/lp/categories/type'; + +export default function BasicDetailCard({ + lpModel: model, + handleEditClick, +}: { + lpModel: LpCategory; + handleEditClick: () => void; +}): React.JSX.Element { + const { t } = useTranslation(); + + return ( + + { + handleEditClick(); + }} + > + + + } + avatar={ + + + + } + title={t('list.basic-details')} + /> + } + orientation="vertical" + sx={{ '--PropertyItem-padding': '12px 24px' }} + > + {( + [ + { + key: 'Customer ID', + value: ( + + ), + }, + { key: 'Name', value: model.cat_name }, + { key: 'Remarks', value: model.remarks }, + { key: 'Description', value: model.description }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + ); +} + + + +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import SampleAddressCard from '@/app/dashboard/Sample/AddressCard'; +import { SampleNotifications } from '@/app/dashboard/Sample/Notifications'; +import SamplePaymentCard from '@/app/dashboard/Sample/PaymentCard'; +import SampleSecurityCard from '@/app/dashboard/Sample/SecurityCard'; +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Grid from '@mui/material/Unstable_Grid2'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import type { RecordModel } from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; +import ErrorDisplay from '@/components/dashboard/error'; +import { defaultLpCategory } from '@/components/dashboard/lp/categories/_constants.ts'; +import { Notifications } from '@/components/dashboard/lp/categories/notifications'; +import type { LpCategory } from '@/components/dashboard/lp/categories/type'; +import FormLoading from '@/components/loading'; + +import BasicDetailCard from './BasicDetailCard'; +import TitleCard from './TitleCard'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + const router = useRouter(); + // + const { cat_id: catId } = useParams<{ cat_id: string }>(); + // + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + + // + const [showLessonCategory, setShowLessonCategory] = React.useState(defaultLpCategory); + + function handleEditClick() { + router.push(paths.dashboard.lp_categories.edit(showLessonCategory.id)); + } + + React.useEffect(() => { + if (catId) { + pb.collection(COL_QUIZ_LP_CATEGORIES) + .getOne(catId) + .then((model: RecordModel) => { + setShowLessonCategory({ ...defaultLpCategory, ...model }); + }) + .catch((err) => { + logger.error(err); + toast(t('list.error')); + + setShowError({ show: true, detail: JSON.stringify(err) }); + }) + .finally(() => { + setShowLoading(false); + }); + } + }, [catId]); + + if (showLoading) return ; + if (showError.show) + return ( + + ); + + return ( + + + +
+ + + {t('list.title')} + +
+ + + +
+ + + + + + + + + + + + + + + +
+
+ ); +} +
+ + +'use client'; + +import * as React from 'react'; +import { Button } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { useTranslation } from 'react-i18next'; + +import { LpCategory } from '@/components/dashboard/lp/categories/type'; + +function getImageUrlFrRecord(record: LpCategory): string { + return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.cat_image}`; +} + +export default function SampleTitleCard({ lpModel }: { lpModel: LpCategory }): React.JSX.Element { + const { t } = useTranslation(); + + return ( + <> + + + {t('empty')} + +
+ + {lpModel.cat_name} + + } + label={lpModel.visible} + size="small" + variant="outlined" + /> + + + {lpModel.slug} + +
+
+
+ +
+ + ); +} +
+ + +'use client'; + +// RULES: +// T.B.A. +// +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LpCategoryCreateForm } from '@/components/dashboard/lp/categories/lp-category-create-form'; + +export default function Page(): React.JSX.Element { + // RULES: follow the name of page directory + const { t } = useTranslation(['lp_categories']); + + return ( + + + +
+ + + {t('title')} + +
+
+ {t('create.title')} +
+
+ +
+
+ ); +} +
+ + +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LessonCategoryCreateForm } from '@/components/dashboard/lesson_category/lesson-category-create-form'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + return ( + + + +
+ + + {t('title', { ns: 'lesson_category' })} + +
+
+ {t('create.title', { ns: 'lesson_category' })} +
+
+ +
+
+ ); +} +
+ + +'use client'; + +// RULES: +// T.B.A. +// +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LpCategoryCreateForm } from '@/components/dashboard/lp/categories/lp-category-create-form'; + +export default function Page(): React.JSX.Element { + // RULES: follow the name of page directory + const { t } = useTranslation(['lp_categories']); + + return ( + + + +
+ + + {t('title')} + +
+
+ {t('create.title')} +
+
+ +
+
+ ); +} +
+ + +# task + +## instruction + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/_helloworld/page.tsx` + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx` + +please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_categories/edit/page.tsx` + +please draft a tsx for showing error to user thanks, + + + +'use client'; + +import * as React from 'react'; + +export default function Page(): React.JSX.Element { + return <>helloworld; +} + + + +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LpCategoryEditForm } from '@/components/dashboard/lp/categories/lp-category-edit-form'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(['lp_categories']); + + React.useEffect(() => { + // console.log('helloworld'); + }, []); + + return ( + + + +
+ + + {t('edit.title')} + +
+
+ {t('edit.title')} +
+
+ +
+
+ ); +} +
+ + +import { dayjs } from '@/lib/dayjs'; +import { LessonCategory } from '@/components/dashboard/lesson_category/type'; + +export const LpCategoriesSampleData = [ + { + id: 'USR-005', + name: 'Fran Perez', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-003', + name: 'Carson Darrin', + avatar: '/assets/avatar-3.png', + email: 'carson.darrin@domain.com', + phone: '(715) 278-5041', + quota: 10, + status: 'blocked', + createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-002', + name: 'Siegbert Gottfried', + avatar: '/assets/avatar-2.png', + email: 'siegbert.gottfried@domain.com', + phone: '(603) 766-0431', + quota: 0, + status: 'pending', + createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-001', + name: 'Miron Vitold', + avatar: '/assets/avatar-1.png', + email: 'miron.vitold@domain.com', + phone: '(425) 434-5535', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, +] satisfies LessonCategory[]; + + + +'use client'; + +// RULES: +// contains list page for lp_categories (QuizLPCategories) +// contain definition to collection only +// +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import { LoadingButton } from '@mui/lab'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import type { ListResult, RecordModel } from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import isDevelopment from '@/lib/check-is-development'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; +import ErrorDisplay from '@/components/dashboard/error'; +import { defaultLpCategory } from '@/components/dashboard/lp/categories/_constants'; +import { LpCategoriesFilters } from '@/components/dashboard/lp/categories/lp-categories-filters'; +import type { Filters } from '@/components/dashboard/lp/categories/lp-categories-filters'; +import { LpCategoriesPagination } from '@/components/dashboard/lp/categories/lp-categories-pagination'; +import { LpCategoriesSelectionProvider } from '@/components/dashboard/lp/categories/lp-categories-selection-context'; +import { LpCategoriesTable } from '@/components/dashboard/lp/categories/lp-categories-table'; +import type { LpCategory } from '@/components/dashboard/lp/categories/type'; +import FormLoading from '@/components/loading'; + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + const { t } = useTranslation(['lp_categories']); + const { email, phone, sortDir, status, name, visible, type } = searchParams; + const router = useRouter(); + const [lessonCategoriesData, setLessonCategoriesData] = React.useState([]); + // + + const [isLoadingAddPage, setIsLoadingAddPage] = React.useState(false); + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + // + const [rowsPerPage, setRowsPerPage] = React.useState(5); + const [f, setF] = React.useState([]); + const [currentPage, setCurrentPage] = React.useState(1); + const [recordCount, setRecordCount] = React.useState(0); + const [listOption, setListOption] = React.useState({}); + const [listSort, setListSort] = React.useState({}); + + // + const sortedLessonCategories = applySort(lessonCategoriesData, sortDir); + const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status }); + + const reloadRows = async (): Promise => { + try { + const models: ListResult = await pb + .collection(COL_QUIZ_LP_CATEGORIES) + .getList(currentPage + 1, rowsPerPage, listOption); + const { items, totalItems } = models; + const tempLessonTypes: LpCategory[] = items.map((lt) => { + return { ...defaultLpCategory, ...lt }; + }); + + setLessonCategoriesData(tempLessonTypes); + setRecordCount(totalItems); + setF(tempLessonTypes); + // console.log({ currentPage, f }); + } catch (error) { + // + logger.error(error); + setShowError({ + // + show: true, + detail: JSON.stringify(error, null, 2), + }); + } finally { + setShowLoading(false); + } + }; + + const [lastListOption, setLastListOption] = React.useState({}); + const isFirstRun = React.useRef(false); + React.useEffect(() => { + if (!isFirstRun.current) { + isFirstRun.current = true; + } else { + if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) { + // reset page number as tab changes + setLastListOption(listOption); + setCurrentPage(0); + void reloadRows(); + } else { + void reloadRows(); + } + } + }, [currentPage, rowsPerPage, listOption]); + + React.useEffect(() => { + let tempFilter = [], + tempSortDir = ''; + + if (visible) { + tempFilter.push(`visible = "${visible}"`); + } + + if (sortDir) { + tempSortDir = `-created`; + } + + if (name) { + tempFilter.push(`name ~ "%${name}%"`); + } + + if (type) { + tempFilter.push(`type ~ "%${type}%"`); + } + + let preFinalListOption = {}; + if (tempFilter.length > 0) { + preFinalListOption = { filter: tempFilter.join(' && ') }; + } + if (tempSortDir.length > 0) { + preFinalListOption = { ...preFinalListOption, sort: tempSortDir }; + } + setListOption(preFinalListOption); + // setListOption({ + // filter: tempFilter.join(' && '), + // sort: tempSortDir, + // // + // }); + }, [visible, sortDir, name, type]); + + // return <>helloworld; + + if (showLoading) return ; + + if (showError.show) + return ( + + ); + + return ( + + + + + {t('list.title')} + + + { + setIsLoadingAddPage(true); + router.push(paths.dashboard.lp_categories.create); + }} + startIcon={} + variant="contained" + > + {t('list.add')} + + + + + + + + + + + + + + + + +
{JSON.stringify(f, null, 2)}
+
+
+ ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: LpCategory[], sortDir: 'asc' | 'desc' | undefined): LpCategory[] { + return row.sort((a, b) => { + if (sortDir === 'asc') { + return a.createdAt.getTime() - b.createdAt.getTime(); + } + + return b.createdAt.getTime() - a.createdAt.getTime(); + }); +} + +function applyFilters(row: LpCategory[], { email, phone, status, name, visible }: Filters): LpCategory[] { + return row.filter((item) => { + if (email) { + if (!item.email?.toLowerCase().includes(email.toLowerCase())) { + return false; + } + } + + if (phone) { + if (!item.phone?.toLowerCase().includes(phone.toLowerCase())) { + return false; + } + } + + if (status) { + if (item.status !== status) { + return false; + } + } + + if (name) { + if (!item.name?.toLowerCase().includes(name.toLowerCase())) { + return false; + } + } + + if (visible) { + if (!item.visible?.toLowerCase().includes(visible.toLowerCase())) { + return false; + } + } + + return true; + }); +} + +interface PageProps { + searchParams: { + email?: string; + phone?: string; + sortDir?: 'asc' | 'desc'; + status?: string; + name?: string; + visible?: string; + type?: string; + // + }; +} +
+ + +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User'; +import { useTranslation } from 'react-i18next'; + +import { PropertyItem } from '@/components/core/property-item'; +import { PropertyList } from '@/components/core/property-list'; +import { LpCategory } from '@/components/dashboard/lp/categories/type'; + +export default function BasicDetailCard({ + lpModel: model, + handleEditClick, +}: { + lpModel: LpCategory; + handleEditClick: () => void; +}): React.JSX.Element { + const { t } = useTranslation(); + + return ( + + { + handleEditClick(); + }} + > + + + } + avatar={ + + + + } + title={t('list.basic-details')} + /> + } + orientation="vertical" + sx={{ '--PropertyItem-padding': '12px 24px' }} + > + {( + [ + { + key: 'Customer ID', + value: ( + + ), + }, + { key: 'Name', value: model.cat_name }, + { key: 'Remarks', value: model.remarks }, + { key: 'Description', value: model.description }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + ); +} + + + +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import SampleAddressCard from '@/app/dashboard/Sample/AddressCard'; +import { SampleNotifications } from '@/app/dashboard/Sample/Notifications'; +import SamplePaymentCard from '@/app/dashboard/Sample/PaymentCard'; +import SampleSecurityCard from '@/app/dashboard/Sample/SecurityCard'; +import { COL_QUIZ_LP_QUESTIONS } from '@/constants'; +import { Grid } from '@mui/material'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import type { RecordModel } from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; +import ErrorDisplay from '@/components/dashboard/error'; +import { defaultLpQuestion } from '@/components/dashboard/lp/questions/_constants.ts'; +import { Notifications } from '@/components/dashboard/lp/questions/notifications'; +import type { LpQuestion } from '@/components/dashboard/lp/questions/type'; +import FormLoading from '@/components/loading'; + +import BasicDetailCard from './BasicDetailCard'; +import TitleCard from './TitleCard'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + const router = useRouter(); + // + const { cat_id: catId } = useParams<{ cat_id: string }>(); + // + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + + // + const [showLessonQuestion, setShowLessonQuestion] = React.useState(defaultLpQuestion); + + function handleEditClick() { + router.push(paths.dashboard.lp_questions.edit(showLessonQuestion.id)); + } + + React.useEffect(() => { + if (catId) { + pb.collection(COL_QUIZ_LP_QUESTIONS) + .getOne(catId) + .then((model: RecordModel) => { + setShowLessonQuestion({ ...defaultLpQuestion, ...model }); + }) + .catch((err) => { + logger.error(err); + toast(t('list.error')); + + setShowError({ show: true, detail: JSON.stringify(err) }); + }) + .finally(() => { + setShowLoading(false); + }); + } + }, [catId]); + + if (showLoading) return ; + if (showError.show) + return ( + + ); + + return ( + + + +
+ + + {t('edit.title')} + +
+ + + +
+ + + + + + + + + + + + + + + +
+
+ ); +} +
+ + +'use client'; + +import * as React from 'react'; +import { Button } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { useTranslation } from 'react-i18next'; + +import { LpCategory } from '@/components/dashboard/lp/categories/type'; + +function getImageUrlFrRecord(record: LpCategory): string { + return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.cat_image}`; +} + +export default function SampleTitleCard({ lpModel }: { lpModel: LpCategory }): React.JSX.Element { + const { t } = useTranslation(); + + return ( + <> + + + {t('empty')} + +
+ + {lpModel.cat_name} + + } + label={lpModel.visible} + size="small" + variant="outlined" + /> + + + {lpModel.slug} + +
+
+
+ +
+ + ); +} +
+ + +'use client'; + +// RULES: +// T.B.A. +// +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LpQuestionCreateForm } from '@/components/dashboard/lp/questions/lp-question-create-form'; + +export default function Page(): React.JSX.Element { + // RULES: follow the name of page directory + const { t } = useTranslation(['lp_questions']); + + return ( + + + +
+ + + {t('title')} + +
+
+ {t('create.title')} +
+
+ +
+
+ ); +} +
+ + +# task + +## instruction + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/_helloworld/page.tsx` + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx` + +please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_categories/edit/page.tsx` + +please draft a tsx for showing error to user thanks, + + + +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LpQuestionEditForm } from '@/components/dashboard/lp/questions/lp-question-edit-form'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(['lp_questions']); + + React.useEffect(() => { + // console.log('helloworld'); + }, []); + + return ( + + + +
+ + + {t('edit.title')} + +
+
+ {t('edit.title')} +
+
+ +
+
+ ); +} +
+ + +import { dayjs } from '@/lib/dayjs'; +import { LessonCategory } from '@/components/dashboard/lesson_category/type'; + +export const LpCategoriesSampleData = [ + { + id: 'USR-005', + name: 'Fran Perez', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-003', + name: 'Carson Darrin', + avatar: '/assets/avatar-3.png', + email: 'carson.darrin@domain.com', + phone: '(715) 278-5041', + quota: 10, + status: 'blocked', + createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-002', + name: 'Siegbert Gottfried', + avatar: '/assets/avatar-2.png', + email: 'siegbert.gottfried@domain.com', + phone: '(603) 766-0431', + quota: 0, + status: 'pending', + createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, + { + id: 'USR-001', + name: 'Miron Vitold', + avatar: '/assets/avatar-1.png', + email: 'miron.vitold@domain.com', + phone: '(425) 434-5535', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), + collectionId: '0000000001', + cat_name: '', + pos: 99, + visible: 'visible', + lesson_id: 'lid_00001', + description: '', + remarks: '', + }, +] satisfies LessonCategory[]; + + + +'use client'; + +// RULES: +// contains list page for lp_questions (QuizLPQuestions) +// contain definition to collection only +// +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { COL_QUIZ_LP_QUESTIONS } from '@/constants'; +import { LoadingButton } from '@mui/lab'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import type { ListResult, RecordModel } from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import isDevelopment from '@/lib/check-is-development'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; +import ErrorDisplay from '@/components/dashboard/error'; +import { defaultLpQuestion } from '@/components/dashboard/lp/questions/_constants'; +import { LpQuestionsFilters } from '@/components/dashboard/lp/questions/lp-questions-filters'; +import type { Filters } from '@/components/dashboard/lp/questions/lp-questions-filters'; +import { LpQuestionsPagination } from '@/components/dashboard/lp/questions/lp-questions-pagination'; +import { LpQuestionsSelectionProvider } from '@/components/dashboard/lp/questions/lp-questions-selection-context'; +import { LpQuestionsTable } from '@/components/dashboard/lp/questions/lp-questions-table'; +import type { LpQuestion } from '@/components/dashboard/lp/questions/type'; +import FormLoading from '@/components/loading'; + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + const { t } = useTranslation(['lp_questions']); + const { email, phone, sortDir, status, name, visible, type } = searchParams; + const router = useRouter(); + const [lessonQuestionsData, setLessonCategoriesData] = React.useState([]); + // + + const [isLoadingAddPage, setIsLoadingAddPage] = React.useState(false); + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + // + const [rowsPerPage, setRowsPerPage] = React.useState(5); + const [f, setF] = React.useState([]); + const [currentPage, setCurrentPage] = React.useState(0); + const [recordCount, setRecordCount] = React.useState(0); + const [listOption, setListOption] = React.useState({}); + const [listSort, setListSort] = React.useState({}); + + // + const sortedLessonCategories = applySort(lessonQuestionsData, sortDir); + const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status }); + + const reloadRows = async (): Promise => { + try { + const models: ListResult = await pb + .collection(COL_QUIZ_LP_QUESTIONS) + .getList(currentPage + 1, rowsPerPage, listOption); + const { items, totalItems } = models; + const tempLessonTypes: LpQuestion[] = items.map((lt) => { + return { ...defaultLpQuestion, ...lt }; + }); + + setLessonCategoriesData(tempLessonTypes); + setRecordCount(totalItems); + setF(tempLessonTypes); + // console.log({ currentPage, f }); + } catch (error) { + // + logger.error(error); + setShowError({ + // + show: true, + detail: JSON.stringify(error, null, 2), + }); + } finally { + setShowLoading(false); + } + }; + + const [lastListOption, setLastListOption] = React.useState({}); + const isFirstRun = React.useRef(false); + React.useEffect(() => { + if (!isFirstRun.current) { + isFirstRun.current = true; + } else { + if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) { + // reset page number as tab changes + setLastListOption(listOption); + setCurrentPage(0); + void reloadRows(); + } else { + void reloadRows(); + } + } + }, [currentPage, rowsPerPage, listOption]); + + React.useEffect(() => { + let tempFilter = [], + tempSortDir = ''; + + if (visible) { + tempFilter.push(`visible = "${visible}"`); + } + + if (sortDir) { + tempSortDir = `-created`; + } + + if (name) { + tempFilter.push(`name ~ "%${name}%"`); + } + + if (type) { + tempFilter.push(`type ~ "%${type}%"`); + } + + let preFinalListOption = {}; + if (tempFilter.length > 0) { + preFinalListOption = { filter: tempFilter.join(' && ') }; + } + if (tempSortDir.length > 0) { + preFinalListOption = { ...preFinalListOption, sort: tempSortDir }; + } + setListOption(preFinalListOption); + // setListOption({ + // filter: tempFilter.join(' && '), + // sort: tempSortDir, + // // + // }); + }, [visible, sortDir, name, type]); + + // return <>helloworld; + + if (showLoading) return ; + + if (showError.show) + return ( + + ); + + return ( + + + + + {t('list.title')} + + + { + setIsLoadingAddPage(true); + router.push(paths.dashboard.lp_questions.create); + }} + startIcon={} + variant="contained" + > + {t('list.add')} + + + + + + + + + + + + + + + + +
{JSON.stringify(f, null, 2)}
+
+
+ ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: LpQuestion[], sortDir: 'asc' | 'desc' | undefined): LpQuestion[] { + return row.sort((a, b) => { + if (sortDir === 'asc') { + return a.createdAt.getTime() - b.createdAt.getTime(); + } + + return b.createdAt.getTime() - a.createdAt.getTime(); + }); +} + +function applyFilters(row: LpQuestion[], { email, phone, status, name, visible }: Filters): LpQuestion[] { + return row.filter((item) => { + if (email) { + if (!item.email?.toLowerCase().includes(email.toLowerCase())) { + return false; + } + } + + if (phone) { + if (!item.phone?.toLowerCase().includes(phone.toLowerCase())) { + return false; + } + } + + if (status) { + if (item.status !== status) { + return false; + } + } + + if (name) { + if (!item.name?.toLowerCase().includes(name.toLowerCase())) { + return false; + } + } + + if (visible) { + if (!item.visible?.toLowerCase().includes(visible.toLowerCase())) { + return false; + } + } + + return true; + }); +} + +interface PageProps { + searchParams: { + email?: string; + phone?: string; + sortDir?: 'asc' | 'desc'; + status?: string; + name?: string; + visible?: string; + type?: string; + // + }; +} +
+ +
diff --git a/002_source/cms/src/components/dashboard/cr/categories/_PROMPT.MD b/002_source/cms/src/components/dashboard/cr/categories/_PROMPT.MD new file mode 100644 index 0000000..93ce5a6 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/_PROMPT.MD @@ -0,0 +1 @@ +please review and add translations, e.g. `{t('[word]')}` diff --git a/002_source/cms/src/components/dashboard/cr/categories/_constants.ts b/002_source/cms/src/components/dashboard/cr/categories/_constants.ts new file mode 100644 index 0000000..885067e --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/_constants.ts @@ -0,0 +1,44 @@ +import { dayjs } from '@/lib/dayjs'; + +import { CreateFormProps, LpCategory } from './type'; + +export const defaultLpCategory: LpCategory = { + isEmpty: false, + id: 'default-id', + cat_name: 'default-category-name', + cat_image_url: undefined, + cat_image: undefined, + pos: 0, + visible: 'hidden', + lesson_id: 'default-lesson-id', + description: 'default-description', + remarks: 'default-remarks', + slug: '', + init_answer: {}, + // from pocketbase + collectionId: '0000000000', + createdAt: dayjs('2099-01-01').toDate(), + // + name: '', + avatar: '', + email: '', + phone: '', + quota: 0, + status: 'NA', +}; + +// export const LpCategoryCreateFormDefault: CreateFormProps = { +// name: '', +// type: '', +// pos: 1, +// visible: 'visible', +// description: '', +// isActive: true, +// order: 1, +// imageUrl: '', +// }; + +export const emptyLpCategory: LpCategory = { + ...defaultLpCategory, + isEmpty: true, +}; diff --git a/002_source/cms/src/components/dashboard/cr/categories/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/cr/categories/confirm-delete-modal.tsx new file mode 100644 index 0000000..4d5b267 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/confirm-delete-modal.tsx @@ -0,0 +1,125 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { COL_LESSON_TYPES } from '@/constants'; +import deleteQuizLPCategories from '@/db/QuizListenings/Delete'; +import { LoadingButton } from '@mui/lab'; +import { Button, Container, Modal, Paper } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Note as NoteIcon } from '@phosphor-icons/react/dist/ssr/Note'; +import PocketBase from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { logger } from '@/lib/default-logger'; +import { toast } from '@/components/core/toaster'; + +export default function ConfirmDeleteModal({ + open, + setOpen, + idToDelete, + reloadRows, +}: { + open: boolean; + setOpen: (b: boolean) => void; + idToDelete: string; + reloadRows: () => void; +}): React.JSX.Element { + const { t } = useTranslation(); + + // const handleClose = () => setOpen(false); + function handleClose(): void { + setOpen(false); + } + + const [isDeleteing, setIsDeleteing] = React.useState(false); + const style = { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }; + + function handleUserConfirmDelete(): void { + if (idToDelete) { + setIsDeleteing(true); + deleteQuizLPCategories(idToDelete) + .then(() => { + reloadRows(); + handleClose(); + toast(t('delete.success')); + }) + .catch((err) => { + // console.error(err) + logger.error(err); + toast(t('delete.error')); + }) + .finally(() => { + setIsDeleteing(false); + }); + } + } + + return ( +
+ + + + + + + + + + + {t('Delete Lesson Type ?')} + + {t('Are you sure you want to delete lesson type ?')} + + + + + { + handleUserConfirmDelete(); + }} + loading={isDeleteing} + > + {t('Delete')} + + + + + + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/lp-categories-filters.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-filters.tsx new file mode 100644 index 0000000..7573cc6 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-filters.tsx @@ -0,0 +1,456 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +// import { COL_LESSON_CATEGORIES } from '@/constants'; +import GetAllCount from '@/db/QuizListenings/GetAllCount'; +import GetHiddenCount from '@/db/QuizListenings/GetHiddenCount'; +import GetVisibleCount from '@/db/QuizListenings/GetVisibleCount'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button'; +import { Option } from '@/components/core/option'; + +import { useLpCategoriesSelection } from './lp-categories-selection-context'; +import { LpCategory } from './type'; + +export interface Filters { + email?: string; + phone?: string; + status?: string; + name?: string; + visible?: string; + type?: string; +} + +export type SortDir = 'asc' | 'desc'; + +export interface LpCategoriesFiltersProps { + filters?: Filters; + sortDir?: SortDir; + fullData: LpCategory[]; +} + +export function LpCategoriesFilters({ + filters = {}, + sortDir = 'desc', + fullData, +}: LpCategoriesFiltersProps): React.JSX.Element { + const { t } = useTranslation(); + const { email, phone, status, name, visible, type } = filters; + + const [totalCount, setTotalCount] = React.useState(0); + const [visibleCount, setVisibleCount] = React.useState(0); + const [hiddenCount, setHiddenCount] = React.useState(0); + + const router = useRouter(); + + const selection = useLpCategoriesSelection(); + + function getVisible(): number { + return fullData.reduce((count, item: LpCategory) => { + return item.visible === 'visible' ? count + 1 : count; + }, 0); + } + + function getHidden(): number { + return fullData.reduce((count, item: LpCategory) => { + return item.visible === 'hidden' ? count + 1 : count; + }, 0); + } + + // The tabs should be generated using API data. + const tabs = [ + { label: t('All'), value: '', count: totalCount }, + // { label: 'Active', value: 'active', count: 3 }, + // { label: 'Pending', value: 'pending', count: 1 }, + // { label: 'Blocked', value: 'blocked', count: 1 }, + { label: t('visible'), value: 'visible', count: visibleCount }, + { label: t('hidden'), value: 'hidden', count: hiddenCount }, + ] as const; + + const updateSearchParams = React.useCallback( + (newFilters: Filters, newSortDir: SortDir): void => { + const searchParams = new URLSearchParams(); + + if (newSortDir === 'asc') { + searchParams.set('sortDir', newSortDir); + } + + if (newFilters.status) { + searchParams.set('status', newFilters.status); + } + + if (newFilters.email) { + searchParams.set('email', newFilters.email); + } + + if (newFilters.phone) { + searchParams.set('phone', newFilters.phone); + } + + if (newFilters.name) { + searchParams.set('name', newFilters.name); + } + + if (newFilters.type) { + searchParams.set('type', newFilters.type); + } + + if (newFilters.visible) { + searchParams.set('visible', newFilters.visible); + } + + // NOTE: modify according to COLLECTION + router.push(`${paths.dashboard.lp_categories.list}?${searchParams.toString()}`); + }, + [router] + ); + + const handleClearFilters = React.useCallback(() => { + updateSearchParams({}, sortDir); + }, [updateSearchParams, sortDir]); + + const handleStatusChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, status: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleVisibleChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, visible: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleNameChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, name: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleTypeChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, type: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleEmailChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, email: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handlePhoneChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, phone: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleSortChange = React.useCallback( + (event: SelectChangeEvent) => { + updateSearchParams(filters, event.target.value as SortDir); + }, + [updateSearchParams, filters] + ); + + React.useEffect(() => { + const fetchCount = async (): Promise => { + try { + const tc = await GetAllCount(); + setTotalCount(tc); + + const vc = await GetVisibleCount(); + setVisibleCount(vc); + + const hc = await GetHiddenCount(); + setHiddenCount(hc); + } catch (error) { + // + } + }; + void fetchCount(); + }, []); + + const hasFilters = status || email || phone || visible || name || type; + + return ( +
+ + {tabs.map((tab) => ( + + } + iconPosition="end" + key={tab.value} + label={tab.label} + sx={{ minHeight: 'auto' }} + tabIndex={0} + value={tab.value} + /> + ))} + + + + + { + handleNameChange(value as string); + }} + onFilterDelete={() => { + handleNameChange(); + }} + popover={} + value={name} + /> + + { + handleTypeChange(value as string); + }} + onFilterDelete={() => { + handleTypeChange(); + }} + popover={} + value={type} + /> + + {hasFilters ? : null} + + {selection.selectedAny ? ( + + + {selection.selected.size} {t('selected')} + + + + ) : null} + + +
+ ); +} + +function TypeFilterPopover(): React.JSX.Element { + const { t } = useTranslation(); + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function NameFilterPopover(): React.JSX.Element { + const { t } = useTranslation(); + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function EmailFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + const { t } = useTranslation(); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function PhoneFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + const { t } = useTranslation(); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/lp-categories-pagination.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-pagination.tsx new file mode 100644 index 0000000..21bdbef --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-pagination.tsx @@ -0,0 +1,52 @@ +'use client'; + +// lp-categories-pagination.tsx +// RULES: +// T.B.A. +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface LpCategoriesPaginationProps { + count: number; + page: number; + // + setPage: (page: number) => void; + setRowsPerPage: (page: number) => void; + rowsPerPage: number; +} + +export function LpCategoriesPagination({ + count, + page, + // + setPage, + setRowsPerPage, + rowsPerPage, +}: LpCategoriesPaginationProps): React.JSX.Element { + // You should implement the pagination using a similar logic as the filters. + // Note that when page change, you should keep the filter search params. + const handleChangePage = (event: unknown, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value)); + // console.log(parseInt(event.target.value)); + }; + + return ( + + ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/lp-categories-selection-context.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-selection-context.tsx new file mode 100644 index 0000000..a6c6502 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-selection-context.tsx @@ -0,0 +1,46 @@ +'use client'; + +import * as React from 'react'; + +// import type { LessonCategory } from '@/types/lesson-type'; +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import { LpCategory } from './type'; + +function noop(): void { + return undefined; +} + +export interface LpCategoriesSelectionContextValue extends Selection {} + +export const LpCategoriesSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface LpCategoriesSelectionProviderProps { + children: React.ReactNode; + lessonCategories: LpCategory[]; +} + +export function LpCategoriesSelectionProvider({ + children, + lessonCategories = [], +}: LpCategoriesSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => lessonCategories.map((customer) => customer.id), [lessonCategories]); + const selection = useSelection(customerIds); + + return ( + {children} + ); +} + +export function useLpCategoriesSelection(): LpCategoriesSelectionContextValue { + return React.useContext(LpCategoriesSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/lp-categories-table.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-table.tsx new file mode 100644 index 0000000..26034f1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/lp-categories-table.tsx @@ -0,0 +1,241 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { LoadingButton } from '@mui/lab'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import LinearProgress from '@mui/material/LinearProgress'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock'; +import { Images as ImagesIcon } from '@phosphor-icons/react/dist/ssr/Images'; +import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +import { paths } from '@/paths'; +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; + +import ConfirmDeleteModal from './confirm-delete-modal'; +import { useLpCategoriesSelection } from './lp-categories-selection-context'; +import type { LpCategory } from './type'; + +function columns(handleDeleteClick: (testId: string) => void): ColumnDef[] { + return [ + { + formatter: (row): React.JSX.Element => ( + + + + + + {' '} +
+ {row.cat_name} + + slug: {row.cat_name} + +
+
+ +
+ ), + name: 'Name', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)} + + + ), + // NOTE: please refer to translation.json here + name: 'word-count', + width: '100px', + }, + { + formatter: (row): React.JSX.Element => { + // eslint-disable-next-line react-hooks/rules-of-hooks + + const mapping = { + active: { + label: 'Active', + icon: ( + + ), + }, + blocked: { label: 'Blocked', icon: }, + pending: { + label: 'Pending', + icon: ( + + ), + }, + NA: { + label: 'NA', + icon: ( + + ), + }, + } as const; + const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null }; + + return ( + + ); + }, + name: 'Status', + width: '150px', + }, + { + formatter(row) { + return dayjs(row.createdAt).format('MMM D, YYYY'); + }, + name: 'Created at', + width: '100px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + + { + handleDeleteClick(row.id); + }} + > + + + + ), + name: 'Actions', + hideName: true, + width: '100px', + align: 'right', + }, + ]; +} + +export interface LessonCategoriesTableProps { + rows: LpCategory[]; + reloadRows: () => void; +} + +export function LpCategoriesTable({ rows, reloadRows }: LessonCategoriesTableProps): React.JSX.Element { + const { t } = useTranslation(['lp_categories']); + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLpCategoriesSelection(); + + const [idToDelete, setIdToDelete] = React.useState(''); + const [open, setOpen] = React.useState(false); + + function handleDeleteClick(testId: string): void { + setOpen(true); + setIdToDelete(testId); + } + + return ( + + + + columns={columns(handleDeleteClick)} + onDeselectAll={deselectAll} + onDeselectOne={(_, row) => { + deselectOne(row.id); + }} + onSelectAll={selectAll} + onSelectOne={(_, row) => { + selectOne(row.id); + }} + rows={rows} + selectable + selected={selected} + /> + {!rows.length ? ( + + + {t('no-lesson-categories-found')} + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/lp-category-create-form.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-category-create-form.tsx new file mode 100644 index 0000000..ba46ac7 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/lp-category-create-form.tsx @@ -0,0 +1,419 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useRouter } from 'next/navigation'; +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LoadingButton } from '@mui/lab'; +import { Avatar, Divider, MenuItem, Select } from '@mui/material'; +// import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +// import axios from 'axios'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import isDevelopment from '@/lib/check-is-development'; +import { logger } from '@/lib/default-logger'; +import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; +import { pb } from '@/lib/pb'; +import { TextEditor } from '@/components/core/text-editor/text-editor'; +import { toast } from '@/components/core/toaster'; + +import type { CreateFormProps } from './type'; + +const schema = zod.object({ + cat_name: zod.string().min(1, 'name-is-required').max(255), + cat_image: zod.array(zod.any()).optional(), + pos: zod.number().min(1, 'position is required').max(99), + init_answer: zod.string().optional(), + visible: zod.string(), + slug: zod.string().min(1, 'slug-is-required').max(255), + remarks: zod.string().optional(), + description: zod.string().optional(), + // NOTE: for image handling + avatar: zod.string().optional(), + // TODO: remove me + type: zod.string().optional(), + isActive: zod.boolean().optional(), + order: zod.number().optional(), +}); + +type Values = zod.infer; + +export const defaultValues = { + cat_name: '', + cat_image: undefined, + pos: 1, + init_answer: '', + visible: 'hidden', + slug: '', + remarks: '', + description: '', +} satisfies Values; + +export function LpCategoryCreateForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(['lp_categories']); + + const [isCreating, setIsCreating] = React.useState(false); + + const { + control, + handleSubmit, + formState: { errors }, + setValue, + watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsCreating(true); + + const payload: CreateFormProps = { + cat_name: values.cat_name, + cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null, + pos: values.pos, + init_answer: values.init_answer, + visible: values.visible, + slug: values.slug, + remarks: values.remarks, + description: values.description, + // + // TODO: remove me + type: 'type tet', + isActive: true, + order: 1, + }; + + try { + const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).create(payload); + + logger.debug(result); + toast.success(t('create.success')); + router.push(paths.dashboard.lp_categories.list); + } catch (error) { + logger.error(error); + toast.error(t('create.failed')); + } finally { + setIsCreating(false); + } + }, + // t is not necessary here + // eslint-disable-next-line react-hooks/exhaustive-deps + [router] + ); + + const avatarInputRef = React.useRef(null); + const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + const url = await fileToBase64(file); + setValue('avatar', url); + } + }, + [setValue] + ); + + return ( +
+ + + } + 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/cr/categories/lp-category-edit-form.tsx b/002_source/cms/src/components/dashboard/cr/categories/lp-category-edit-form.tsx new file mode 100644 index 0000000..c00e964 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/lp-category-edit-form.tsx @@ -0,0 +1,500 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +// +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LoadingButton } from '@mui/lab'; +// +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +// +import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +// +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; +import { pb } from '@/lib/pb'; +import { TextEditor } from '@/components/core/text-editor/text-editor'; +import { toast } from '@/components/core/toaster'; +import FormLoading from '@/components/loading'; + +import ErrorDisplay from '../../error'; +import type { EditFormProps } from './type'; + +// TODO: review this +const schema = zod.object({ + cat_name: zod.string().min(1, 'name-is-required').max(255), + // accept file object when user change image + // accept http string when user not changing image + cat_image: zod.union([zod.array(zod.any()), zod.string()]).optional(), + + // position + pos: zod.number().min(1, 'position is required').max(99), + + // it should be a valid JSON + init_answer: zod + .string() + .refine( + (value) => { + try { + JSON.parse(value); + return true; + } catch (error) { + return false; + } + }, + { message: 'init_answer must be a valid JSON' } + ) + .optional(), + visible: zod.string(), + slug: zod.string().min(0, 'slug-is-required').max(255).optional(), + remarks: zod.string().optional(), + description: zod.string().optional(), + // NOTE: for image handling + avatar: zod.string().optional(), +}); + +type Values = zod.infer; + +const defaultValues = { + cat_name: '', + cat_image: undefined, + pos: 1, + init_answer: JSON.stringify({}), + visible: 'hidden', + slug: '', + remarks: '', + description: '', +} satisfies Values; + +export function LpCategoryEditForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(['lp_categories']); + + const { cat_id: catId } = useParams<{ cat_id: string }>(); + // + const [isUpdating, setIsUpdating] = React.useState(false); + const [showLoading, setShowLoading] = React.useState(false); + // + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + + const { + control, + handleSubmit, + formState: { errors }, + setValue, + reset, + watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsUpdating(true); + + const tempUpdate: EditFormProps = { + cat_name: values.cat_name, + cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null, + pos: values.pos, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + init_answer: JSON.parse(values.init_answer || '{}'), + + visible: values.visible, + slug: values.slug || 'not-defined', + remarks: values.remarks, + description: values.description, + // + // TODO: remove below + type: '', + }; + + try { + const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).update(catId, tempUpdate); + logger.debug(result); + toast.success(t('edit.success')); + router.push(paths.dashboard.lp_categories.list); + } catch (error) { + logger.error(error); + toast.error(t('update.failed')); + } finally { + setIsUpdating(false); + } + }, + // t is not necessary here + // eslint-disable-next-line react-hooks/exhaustive-deps + [router] + ); + + const avatarInputRef = React.useRef(null); + const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + const url = await fileToBase64(file); + setValue('avatar', url); + } + }, + [setValue] + ); + + // TODO: need to align with save form + // use trycatch + const [textDescription, setTextDescription] = React.useState(''); + const [textRemarks, setTextRemarks] = React.useState(''); + + // load existing data when user arrive + const loadExistingData = React.useCallback( + async (id: string) => { + setShowLoading(true); + + try { + const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).getOne(id); + + reset({ ...defaultValues, ...result, init_answer: JSON.stringify(result.init_answer) }); + setTextDescription(result.description); + setTextRemarks(result.remarks); + + if (result.cat_image !== '') { + const fetchResult = await fetch( + `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.cat_image}` + ); + + const blob = await fetchResult.blob(); + const url = await fileToBase64(blob); + + setValue('avatar', url); + } else { + setValue('avatar', ''); + } + } catch (error) { + logger.error(error); + toast(t('list.error')); + + setShowError({ show: true, detail: JSON.stringify(error, null, 2) }); + } finally { + setShowLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [catId] + ); + + React.useEffect(() => { + void loadExistingData(catId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [catId]); + + if (showLoading) return ; + if (showError.show) + return ( + + ); + + return ( +
+ + + } + 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/cr/categories/mf-categories-filters.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-filters.tsx new file mode 100644 index 0000000..7573cc6 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-filters.tsx @@ -0,0 +1,456 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +// import { COL_LESSON_CATEGORIES } from '@/constants'; +import GetAllCount from '@/db/QuizListenings/GetAllCount'; +import GetHiddenCount from '@/db/QuizListenings/GetHiddenCount'; +import GetVisibleCount from '@/db/QuizListenings/GetVisibleCount'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button'; +import { Option } from '@/components/core/option'; + +import { useLpCategoriesSelection } from './lp-categories-selection-context'; +import { LpCategory } from './type'; + +export interface Filters { + email?: string; + phone?: string; + status?: string; + name?: string; + visible?: string; + type?: string; +} + +export type SortDir = 'asc' | 'desc'; + +export interface LpCategoriesFiltersProps { + filters?: Filters; + sortDir?: SortDir; + fullData: LpCategory[]; +} + +export function LpCategoriesFilters({ + filters = {}, + sortDir = 'desc', + fullData, +}: LpCategoriesFiltersProps): React.JSX.Element { + const { t } = useTranslation(); + const { email, phone, status, name, visible, type } = filters; + + const [totalCount, setTotalCount] = React.useState(0); + const [visibleCount, setVisibleCount] = React.useState(0); + const [hiddenCount, setHiddenCount] = React.useState(0); + + const router = useRouter(); + + const selection = useLpCategoriesSelection(); + + function getVisible(): number { + return fullData.reduce((count, item: LpCategory) => { + return item.visible === 'visible' ? count + 1 : count; + }, 0); + } + + function getHidden(): number { + return fullData.reduce((count, item: LpCategory) => { + return item.visible === 'hidden' ? count + 1 : count; + }, 0); + } + + // The tabs should be generated using API data. + const tabs = [ + { label: t('All'), value: '', count: totalCount }, + // { label: 'Active', value: 'active', count: 3 }, + // { label: 'Pending', value: 'pending', count: 1 }, + // { label: 'Blocked', value: 'blocked', count: 1 }, + { label: t('visible'), value: 'visible', count: visibleCount }, + { label: t('hidden'), value: 'hidden', count: hiddenCount }, + ] as const; + + const updateSearchParams = React.useCallback( + (newFilters: Filters, newSortDir: SortDir): void => { + const searchParams = new URLSearchParams(); + + if (newSortDir === 'asc') { + searchParams.set('sortDir', newSortDir); + } + + if (newFilters.status) { + searchParams.set('status', newFilters.status); + } + + if (newFilters.email) { + searchParams.set('email', newFilters.email); + } + + if (newFilters.phone) { + searchParams.set('phone', newFilters.phone); + } + + if (newFilters.name) { + searchParams.set('name', newFilters.name); + } + + if (newFilters.type) { + searchParams.set('type', newFilters.type); + } + + if (newFilters.visible) { + searchParams.set('visible', newFilters.visible); + } + + // NOTE: modify according to COLLECTION + router.push(`${paths.dashboard.lp_categories.list}?${searchParams.toString()}`); + }, + [router] + ); + + const handleClearFilters = React.useCallback(() => { + updateSearchParams({}, sortDir); + }, [updateSearchParams, sortDir]); + + const handleStatusChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, status: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleVisibleChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, visible: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleNameChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, name: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleTypeChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, type: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleEmailChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, email: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handlePhoneChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, phone: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleSortChange = React.useCallback( + (event: SelectChangeEvent) => { + updateSearchParams(filters, event.target.value as SortDir); + }, + [updateSearchParams, filters] + ); + + React.useEffect(() => { + const fetchCount = async (): Promise => { + try { + const tc = await GetAllCount(); + setTotalCount(tc); + + const vc = await GetVisibleCount(); + setVisibleCount(vc); + + const hc = await GetHiddenCount(); + setHiddenCount(hc); + } catch (error) { + // + } + }; + void fetchCount(); + }, []); + + const hasFilters = status || email || phone || visible || name || type; + + return ( +
+ + {tabs.map((tab) => ( + + } + iconPosition="end" + key={tab.value} + label={tab.label} + sx={{ minHeight: 'auto' }} + tabIndex={0} + value={tab.value} + /> + ))} + + + + + { + handleNameChange(value as string); + }} + onFilterDelete={() => { + handleNameChange(); + }} + popover={} + value={name} + /> + + { + handleTypeChange(value as string); + }} + onFilterDelete={() => { + handleTypeChange(); + }} + popover={} + value={type} + /> + + {hasFilters ? : null} + + {selection.selectedAny ? ( + + + {selection.selected.size} {t('selected')} + + + + ) : null} + + +
+ ); +} + +function TypeFilterPopover(): React.JSX.Element { + const { t } = useTranslation(); + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function NameFilterPopover(): React.JSX.Element { + const { t } = useTranslation(); + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function EmailFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + const { t } = useTranslation(); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function PhoneFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + const { t } = useTranslation(); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/mf-categories-pagination.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-pagination.tsx new file mode 100644 index 0000000..bf40481 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-pagination.tsx @@ -0,0 +1,49 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface LessonCategoriesPaginationProps { + count: number; + page: number; + // + setPage: (page: number) => void; + setRowsPerPage: (page: number) => void; + rowsPerPage: number; +} + +export function LpCategoriesPagination({ + count, + page, + // + setPage, + setRowsPerPage, + rowsPerPage, +}: LessonCategoriesPaginationProps): React.JSX.Element { + // You should implement the pagination using a similar logic as the filters. + // Note that when page change, you should keep the filter search params. + const handleChangePage = (event: unknown, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value)); + // console.log(parseInt(event.target.value)); + }; + + return ( + + ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/mf-categories-selection-context.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-selection-context.tsx new file mode 100644 index 0000000..a6c6502 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-selection-context.tsx @@ -0,0 +1,46 @@ +'use client'; + +import * as React from 'react'; + +// import type { LessonCategory } from '@/types/lesson-type'; +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import { LpCategory } from './type'; + +function noop(): void { + return undefined; +} + +export interface LpCategoriesSelectionContextValue extends Selection {} + +export const LpCategoriesSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface LpCategoriesSelectionProviderProps { + children: React.ReactNode; + lessonCategories: LpCategory[]; +} + +export function LpCategoriesSelectionProvider({ + children, + lessonCategories = [], +}: LpCategoriesSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => lessonCategories.map((customer) => customer.id), [lessonCategories]); + const selection = useSelection(customerIds); + + return ( + {children} + ); +} + +export function useLpCategoriesSelection(): LpCategoriesSelectionContextValue { + return React.useContext(LpCategoriesSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/mf-categories-table.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-table.tsx new file mode 100644 index 0000000..26034f1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/mf-categories-table.tsx @@ -0,0 +1,241 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { LoadingButton } from '@mui/lab'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import LinearProgress from '@mui/material/LinearProgress'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock'; +import { Images as ImagesIcon } from '@phosphor-icons/react/dist/ssr/Images'; +import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +import { paths } from '@/paths'; +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; + +import ConfirmDeleteModal from './confirm-delete-modal'; +import { useLpCategoriesSelection } from './lp-categories-selection-context'; +import type { LpCategory } from './type'; + +function columns(handleDeleteClick: (testId: string) => void): ColumnDef[] { + return [ + { + formatter: (row): React.JSX.Element => ( + + + + + + {' '} +
+ {row.cat_name} + + slug: {row.cat_name} + +
+
+ +
+ ), + name: 'Name', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)} + + + ), + // NOTE: please refer to translation.json here + name: 'word-count', + width: '100px', + }, + { + formatter: (row): React.JSX.Element => { + // eslint-disable-next-line react-hooks/rules-of-hooks + + const mapping = { + active: { + label: 'Active', + icon: ( + + ), + }, + blocked: { label: 'Blocked', icon: }, + pending: { + label: 'Pending', + icon: ( + + ), + }, + NA: { + label: 'NA', + icon: ( + + ), + }, + } as const; + const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null }; + + return ( + + ); + }, + name: 'Status', + width: '150px', + }, + { + formatter(row) { + return dayjs(row.createdAt).format('MMM D, YYYY'); + }, + name: 'Created at', + width: '100px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + + { + handleDeleteClick(row.id); + }} + > + + + + ), + name: 'Actions', + hideName: true, + width: '100px', + align: 'right', + }, + ]; +} + +export interface LessonCategoriesTableProps { + rows: LpCategory[]; + reloadRows: () => void; +} + +export function LpCategoriesTable({ rows, reloadRows }: LessonCategoriesTableProps): React.JSX.Element { + const { t } = useTranslation(['lp_categories']); + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLpCategoriesSelection(); + + const [idToDelete, setIdToDelete] = React.useState(''); + const [open, setOpen] = React.useState(false); + + function handleDeleteClick(testId: string): void { + setOpen(true); + setIdToDelete(testId); + } + + return ( + + + + columns={columns(handleDeleteClick)} + onDeselectAll={deselectAll} + onDeselectOne={(_, row) => { + deselectOne(row.id); + }} + onSelectAll={selectAll} + onSelectOne={(_, row) => { + selectOne(row.id); + }} + rows={rows} + selectable + selected={selected} + /> + {!rows.length ? ( + + + {t('no-lesson-categories-found')} + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/mf-category-create-form.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-category-create-form.tsx new file mode 100644 index 0000000..ba46ac7 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/mf-category-create-form.tsx @@ -0,0 +1,419 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useRouter } from 'next/navigation'; +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LoadingButton } from '@mui/lab'; +import { Avatar, Divider, MenuItem, Select } from '@mui/material'; +// import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +// import axios from 'axios'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import isDevelopment from '@/lib/check-is-development'; +import { logger } from '@/lib/default-logger'; +import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; +import { pb } from '@/lib/pb'; +import { TextEditor } from '@/components/core/text-editor/text-editor'; +import { toast } from '@/components/core/toaster'; + +import type { CreateFormProps } from './type'; + +const schema = zod.object({ + cat_name: zod.string().min(1, 'name-is-required').max(255), + cat_image: zod.array(zod.any()).optional(), + pos: zod.number().min(1, 'position is required').max(99), + init_answer: zod.string().optional(), + visible: zod.string(), + slug: zod.string().min(1, 'slug-is-required').max(255), + remarks: zod.string().optional(), + description: zod.string().optional(), + // NOTE: for image handling + avatar: zod.string().optional(), + // TODO: remove me + type: zod.string().optional(), + isActive: zod.boolean().optional(), + order: zod.number().optional(), +}); + +type Values = zod.infer; + +export const defaultValues = { + cat_name: '', + cat_image: undefined, + pos: 1, + init_answer: '', + visible: 'hidden', + slug: '', + remarks: '', + description: '', +} satisfies Values; + +export function LpCategoryCreateForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(['lp_categories']); + + const [isCreating, setIsCreating] = React.useState(false); + + const { + control, + handleSubmit, + formState: { errors }, + setValue, + watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsCreating(true); + + const payload: CreateFormProps = { + cat_name: values.cat_name, + cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null, + pos: values.pos, + init_answer: values.init_answer, + visible: values.visible, + slug: values.slug, + remarks: values.remarks, + description: values.description, + // + // TODO: remove me + type: 'type tet', + isActive: true, + order: 1, + }; + + try { + const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).create(payload); + + logger.debug(result); + toast.success(t('create.success')); + router.push(paths.dashboard.lp_categories.list); + } catch (error) { + logger.error(error); + toast.error(t('create.failed')); + } finally { + setIsCreating(false); + } + }, + // t is not necessary here + // eslint-disable-next-line react-hooks/exhaustive-deps + [router] + ); + + const avatarInputRef = React.useRef(null); + const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + const url = await fileToBase64(file); + setValue('avatar', url); + } + }, + [setValue] + ); + + return ( +
+ + + } + 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/cr/categories/mf-category-edit-form.tsx b/002_source/cms/src/components/dashboard/cr/categories/mf-category-edit-form.tsx new file mode 100644 index 0000000..c00e964 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/mf-category-edit-form.tsx @@ -0,0 +1,500 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +// +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LoadingButton } from '@mui/lab'; +// +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +// +import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +// +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; +import { pb } from '@/lib/pb'; +import { TextEditor } from '@/components/core/text-editor/text-editor'; +import { toast } from '@/components/core/toaster'; +import FormLoading from '@/components/loading'; + +import ErrorDisplay from '../../error'; +import type { EditFormProps } from './type'; + +// TODO: review this +const schema = zod.object({ + cat_name: zod.string().min(1, 'name-is-required').max(255), + // accept file object when user change image + // accept http string when user not changing image + cat_image: zod.union([zod.array(zod.any()), zod.string()]).optional(), + + // position + pos: zod.number().min(1, 'position is required').max(99), + + // it should be a valid JSON + init_answer: zod + .string() + .refine( + (value) => { + try { + JSON.parse(value); + return true; + } catch (error) { + return false; + } + }, + { message: 'init_answer must be a valid JSON' } + ) + .optional(), + visible: zod.string(), + slug: zod.string().min(0, 'slug-is-required').max(255).optional(), + remarks: zod.string().optional(), + description: zod.string().optional(), + // NOTE: for image handling + avatar: zod.string().optional(), +}); + +type Values = zod.infer; + +const defaultValues = { + cat_name: '', + cat_image: undefined, + pos: 1, + init_answer: JSON.stringify({}), + visible: 'hidden', + slug: '', + remarks: '', + description: '', +} satisfies Values; + +export function LpCategoryEditForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(['lp_categories']); + + const { cat_id: catId } = useParams<{ cat_id: string }>(); + // + const [isUpdating, setIsUpdating] = React.useState(false); + const [showLoading, setShowLoading] = React.useState(false); + // + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + + const { + control, + handleSubmit, + formState: { errors }, + setValue, + reset, + watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsUpdating(true); + + const tempUpdate: EditFormProps = { + cat_name: values.cat_name, + cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null, + pos: values.pos, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + init_answer: JSON.parse(values.init_answer || '{}'), + + visible: values.visible, + slug: values.slug || 'not-defined', + remarks: values.remarks, + description: values.description, + // + // TODO: remove below + type: '', + }; + + try { + const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).update(catId, tempUpdate); + logger.debug(result); + toast.success(t('edit.success')); + router.push(paths.dashboard.lp_categories.list); + } catch (error) { + logger.error(error); + toast.error(t('update.failed')); + } finally { + setIsUpdating(false); + } + }, + // t is not necessary here + // eslint-disable-next-line react-hooks/exhaustive-deps + [router] + ); + + const avatarInputRef = React.useRef(null); + const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + const url = await fileToBase64(file); + setValue('avatar', url); + } + }, + [setValue] + ); + + // TODO: need to align with save form + // use trycatch + const [textDescription, setTextDescription] = React.useState(''); + const [textRemarks, setTextRemarks] = React.useState(''); + + // load existing data when user arrive + const loadExistingData = React.useCallback( + async (id: string) => { + setShowLoading(true); + + try { + const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).getOne(id); + + reset({ ...defaultValues, ...result, init_answer: JSON.stringify(result.init_answer) }); + setTextDescription(result.description); + setTextRemarks(result.remarks); + + if (result.cat_image !== '') { + const fetchResult = await fetch( + `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.cat_image}` + ); + + const blob = await fetchResult.blob(); + const url = await fileToBase64(blob); + + setValue('avatar', url); + } else { + setValue('avatar', ''); + } + } catch (error) { + logger.error(error); + toast(t('list.error')); + + setShowError({ show: true, detail: JSON.stringify(error, null, 2) }); + } finally { + setShowLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [catId] + ); + + React.useEffect(() => { + void loadExistingData(catId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [catId]); + + if (showLoading) return ; + if (showError.show) + return ( + + ); + + return ( +
+ + + } + 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/cr/categories/notifications.tsx b/002_source/cms/src/components/dashboard/cr/categories/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/notifications.tsx @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple'; + +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; +import { Option } from '@/components/core/option'; + +export interface Notification { + id: string; + type: string; + status: 'delivered' | 'pending' | 'failed'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {row.type} + + ), + name: 'Type', + width: '300px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + delivered: { label: 'Delivered', color: 'success' }, + pending: { label: 'Pending', color: 'warning' }, + failed: { label: 'Failed', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface NotificationsProps { + notifications: Notification[]; +} + +export function Notifications({ notifications }: NotificationsProps): React.JSX.Element { + return ( + + + + + } + title="Notifications" + /> + + + + +
+ +
+
+ + + columns={columns} rows={notifications} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/payments.tsx b/002_source/cms/src/components/dashboard/cr/categories/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/payments.tsx @@ -0,0 +1,138 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import { ShoppingCartSimple as ShoppingCartSimpleIcon } from '@phosphor-icons/react/dist/ssr/ShoppingCartSimple'; + +import { dayjs } from '@/lib/dayjs'; +import type { ColumnDef } from '@/components/core/data-table'; +import { DataTable } from '@/components/core/data-table'; + +export interface Payment { + currency: string; + amount: number; + invoiceId: string; + status: 'pending' | 'completed' | 'canceled' | 'refunded'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)} + + ), + name: 'Amount', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + pending: { label: 'Pending', color: 'warning' }, + completed: { label: 'Completed', color: 'success' }, + canceled: { label: 'Canceled', color: 'error' }, + refunded: { label: 'Refunded', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + return {row.invoiceId}; + }, + name: 'Invoice ID', + width: '150px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface PaymentsProps { + ordersValue: number; + payments: Payment[]; + refundsValue: number; + totalOrders: number; +} + +export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element { + return ( + + }> + Create Payment + + } + avatar={ + + + + } + title="Payments" + /> + + + + } + spacing={3} + sx={{ justifyContent: 'space-between', p: 2 }} + > +
+ + Total orders + + {new Intl.NumberFormat('en-US').format(totalOrders)} +
+
+ + Orders value + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)} + +
+
+ + Refunds + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)} + +
+
+
+ + + columns={columns} rows={payments} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/shipping-address.tsx b/002_source/cms/src/components/dashboard/cr/categories/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/shipping-address.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; + +export interface Address { + id: string; + country: string; + state: string; + city: string; + zipCode: string; + street: string; + primary?: boolean; +} + +export interface ShippingAddressProps { + address: Address; +} + +export function ShippingAddress({ address }: ShippingAddressProps): React.ReactElement { + return ( + + + + + {address.street}, +
+ {address.city}, {address.state}, {address.country}, +
+ {address.zipCode} +
+ + {address.primary ? : } + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/cr/categories/type.d.ts b/002_source/cms/src/components/dashboard/cr/categories/type.d.ts new file mode 100644 index 0000000..c66a11f --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/categories/type.d.ts @@ -0,0 +1,61 @@ +export interface LpCategory { + isEmpty?: boolean; + // + id: string; + collectionId: string; + // + cat_name: string; + cat_image_url?: string; + cat_image?: string; + pos: number; + visible: string; + lesson_id: string; + description: string; + remarks: string; + slug: string; + init_answer: any; + // + name: string; + avatar: string; + email: string; + phone: string; + quota: number; + status: 'pending' | 'active' | 'blocked' | 'NA'; + createdAt: Date; +} + +export interface CreateFormProps { + cat_name: string; + cat_image: File[] | null; + pos: number; + init_answer?: string; + visible: string; + slug: string; + remarks?: string; + description?: string; + // + // TODO: to remove + type: string; + isActive: boolean; + order: number; + name?: string; + imageUrl?: string; +} + +export interface EditFormProps { + cat_name: string; + cat_image: File[] | null; + pos: number; + init_answer: any; + visible: string; + slug: string; + remarks?: string; + description?: string; + // + // TODO: remove below + type: string; +} + +export interface Helloworld { + helloworld: string; +} diff --git a/002_source/cms/src/components/dashboard/cr/questions/_PROMPT.MD b/002_source/cms/src/components/dashboard/cr/questions/_PROMPT.MD new file mode 100644 index 0000000..369cdca --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/_PROMPT.MD @@ -0,0 +1,21 @@ +please review and add translations, e.g. `{t('[word]')}` + +--- + +please help to review the `tsx` file in this folder +`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/lp/questions` + +it was clone from +`category`/`categories`, `lp_category`/`lp_categories` +please help to modify to `question`/`questions`, `lp_question`/`lp_questions` + +please also help to modify the name of +`variables`, `constants`, `functions`, `classes`, components's name, paths + +the db fields structures are the same + +do not move the files +do not create directories +keep current folder structure is important + +thanks diff --git a/002_source/cms/src/components/dashboard/cr/questions/_constants.ts b/002_source/cms/src/components/dashboard/cr/questions/_constants.ts new file mode 100644 index 0000000..95a4d17 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/_constants.ts @@ -0,0 +1,44 @@ +import { dayjs } from '@/lib/dayjs'; + +import { CreateFormProps, LpQuestion } from './type'; + +export const defaultLpQuestion: LpQuestion = { + isEmpty: false, + id: 'default-id', + cat_name: 'default-question-name', + cat_image_url: undefined, + cat_image: undefined, + pos: 0, + visible: 'hidden', + lesson_id: 'default-lesson-id', + description: 'default-description', + remarks: 'default-remarks', + slug: '', + init_answer: {}, + // from pocketbase + collectionId: '0000000000', + createdAt: dayjs('2099-01-01').toDate(), + // + name: '', + avatar: '', + email: '', + phone: '', + quota: 0, + status: 'NA', +}; + +// export const LpCategoryCreateFormDefault: CreateFormProps = { +// name: '', +// type: '', +// pos: 1, +// visible: 'visible', +// description: '', +// isActive: true, +// order: 1, +// imageUrl: '', +// }; + +export const emptyLpQuestion: LpQuestion = { + ...defaultLpQuestion, + isEmpty: true, +}; diff --git a/002_source/cms/src/components/dashboard/cr/questions/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/cr/questions/confirm-delete-modal.tsx new file mode 100644 index 0000000..4d5b267 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/confirm-delete-modal.tsx @@ -0,0 +1,125 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { COL_LESSON_TYPES } from '@/constants'; +import deleteQuizLPCategories from '@/db/QuizListenings/Delete'; +import { LoadingButton } from '@mui/lab'; +import { Button, Container, Modal, Paper } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Note as NoteIcon } from '@phosphor-icons/react/dist/ssr/Note'; +import PocketBase from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { logger } from '@/lib/default-logger'; +import { toast } from '@/components/core/toaster'; + +export default function ConfirmDeleteModal({ + open, + setOpen, + idToDelete, + reloadRows, +}: { + open: boolean; + setOpen: (b: boolean) => void; + idToDelete: string; + reloadRows: () => void; +}): React.JSX.Element { + const { t } = useTranslation(); + + // const handleClose = () => setOpen(false); + function handleClose(): void { + setOpen(false); + } + + const [isDeleteing, setIsDeleteing] = React.useState(false); + const style = { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }; + + function handleUserConfirmDelete(): void { + if (idToDelete) { + setIsDeleteing(true); + deleteQuizLPCategories(idToDelete) + .then(() => { + reloadRows(); + handleClose(); + toast(t('delete.success')); + }) + .catch((err) => { + // console.error(err) + logger.error(err); + toast(t('delete.error')); + }) + .finally(() => { + setIsDeleteing(false); + }); + } + } + + return ( +
+ + + + + + + + + + + {t('Delete Lesson Type ?')} + + {t('Are you sure you want to delete lesson type ?')} + + + + + { + handleUserConfirmDelete(); + }} + loading={isDeleteing} + > + {t('Delete')} + + + + + + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/cr/questions/lp-question-create-form.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-question-create-form.tsx new file mode 100644 index 0000000..a39ad69 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/lp-question-create-form.tsx @@ -0,0 +1,419 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useRouter } from 'next/navigation'; +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LoadingButton } from '@mui/lab'; +import { Avatar, Divider, MenuItem, Select } from '@mui/material'; +// import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +// import axios from 'axios'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import isDevelopment from '@/lib/check-is-development'; +import { logger } from '@/lib/default-logger'; +import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; +import { pb } from '@/lib/pb'; +import { TextEditor } from '@/components/core/text-editor/text-editor'; +import { toast } from '@/components/core/toaster'; + +import type { CreateFormProps } from './type'; + +const schema = zod.object({ + cat_name: zod.string().min(1, 'name-is-required').max(255), + cat_image: zod.array(zod.any()).optional(), + pos: zod.number().min(1, 'position is required').max(99), + init_answer: zod.string().optional(), + visible: zod.string(), + slug: zod.string().min(1, 'slug-is-required').max(255), + remarks: zod.string().optional(), + description: zod.string().optional(), + // NOTE: for image handling + avatar: zod.string().optional(), + // TODO: remove me + type: zod.string().optional(), + isActive: zod.boolean().optional(), + order: zod.number().optional(), +}); + +type Values = zod.infer; + +export const defaultValues = { + cat_name: '', + cat_image: undefined, + pos: 1, + init_answer: '', + visible: 'hidden', + slug: '', + remarks: '', + description: '', +} satisfies Values; + +export function LpQuestionCreateForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(['lp_categories']); + + const [isCreating, setIsCreating] = React.useState(false); + + const { + control, + handleSubmit, + formState: { errors }, + setValue, + watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsCreating(true); + + const payload: CreateFormProps = { + cat_name: values.cat_name, + cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null, + pos: values.pos, + init_answer: values.init_answer, + visible: values.visible, + slug: values.slug, + remarks: values.remarks, + description: values.description, + // + // TODO: remove me + type: 'type tet', + isActive: true, + order: 1, + }; + + try { + const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).create(payload); + + logger.debug(result); + toast.success(t('create.success')); + router.push(paths.dashboard.lp_categories.list); + } catch (error) { + logger.error(error); + toast.error(t('create.failed')); + } finally { + setIsCreating(false); + } + }, + // t is not necessary here + // eslint-disable-next-line react-hooks/exhaustive-deps + [router] + ); + + const avatarInputRef = React.useRef(null); + const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + const url = await fileToBase64(file); + setValue('avatar', url); + } + }, + [setValue] + ); + + return ( +
+ + + } + 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/cr/questions/lp-question-edit-form.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-question-edit-form.tsx new file mode 100644 index 0000000..a38d9b2 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/lp-question-edit-form.tsx @@ -0,0 +1,500 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +// +import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LoadingButton } from '@mui/lab'; +// +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +// +import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +// +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; +import { pb } from '@/lib/pb'; +import { TextEditor } from '@/components/core/text-editor/text-editor'; +import { toast } from '@/components/core/toaster'; +import FormLoading from '@/components/loading'; + +import ErrorDisplay from '../../error'; +import type { EditFormProps } from './type'; + +// TODO: review this +const schema = zod.object({ + cat_name: zod.string().min(1, 'name-is-required').max(255), + // accept file object when user change image + // accept http string when user not changing image + cat_image: zod.union([zod.array(zod.any()), zod.string()]).optional(), + + // position + pos: zod.number().min(1, 'position is required').max(99), + + // it should be a valid JSON + init_answer: zod + .string() + .refine( + (value) => { + try { + JSON.parse(value); + return true; + } catch (error) { + return false; + } + }, + { message: 'init_answer must be a valid JSON' } + ) + .optional(), + visible: zod.string(), + slug: zod.string().min(0, 'slug-is-required').max(255).optional(), + remarks: zod.string().optional(), + description: zod.string().optional(), + // NOTE: for image handling + avatar: zod.string().optional(), +}); + +type Values = zod.infer; + +const defaultValues = { + cat_name: '', + cat_image: undefined, + pos: 1, + init_answer: JSON.stringify({}), + visible: 'hidden', + slug: '', + remarks: '', + description: '', +} satisfies Values; + +export function LpQuestionEditForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(['lp_categories']); + + const { cat_id: catId } = useParams<{ cat_id: string }>(); + // + const [isUpdating, setIsUpdating] = React.useState(false); + const [showLoading, setShowLoading] = React.useState(false); + // + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + + const { + control, + handleSubmit, + formState: { errors }, + setValue, + reset, + watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsUpdating(true); + + const tempUpdate: EditFormProps = { + cat_name: values.cat_name, + cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null, + pos: values.pos, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + init_answer: JSON.parse(values.init_answer || '{}'), + + visible: values.visible, + slug: values.slug || 'not-defined', + remarks: values.remarks, + description: values.description, + // + // TODO: remove below + type: '', + }; + + try { + const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).update(catId, tempUpdate); + logger.debug(result); + toast.success(t('edit.success')); + router.push(paths.dashboard.lp_categories.list); + } catch (error) { + logger.error(error); + toast.error(t('update.failed')); + } finally { + setIsUpdating(false); + } + }, + // t is not necessary here + // eslint-disable-next-line react-hooks/exhaustive-deps + [router] + ); + + const avatarInputRef = React.useRef(null); + const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + const url = await fileToBase64(file); + setValue('avatar', url); + } + }, + [setValue] + ); + + // TODO: need to align with save form + // use trycatch + const [textDescription, setTextDescription] = React.useState(''); + const [textRemarks, setTextRemarks] = React.useState(''); + + // load existing data when user arrive + const loadExistingData = React.useCallback( + async (id: string) => { + setShowLoading(true); + + try { + const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).getOne(id); + + reset({ ...defaultValues, ...result, init_answer: JSON.stringify(result.init_answer) }); + setTextDescription(result.description); + setTextRemarks(result.remarks); + + if (result.cat_image !== '') { + const fetchResult = await fetch( + `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.cat_image}` + ); + + const blob = await fetchResult.blob(); + const url = await fileToBase64(blob); + + setValue('avatar', url); + } else { + setValue('avatar', ''); + } + } catch (error) { + logger.error(error); + toast(t('list.error')); + + setShowError({ show: true, detail: JSON.stringify(error, null, 2) }); + } finally { + setShowLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [catId] + ); + + React.useEffect(() => { + void loadExistingData(catId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [catId]); + + if (showLoading) return ; + if (showError.show) + return ( + + ); + + return ( +
+ + + } + 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/cr/questions/lp-questions-filters.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-filters.tsx new file mode 100644 index 0000000..d5b5486 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-filters.tsx @@ -0,0 +1,456 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +// import { COL_LESSON_CATEGORIES } from '@/constants'; +import GetAllCount from '@/db/QuizListenings/GetAllCount'; +import GetHiddenCount from '@/db/QuizListenings/GetHiddenCount'; +import GetVisibleCount from '@/db/QuizListenings/GetVisibleCount'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button'; +import { Option } from '@/components/core/option'; + +import { useLpQuestionsSelection } from './lp-questions-selection-context'; +import { LpQuestion } from './type'; + +export interface Filters { + email?: string; + phone?: string; + status?: string; + name?: string; + visible?: string; + type?: string; +} + +export type SortDir = 'asc' | 'desc'; + +export interface LpQuestionsFiltersProps { + filters?: Filters; + sortDir?: SortDir; + fullData: LpQuestion[]; +} + +export function LpQuestionsFilters({ + filters = {}, + sortDir = 'desc', + fullData, +}: LpQuestionsFiltersProps): React.JSX.Element { + const { t } = useTranslation(); + const { email, phone, status, name, visible, type } = filters; + + const [totalCount, setTotalCount] = React.useState(0); + const [visibleCount, setVisibleCount] = React.useState(0); + const [hiddenCount, setHiddenCount] = React.useState(0); + + const router = useRouter(); + + const selection = useLpQuestionsSelection(); + + function getVisible(): number { + return fullData.reduce((count, item: LpQuestion) => { + return item.visible === 'visible' ? count + 1 : count; + }, 0); + } + + function getHidden(): number { + return fullData.reduce((count, item: LpQuestion) => { + return item.visible === 'hidden' ? count + 1 : count; + }, 0); + } + + // The tabs should be generated using API data. + const tabs = [ + { label: t('All'), value: '', count: totalCount }, + // { label: 'Active', value: 'active', count: 3 }, + // { label: 'Pending', value: 'pending', count: 1 }, + // { label: 'Blocked', value: 'blocked', count: 1 }, + { label: t('visible'), value: 'visible', count: visibleCount }, + { label: t('hidden'), value: 'hidden', count: hiddenCount }, + ] as const; + + const updateSearchParams = React.useCallback( + (newFilters: Filters, newSortDir: SortDir): void => { + const searchParams = new URLSearchParams(); + + if (newSortDir === 'asc') { + searchParams.set('sortDir', newSortDir); + } + + if (newFilters.status) { + searchParams.set('status', newFilters.status); + } + + if (newFilters.email) { + searchParams.set('email', newFilters.email); + } + + if (newFilters.phone) { + searchParams.set('phone', newFilters.phone); + } + + if (newFilters.name) { + searchParams.set('name', newFilters.name); + } + + if (newFilters.type) { + searchParams.set('type', newFilters.type); + } + + if (newFilters.visible) { + searchParams.set('visible', newFilters.visible); + } + + // NOTE: modify according to COLLECTION + router.push(`${paths.dashboard.lp_questions.list}?${searchParams.toString()}`); + }, + [router] + ); + + const handleClearFilters = React.useCallback(() => { + updateSearchParams({}, sortDir); + }, [updateSearchParams, sortDir]); + + const handleStatusChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, status: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleVisibleChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, visible: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleNameChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, name: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleTypeChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, type: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleEmailChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, email: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handlePhoneChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, phone: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleSortChange = React.useCallback( + (event: SelectChangeEvent) => { + updateSearchParams(filters, event.target.value as SortDir); + }, + [updateSearchParams, filters] + ); + + React.useEffect(() => { + const fetchCount = async (): Promise => { + try { + const tc = await GetAllCount(); + setTotalCount(tc); + + const vc = await GetVisibleCount(); + setVisibleCount(vc); + + const hc = await GetHiddenCount(); + setHiddenCount(hc); + } catch (error) { + // + } + }; + void fetchCount(); + }, []); + + const hasFilters = status || email || phone || visible || name || type; + + return ( +
+ + {tabs.map((tab) => ( + + } + iconPosition="end" + key={tab.value} + label={tab.label} + sx={{ minHeight: 'auto' }} + tabIndex={0} + value={tab.value} + /> + ))} + + + + + { + handleNameChange(value as string); + }} + onFilterDelete={() => { + handleNameChange(); + }} + popover={} + value={name} + /> + + { + handleTypeChange(value as string); + }} + onFilterDelete={() => { + handleTypeChange(); + }} + popover={} + value={type} + /> + + {hasFilters ? : null} + + {selection.selectedAny ? ( + + + {selection.selected.size} {t('selected')} + + + + ) : null} + + +
+ ); +} + +function TypeFilterPopover(): React.JSX.Element { + const { t } = useTranslation(); + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function NameFilterPopover(): React.JSX.Element { + const { t } = useTranslation(); + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function EmailFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + const { t } = useTranslation(); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function PhoneFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + const { t } = useTranslation(); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/cr/questions/lp-questions-pagination.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-pagination.tsx new file mode 100644 index 0000000..deef393 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-pagination.tsx @@ -0,0 +1,49 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface LessonQuestionsPaginationProps { + count: number; + page: number; + // + setPage: (page: number) => void; + setRowsPerPage: (page: number) => void; + rowsPerPage: number; +} + +export function LpQuestionsPagination({ + count, + page, + // + setPage, + setRowsPerPage, + rowsPerPage, +}: LessonQuestionsPaginationProps): React.JSX.Element { + // You should implement the pagination using a similar logic as the filters. + // Note that when page change, you should keep the filter search params. + const handleChangePage = (event: unknown, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value)); + // console.log(parseInt(event.target.value)); + }; + + return ( + + ); +} diff --git a/002_source/cms/src/components/dashboard/cr/questions/lp-questions-selection-context.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-selection-context.tsx new file mode 100644 index 0000000..8d98b5a --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-selection-context.tsx @@ -0,0 +1,46 @@ +'use client'; + +import * as React from 'react'; + +// import type { LessonCategory } from '@/types/lesson-type'; +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import type { LpQuestion } from './type'; + +function noop(): void { + return undefined; +} + +export interface LpQuestionsSelectionContextValue extends Selection {} + +export const LpQuestionsSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface LpQuestionsSelectionProviderProps { + children: React.ReactNode; + lessonQuestions: LpQuestion[]; +} + +export function LpQuestionsSelectionProvider({ + children, + lessonQuestions = [], +}: LpQuestionsSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => lessonQuestions.map((customer) => customer.id), [lessonQuestions]); + const selection = useSelection(customerIds); + + return ( + {children} + ); +} + +export function useLpQuestionsSelection(): LpQuestionsSelectionContextValue { + return React.useContext(LpQuestionsSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/cr/questions/lp-questions-table.tsx b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-table.tsx new file mode 100644 index 0000000..e78356c --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/lp-questions-table.tsx @@ -0,0 +1,241 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { LoadingButton } from '@mui/lab'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import LinearProgress from '@mui/material/LinearProgress'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock'; +import { Images as ImagesIcon } from '@phosphor-icons/react/dist/ssr/Images'; +import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +import { paths } from '@/paths'; +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; + +import ConfirmDeleteModal from './confirm-delete-modal'; +import { useLpQuestionsSelection } from './lp-questions-selection-context'; +import type { LpQuestion } from './type'; + +function columns(handleDeleteClick: (testId: string) => void): ColumnDef[] { + return [ + { + formatter: (row): React.JSX.Element => ( + + + + + + {' '} +
+ {row.cat_name} + + slug: {row.cat_name} + +
+
+ +
+ ), + name: 'Name', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)} + + + ), + // NOTE: please refer to translation.json here + name: 'word-count', + width: '100px', + }, + { + formatter: (row): React.JSX.Element => { + // eslint-disable-next-line react-hooks/rules-of-hooks + + const mapping = { + active: { + label: 'Active', + icon: ( + + ), + }, + blocked: { label: 'Blocked', icon: }, + pending: { + label: 'Pending', + icon: ( + + ), + }, + NA: { + label: 'NA', + icon: ( + + ), + }, + } as const; + const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null }; + + return ( + + ); + }, + name: 'Status', + width: '150px', + }, + { + formatter(row) { + return dayjs(row.createdAt).format('MMM D, YYYY'); + }, + name: 'Created at', + width: '100px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + + { + handleDeleteClick(row.id); + }} + > + + + + ), + name: 'Actions', + hideName: true, + width: '100px', + align: 'right', + }, + ]; +} + +export interface LessonQuestionsTableProps { + rows: LpQuestion[]; + reloadRows: () => void; +} + +export function LpQuestionsTable({ rows, reloadRows }: LessonQuestionsTableProps): React.JSX.Element { + const { t } = useTranslation(['lp_categories']); + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLpQuestionsSelection(); + + const [idToDelete, setIdToDelete] = React.useState(''); + const [open, setOpen] = React.useState(false); + + function handleDeleteClick(testId: string): void { + setOpen(true); + setIdToDelete(testId); + } + + return ( + + + + columns={columns(handleDeleteClick)} + onDeselectAll={deselectAll} + onDeselectOne={(_, row) => { + deselectOne(row.id); + }} + onSelectAll={selectAll} + onSelectOne={(_, row) => { + selectOne(row.id); + }} + rows={rows} + selectable + selected={selected} + /> + {!rows.length ? ( + + + {t('no-lesson-categories-found')} + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/cr/questions/notifications.tsx b/002_source/cms/src/components/dashboard/cr/questions/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/notifications.tsx @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple'; + +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; +import { Option } from '@/components/core/option'; + +export interface Notification { + id: string; + type: string; + status: 'delivered' | 'pending' | 'failed'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {row.type} + + ), + name: 'Type', + width: '300px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + delivered: { label: 'Delivered', color: 'success' }, + pending: { label: 'Pending', color: 'warning' }, + failed: { label: 'Failed', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface NotificationsProps { + notifications: Notification[]; +} + +export function Notifications({ notifications }: NotificationsProps): React.JSX.Element { + return ( + + + + + } + title="Notifications" + /> + + + + +
+ +
+
+ + + columns={columns} rows={notifications} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/cr/questions/payments.tsx b/002_source/cms/src/components/dashboard/cr/questions/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/payments.tsx @@ -0,0 +1,138 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import { ShoppingCartSimple as ShoppingCartSimpleIcon } from '@phosphor-icons/react/dist/ssr/ShoppingCartSimple'; + +import { dayjs } from '@/lib/dayjs'; +import type { ColumnDef } from '@/components/core/data-table'; +import { DataTable } from '@/components/core/data-table'; + +export interface Payment { + currency: string; + amount: number; + invoiceId: string; + status: 'pending' | 'completed' | 'canceled' | 'refunded'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)} + + ), + name: 'Amount', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + pending: { label: 'Pending', color: 'warning' }, + completed: { label: 'Completed', color: 'success' }, + canceled: { label: 'Canceled', color: 'error' }, + refunded: { label: 'Refunded', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + return {row.invoiceId}; + }, + name: 'Invoice ID', + width: '150px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface PaymentsProps { + ordersValue: number; + payments: Payment[]; + refundsValue: number; + totalOrders: number; +} + +export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element { + return ( + + }> + Create Payment + + } + avatar={ + + + + } + title="Payments" + /> + + + + } + spacing={3} + sx={{ justifyContent: 'space-between', p: 2 }} + > +
+ + Total orders + + {new Intl.NumberFormat('en-US').format(totalOrders)} +
+
+ + Orders value + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)} + +
+
+ + Refunds + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)} + +
+
+
+ + + columns={columns} rows={payments} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/cr/questions/shipping-address.tsx b/002_source/cms/src/components/dashboard/cr/questions/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/shipping-address.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; + +export interface Address { + id: string; + country: string; + state: string; + city: string; + zipCode: string; + street: string; + primary?: boolean; +} + +export interface ShippingAddressProps { + address: Address; +} + +export function ShippingAddress({ address }: ShippingAddressProps): React.ReactElement { + return ( + + + + + {address.street}, +
+ {address.city}, {address.state}, {address.country}, +
+ {address.zipCode} +
+ + {address.primary ? : } + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/cr/questions/type.d.ts b/002_source/cms/src/components/dashboard/cr/questions/type.d.ts new file mode 100644 index 0000000..8975fb4 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cr/questions/type.d.ts @@ -0,0 +1,61 @@ +export interface LpQuestion { + isEmpty?: boolean; + // + id: string; + collectionId: string; + // + cat_name: string; + cat_image_url?: string; + cat_image?: string; + pos: number; + visible: string; + lesson_id: string; + description: string; + remarks: string; + slug: string; + init_answer: any; + // + name: string; + avatar: string; + email: string; + phone: string; + quota: number; + status: 'pending' | 'active' | 'blocked' | 'NA'; + createdAt: Date; +} + +export interface CreateFormProps { + cat_name: string; + cat_image: File[] | null; + pos: number; + init_answer?: string; + visible: string; + slug: string; + remarks?: string; + description?: string; + // + // TODO: to remove + type: string; + isActive: boolean; + order: number; + name?: string; + imageUrl?: string; +} + +export interface EditFormProps { + cat_name: string; + cat_image: File[] | null; + pos: number; + init_answer: any; + visible: string; + slug: string; + remarks?: string; + description?: string; + // + // TODO: remove below + type: string; +} + +export interface Helloworld { + helloworld: string; +}