This commit is contained in:
louiscklaw
2025-04-21 12:15:25 +08:00
parent ac7d3883fd
commit 00a978e55a
67 changed files with 3423 additions and 136 deletions

View File

@@ -1,5 +1,7 @@
'use client';
import * as React from 'react';
import type { Metadata } from 'next';
// import type { Metadata } from 'next';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
@@ -17,7 +19,7 @@ import { CustomersSelectionProvider } from '@/components/dashboard/customer/cust
import { CustomersTable } from '@/components/dashboard/customer/customers-table';
import type { Customer } from '@/components/dashboard/customer/customers-table';
export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
// export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
const customers = [
{
@@ -182,6 +184,10 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
const sortedCustomers = applySort(customers, sortDir);
const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status });
React.useEffect(() => {
console.log('helloworld');
}, []);
return (
<Box
sx={{
@@ -192,25 +198,38 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
}}
>
<Stack spacing={4}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<Box sx={{ flex: '1 1 auto' }}>
<Typography variant="h4">Customers</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button startIcon={<PlusIcon />} variant="contained">
<Button
startIcon={<PlusIcon />}
variant="contained"
>
Add
</Button>
</Box>
</Stack>
<CustomersSelectionProvider customers={filteredCustomers}>
<Card>
<CustomersFilters filters={{ email, phone, status }} sortDir={sortDir} />
<CustomersFilters
filters={{ email, phone, status }}
sortDir={sortDir}
/>
<Divider />
<Box sx={{ overflowX: 'auto' }}>
<CustomersTable rows={filteredCustomers} />
</Box>
<Divider />
<CustomersPagination count={filteredCustomers.length + 100} page={0} />
<CustomersPagination
count={filteredCustomers.length + 100}
page={0}
/>
</Card>
</CustomersSelectionProvider>
</Stack>

View File

@@ -40,7 +40,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
const [isLoadingAddPage, setIsLoadingAddPage] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState<boolean>(false);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [rowsPerPage, setRowsPerPage] = React.useState<number>(5);
const [f, setF] = React.useState<LessonType[]>([]);
@@ -65,6 +65,8 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
setF(tempLessonTypes);
} catch (error) {
//
logger.error(error);
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
}
@@ -106,9 +108,9 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
if (showError)
return (
<ErrorDisplay
message={t('unable-to-process-request')}
message={t('error.unable-to-process-request')}
code="500"
details={t('detailed-error-information')}
details={showError.detail}
/>
);

View File

@@ -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 { LpQuestion } from '@/components/dashboard/lp_questions.plan/type';
export default function BasicDetailCard({
lpModel: model,
handleEditClick,
}: {
lpModel: LpQuestion;
handleEditClick: () => void;
}): React.JSX.Element {
const { t } = useTranslation();
return (
<Card>
<CardHeader
action={
<IconButton
onClick={() => {
handleEditClick();
}}
>
<PencilSimpleIcon />
</IconButton>
}
avatar={
<Avatar>
<UserIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title={t('list.basic-details')}
/>
<PropertyList
divider={<Divider />}
orientation="vertical"
sx={{ '--PropertyItem-padding': '12px 24px' }}
>
{(
[
{
key: 'Customer ID',
value: (
<Chip
label={model.id}
size="small"
variant="soft"
/>
),
},
{ key: 'Name', value: model.question_name },
{ key: 'Remarks', value: model.remarks },
{ key: 'Description', value: model.description },
] satisfies { key: string; value: React.ReactNode }[]
).map(
(item): React.JSX.Element => (
<PropertyItem
key={item.key}
name={item.key}
value={item.value}
/>
)
)}
</PropertyList>
</Card>
);
}

View File

@@ -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 { LpQuestion } from '@/components/dashboard/lp_questions.plan/type';
function getImageUrlFrRecord(record: LpQuestion): string {
return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.question_image}`;
}
export default function SampleTitleCard({ lpModel }: { lpModel: LpQuestion }): React.JSX.Element {
const { t } = useTranslation();
return (
<>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto' }}
>
<Avatar
variant="rounded"
src={getImageUrlFrRecord(lpModel)}
sx={{ '--Avatar-size': '64px' }}
>
{t('empty')}
</Avatar>
<div>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="h4">{lpModel.question_name}</Typography>
<Chip
icon={
<CheckCircleIcon
color="var(--mui-palette-success-main)"
weight="fill"
/>
}
label={lpModel.visible}
size="small"
variant="outlined"
/>
</Stack>
<Typography
color="text.secondary"
variant="body1"
>
{lpModel.slug}
</Typography>
</div>
</Stack>
<div>
<Button
endIcon={<CaretDownIcon />}
variant="contained"
>
{t('list.action')}
</Button>
</div>
</>
);
}

View File

@@ -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/SamplePaymentCard';
import SampleSecurityCard from '@/app/dashboard/Sample/SampleSecurityCard';
import { COL_QUIZ_LP_QUESTIONS } 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 { defaultLpQuestion } from '@/components/dashboard/lp_questions.plan/_constants';
import { Notifications } from '@/components/dashboard/lp_questions.plan/notifications';
import type { LpQuestion } from '@/components/dashboard/lp_questions.plan/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<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [showLessonQuestion, setShowLessonQuestion] = React.useState<LpQuestion>(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 <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack spacing={3}>
<div>
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.lp_questions.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
{t('list.title')}
</Link>
</div>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<TitleCard lpModel={showLessonQuestion} />
</Stack>
</Stack>
<Grid
container
spacing={4}
>
<Grid
lg={4}
xs={12}
>
<Stack spacing={4}>
<BasicDetailCard
lpModel={showLessonQuestion}
handleEditClick={handleEditClick}
/>
<SampleSecurityCard />
</Stack>
</Grid>
<Grid
lg={8}
xs={12}
>
<Stack spacing={4}>
<SamplePaymentCard />
<SampleAddressCard />
<Notifications notifications={SampleNotifications} />
</Stack>
</Grid>
</Grid>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,17 @@
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/app/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

View File

@@ -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.plan/lp-question-create-form';
export default function Page(): React.JSX.Element {
// RULES: follow the name of page directory
const { t } = useTranslation(['lp_questions']);
return (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack spacing={3}>
<div>
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.lp_questions.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
{t('title')}
</Link>
</div>
<div>
<Typography variant="h4">{t('create.title')}</Typography>
</div>
</Stack>
<LpCategoryCreateForm />
</Stack>
</Box>
);
}

View File

@@ -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,

View File

@@ -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.plan/lp-question-edit-form';
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['lp_questions']);
React.useEffect(() => {
// console.log('helloworld');
}, []);
return (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack spacing={3}>
<div>
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.lp_questions.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
{t('edit.title')}
</Link>
</div>
<div>
<Typography variant="h4">{t('edit.title')}</Typography>
</div>
</Stack>
<LpCategoryEditForm />
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,213 @@
'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 { 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.plan/_constants';
import { LpQuestionsFilters } from '@/components/dashboard/lp_questions.plan/lp-questions-filters';
import type { Filters } from '@/components/dashboard/lp_questions.plan/lp-questions-filters';
import { LpQuestionsPagination } from '@/components/dashboard/lp_questions.plan/lp-questions-pagination';
import { LpQuestionsSelectionProvider } from '@/components/dashboard/lp_questions.plan/lp-questions-selection-context';
import { LpQuestionsTable } from '@/components/dashboard/lp_questions.plan/lp-questions-table';
import type { LpQuestion } from '@/components/dashboard/lp_questions.plan/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, setLessonQuestionsData] = React.useState<LpQuestion[]>([]);
//
const [isLoadingAddPage, setIsLoadingAddPage] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [rowsPerPage, setRowsPerPage] = React.useState<number>(5);
const [filteredQuestions, setFilteredQuestions] = React.useState<LpQuestion[]>([]);
const [currentPage, setCurrentPage] = React.useState<number>(1);
const [recordCount, setRecordCount] = React.useState<number>(0);
const [listOption, setListOption] = React.useState({});
const [listSort, setListSort] = React.useState({});
//
const sortedLessonQuestions = applySort(lessonQuestionsData, sortDir);
const filteredLessonQuestions = applyFilters(sortedLessonQuestions, { email, phone, status });
const reloadRows = async (): Promise<void> => {
try {
const models: ListResult<RecordModel> = await pb
.collection(COL_QUIZ_LP_QUESTIONS)
.getList(currentPage + 1, rowsPerPage, {});
const { items, totalItems } = models;
const tempLessonTypes: LpQuestion[] = items.map((lt) => {
return { ...defaultLpQuestion, ...lt };
});
setLessonQuestionsData(tempLessonTypes);
setRecordCount(totalItems);
setF(tempLessonTypes);
console.log({ currentPage, f });
} catch (error) {
//
setShowError({ show: true, detail: JSON.stringify(error) });
} finally {
setShowLoading(false);
}
};
React.useEffect(() => {
void reloadRows();
}, [currentPage, rowsPerPage, listOption]);
if (showLoading) return <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<Box sx={{ flex: '1 1 auto' }}>
<Typography variant="h4">{t('list.title')}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<LoadingButton
loading={isLoadingAddPage}
onClick={(): void => {
setIsLoadingAddPage(true);
router.push(paths.dashboard.lp_questions.create);
}}
startIcon={<PlusIcon />}
variant="contained"
>
{t('list.add')}
</LoadingButton>
</Box>
</Stack>
<LpQuestionsSelectionProvider lessonQuestions={filteredQuestions}>
<Card>
<LpQuestionsFilters
filters={{ email, phone, status, name, visible, type }}
fullData={lessonQuestionsData}
sortDir={sortDir}
/>
<Divider />
<Box sx={{ overflowX: 'auto' }}>
<LpQuestionsTable
reloadRows={reloadRows}
rows={filteredQuestions}
/>
</Box>
<Divider />
<LpQuestionsPagination
count={recordCount}
page={currentPage}
rowsPerPage={rowsPerPage}
setPage={setCurrentPage}
setRowsPerPage={setRowsPerPage}
/>
</Card>
</LpQuestionsSelectionProvider>
</Stack>
</Box>
);
}
// 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;
//
};
}

View File

@@ -7,11 +7,11 @@ import SampleAddressCard from '@/app/dashboard/Sample/AddressCard';
import { SampleNotifications } from '@/app/dashboard/Sample/Notifications';
import SamplePaymentCard from '@/app/dashboard/Sample/SamplePaymentCard';
import SampleSecurityCard from '@/app/dashboard/Sample/SampleSecurityCard';
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
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 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';
@@ -21,9 +21,9 @@ 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 { 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';
@@ -39,18 +39,18 @@ export default function Page(): React.JSX.Element {
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [showLessonCategory, setShowLessonCategory] = React.useState<LpCategory>(defaultLpCategory);
const [showLessonQuestion, setShowLessonQuestion] = React.useState<LpQuestion>(defaultLpQuestion);
function handleEditClick() {
router.push(paths.dashboard.lp_categories.edit(showLessonCategory.id));
router.push(paths.dashboard.lp_questions.edit(showLessonQuestion.id));
}
React.useEffect(() => {
if (catId) {
pb.collection(COL_QUIZ_LP_CATEGORIES)
pb.collection(COL_QUIZ_LP_QUESTIONS)
.getOne(catId)
.then((model: RecordModel) => {
setShowLessonCategory({ ...defaultLpCategory, ...model });
setShowLessonQuestion({ ...defaultLpQuestion, ...model });
})
.catch((err) => {
logger.error(err);
@@ -94,7 +94,7 @@ export default function Page(): React.JSX.Element {
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
{t('list.title')}
{t('edit.title')}
</Link>
</div>
<Stack
@@ -102,7 +102,7 @@ export default function Page(): React.JSX.Element {
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<TitleCard lpModel={showLessonCategory} />
<TitleCard lpModel={showLessonQuestion} />
</Stack>
</Stack>
<Grid
@@ -115,7 +115,7 @@ export default function Page(): React.JSX.Element {
>
<Stack spacing={4}>
<BasicDetailCard
lpModel={showLessonCategory}
lpModel={showLessonQuestion}
handleEditClick={handleEditClick}
/>
<SampleSecurityCard />

View File

@@ -13,11 +13,11 @@ import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/Arrow
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { LpCategoryCreateForm } from '@/components/dashboard/lp_categories/lp-category-create-form';
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_categories']);
const { t } = useTranslation(['lp_questions']);
return (
<Box
@@ -46,7 +46,7 @@ export default function Page(): React.JSX.Element {
<Typography variant="h4">{t('create.title')}</Typography>
</div>
</Stack>
<LpCategoryCreateForm />
<LpQuestionCreateForm />
</Stack>
</Box>
);

View File

@@ -10,10 +10,10 @@ import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/Arrow
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { LpCategoryEditForm } from '@/components/dashboard/lp_categories/lp-category-edit-form';
import { LpQuestionEditForm } from '@/components/dashboard/lp_questions/lp-question-edit-form';
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['lp_categories']);
const { t } = useTranslation(['lp_questions']);
React.useEffect(() => {
// console.log('helloworld');
@@ -34,7 +34,7 @@ export default function Page(): React.JSX.Element {
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.lp_categories.list}
href={paths.dashboard.lp_questions.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
@@ -46,7 +46,7 @@ export default function Page(): React.JSX.Element {
<Typography variant="h4">{t('edit.title')}</Typography>
</div>
</Stack>
<LpCategoryEditForm />
<LpQuestionEditForm />
</Stack>
</Box>
);

View File

@@ -1,12 +1,12 @@
'use client';
// RULES:
// contains list page for lp_categories (QuizLPCategories)
// 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_CATEGORIES } from '@/constants';
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import { LoadingButton } from '@mui/lab';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
@@ -22,20 +22,20 @@ 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 { 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_categories']);
const { t } = useTranslation(['lp_questions']);
const { email, phone, sortDir, status, name, visible, type } = searchParams;
const router = useRouter();
const [lessonCategoriesData, setLessonCategoriesData] = React.useState<LpCategory[]>([]);
const [lessonQuestionsData, setLessonCategoriesData] = React.useState<LpQuestion[]>([]);
//
const [isLoadingAddPage, setIsLoadingAddPage] = React.useState<boolean>(false);
@@ -43,49 +43,105 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [rowsPerPage, setRowsPerPage] = React.useState<number>(5);
const [f, setF] = React.useState<LpCategory[]>([]);
const [currentPage, setCurrentPage] = React.useState<number>(1);
const [f, setF] = React.useState<LpQuestion[]>([]);
const [currentPage, setCurrentPage] = React.useState<number>(0);
const [recordCount, setRecordCount] = React.useState<number>(0);
const [listOption, setListOption] = React.useState({});
const [listSort, setListSort] = React.useState({});
//
const sortedLessonCategories = applySort(lessonCategoriesData, sortDir);
const sortedLessonCategories = applySort(lessonQuestionsData, sortDir);
const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status });
const reloadRows = async (): Promise<void> => {
try {
const models: ListResult<RecordModel> = await pb
.collection(COL_QUIZ_LP_CATEGORIES)
.getList(currentPage + 1, rowsPerPage, {});
.collection(COL_QUIZ_LP_QUESTIONS)
.getList(currentPage + 1, rowsPerPage, listOption);
const { items, totalItems } = models;
const tempLessonTypes: LpCategory[] = items.map((lt) => {
return { ...defaultLpCategory, ...lt };
const tempLessonTypes: LpQuestion[] = items.map((lt) => {
return { ...defaultLpQuestion, ...lt };
});
setLessonCategoriesData(tempLessonTypes);
setRecordCount(totalItems);
setF(tempLessonTypes);
console.log({ currentPage, f });
// console.log({ currentPage, f });
} catch (error) {
//
setShowError({ show: true, detail: JSON.stringify(error) });
logger.error(error);
setShowError({
//
show: true,
code: error.status,
detail: JSON.stringify(error, null, 2),
});
} finally {
setShowLoading(false);
}
};
const [lastListOption, setLastListOption] = React.useState({});
const isFirstRun = React.useRef(false);
React.useEffect(() => {
void reloadRows();
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 <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
code={showError.code}
details={showError.detail}
/>
);
@@ -113,7 +169,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
loading={isLoadingAddPage}
onClick={(): void => {
setIsLoadingAddPage(true);
router.push(paths.dashboard.lp_categories.create);
router.push(paths.dashboard.lp_questions.create);
}}
startIcon={<PlusIcon />}
variant="contained"
@@ -122,22 +178,22 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
</LoadingButton>
</Box>
</Stack>
<LpCategoriesSelectionProvider lessonCategories={f}>
<LpQuestionsSelectionProvider lessonQuestions={f}>
<Card>
<LpCategoriesFilters
<LpQuestionsFilters
filters={{ email, phone, status, name, visible, type }}
fullData={lessonCategoriesData}
fullData={lessonQuestionsData}
sortDir={sortDir}
/>
<Divider />
<Box sx={{ overflowX: 'auto' }}>
<LpCategoriesTable
<LpQuestionsTable
reloadRows={reloadRows}
rows={f}
/>
</Box>
<Divider />
<LpCategoriesPagination
<LpQuestionsPagination
count={recordCount}
page={currentPage}
rowsPerPage={rowsPerPage}
@@ -145,15 +201,16 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
setRowsPerPage={setRowsPerPage}
/>
</Card>
</LpCategoriesSelectionProvider>
</LpQuestionsSelectionProvider>
</Stack>
<pre>{JSON.stringify(f, null, 2)}</pre>
</Box>
);
}
// Sorting and filtering has to be done on the server.
function applySort(row: LpCategory[], sortDir: 'asc' | 'desc' | undefined): LpCategory[] {
function applySort(row: LpQuestion[], sortDir: 'asc' | 'desc' | undefined): LpQuestion[] {
return row.sort((a, b) => {
if (sortDir === 'asc') {
return a.createdAt.getTime() - b.createdAt.getTime();
@@ -163,7 +220,7 @@ function applySort(row: LpCategory[], sortDir: 'asc' | 'desc' | undefined): LpCa
});
}
function applyFilters(row: LpCategory[], { email, phone, status, name, visible }: Filters): LpCategory[] {
function applyFilters(row: LpQuestion[], { email, phone, status, name, visible }: Filters): LpQuestion[] {
return row.filter((item) => {
if (email) {
if (!item.email?.toLowerCase().includes(email.toLowerCase())) {

View File

@@ -39,11 +39,13 @@ function ErrorDetails({ details }: { details: string }): React.JSX.Element {
<Collapse in={expanded}>
<Box sx={{ mt: 1, p: 1, bgcolor: 'background.paper' }}>
<pre>
<Typography variant="body2" component="pre" sx={{ whiteSpace: 'pre-wrap' }}>
{details}
</Typography>
</pre>
<Typography
variant="body2"
component="pre"
sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}
>
{details}
</Typography>
</Box>
</Collapse>
</Box>
@@ -72,7 +74,10 @@ function ErrorDisplay({ message, code, details, severity = 'error' }: PropsError
>
<AlertTitle>{code ? `Error ${code}` : 'Error'}</AlertTitle>
<Typography variant="body1" gutterBottom>
<Typography
variant="body1"
gutterBottom
>
{formattedMessage}
</Typography>

View File

@@ -1 +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

View File

@@ -1,11 +1,11 @@
import { dayjs } from '@/lib/dayjs';
import { CreateFormProps, LpCategory } from './type';
import { CreateFormProps, LpQuestion } from './type';
export const defaultLpCategory: LpCategory = {
export const defaultLpQuestion: LpQuestion = {
isEmpty: false,
id: 'default-id',
cat_name: 'default-category-name',
q_name: 'default-question-name',
cat_image_url: undefined,
cat_image: undefined,
pos: 0,
@@ -38,7 +38,7 @@ export const defaultLpCategory: LpCategory = {
// imageUrl: '',
// };
export const emptyLpCategory: LpCategory = {
...defaultLpCategory,
export const emptyLpQuestion: LpQuestion = {
...defaultLpQuestion,
isEmpty: true,
};

View File

@@ -37,8 +37,8 @@ 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(),
q_name: zod.string().min(1, 'name-is-required').max(255),
q_image: zod.array(zod.any()).optional(),
pos: zod.number().min(1, 'position is required').max(99),
init_answer: zod.string().optional(),
visible: zod.string(),
@@ -66,7 +66,7 @@ export const defaultValues = {
description: '',
} satisfies Values;
export function LpCategoryCreateForm(): React.JSX.Element {
export function LpQuestionCreateForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);

View File

@@ -44,10 +44,10 @@ import type { EditFormProps } from './type';
// TODO: review this
const schema = zod.object({
cat_name: zod.string().min(1, 'name-is-required').max(255),
q_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(),
q_image: zod.union([zod.array(zod.any()), zod.string()]).optional(),
// position
pos: zod.number().min(1, 'position is required').max(99),
@@ -88,7 +88,7 @@ const defaultValues = {
description: '',
} satisfies Values;
export function LpCategoryEditForm(): React.JSX.Element {
export function LpQuestionEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);

View File

@@ -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<number>(0);
const [visibleCount, setVisibleCount] = React.useState<number>(0);
const [hiddenCount, setHiddenCount] = React.useState<number>(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<void> => {
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 (
<div>
<Tabs
onChange={handleVisibleChange}
sx={{ px: 3 }}
value={visible ?? ''}
variant="scrollable"
>
{tabs.map((tab) => (
<Tab
icon={
<Chip
label={tab.count}
size="small"
variant="soft"
/>
}
iconPosition="end"
key={tab.value}
label={tab.label}
sx={{ minHeight: 'auto' }}
tabIndex={0}
value={tab.value}
/>
))}
</Tabs>
<Divider />
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flexWrap: 'wrap', px: 3, py: 2 }}
>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto', flexWrap: 'wrap' }}
>
<FilterButton
displayValue={name}
label={t('Name')}
onFilterApply={(value) => {
handleNameChange(value as string);
}}
onFilterDelete={() => {
handleNameChange();
}}
popover={<NameFilterPopover />}
value={name}
/>
<FilterButton
displayValue={type}
label={t('Type')}
onFilterApply={(value) => {
handleTypeChange(value as string);
}}
onFilterDelete={() => {
handleTypeChange();
}}
popover={<TypeFilterPopover />}
value={type}
/>
{hasFilters ? <Button onClick={handleClearFilters}>{t('Clear filters')}</Button> : null}
</Stack>
{selection.selectedAny ? (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center' }}
>
<Typography
color="text.secondary"
variant="body2"
>
{selection.selected.size} {t('selected')}
</Typography>
<Button
color="error"
variant="contained"
>
{t('Delete')}
</Button>
</Stack>
) : null}
<Select
name="sort"
onChange={handleSortChange}
sx={{ maxWidth: '100%', width: '120px' }}
value={sortDir}
>
<Option value="desc">{t('Newest')}</Option>
<Option value="asc">{t('Oldest')}</Option>
</Select>
</Stack>
</div>
);
}
function TypeFilterPopover(): React.JSX.Element {
const { t } = useTranslation();
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover
anchorEl={anchorEl}
onClose={onClose}
open={open}
title={t('Filter by type')}
>
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
{t('Apply')}
</Button>
</FilterPopover>
);
}
function NameFilterPopover(): React.JSX.Element {
const { t } = useTranslation();
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover
anchorEl={anchorEl}
onClose={onClose}
open={open}
title={t('Filter by name')}
>
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
{t('apply')}
</Button>
</FilterPopover>
);
}
function EmailFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
const { t } = useTranslation();
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover
anchorEl={anchorEl}
onClose={onClose}
open={open}
title="Filter by email"
>
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
{t('Apply')}
</Button>
</FilterPopover>
);
}
function PhoneFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
const { t } = useTranslation();
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover
anchorEl={anchorEl}
onClose={onClose}
open={open}
title="Filter by phone number"
>
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
{t('Apply')}
</Button>
</FilterPopover>
);
}

View File

@@ -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,
}: 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<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value));
// console.log(parseInt(event.target.value));
};
return (
<TablePagination
component="div"
count={count}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
page={page}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 25]}
/>
);
}

View File

@@ -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 { LpQuestion } from './type';
function noop(): void {
return undefined;
}
export interface LpQuestionsSelectionContextValue extends Selection {}
export const LpQuestionsSelectionContext = React.createContext<LpQuestionsSelectionContextValue>({
deselectAll: noop,
deselectOne: noop,
selectAll: noop,
selectOne: noop,
selected: new Set(),
selectedAny: false,
selectedAll: false,
});
interface LpQuestionsSelectionProviderProps {
children: React.ReactNode;
lessonCategories: LpQuestion[];
}
export function LpQuestionsSelectionProvider({
children,
lessonCategories = [],
}: LpCategoriesSelectionProviderProps): React.JSX.Element {
const customerIds = React.useMemo(() => lessonCategories.map((customer) => customer.id), [lessonCategories]);
const selection = useSelection(customerIds);
return (
<LpQuestionsSelectionContext.Provider value={{ ...selection }}>{children}</LpQuestionsSelectionContext.Provider>
);
}
export function useLpQuestionsSelection(): LpQuestionsSelectionContextValue {
return React.useContext(LpCategoriesSelectionContext);
}

View File

@@ -25,10 +25,10 @@ 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';
import { useLpQuestionsSelection } from './lp-questions-selection-context';
import type { LpQuestion } from './type';
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<LpCategory>[] {
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<LpQuestion>[] {
return [
{
formatter: (row): React.JSX.Element => (
@@ -186,12 +186,12 @@ function columns(handleDeleteClick: (testId: string) => void): ColumnDef<LpCateg
];
}
export interface LessonCategoriesTableProps {
rows: LpCategory[];
export interface LessonQuestionsTableProps {
rows: LpQuestion[];
reloadRows: () => void;
}
export function LpCategoriesTable({ rows, reloadRows }: LessonCategoriesTableProps): React.JSX.Element {
export function LpQuestionsTable({ rows, reloadRows }: LessonQuestionsTableProps): React.JSX.Element {
const { t } = useTranslation(['lp_categories']);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLpCategoriesSelection();

View File

@@ -1,12 +1,12 @@
export interface LpCategory {
export interface LpQuestion {
isEmpty?: boolean;
//
id: string;
collectionId: string;
//
cat_name: string;
cat_image_url?: string;
cat_image?: string;
q_name: string;
q_image_url?: string;
q_image?: string;
pos: number;
visible: string;
lesson_id: string;
@@ -25,8 +25,8 @@ export interface LpCategory {
}
export interface CreateFormProps {
cat_name: string;
cat_image: File[] | null;
q_name: string;
q_image: File[] | null;
pos: number;
init_answer?: string;
visible: string;
@@ -43,8 +43,8 @@ export interface CreateFormProps {
}
export interface EditFormProps {
cat_name: string;
cat_image: File[] | null;
q_name: string;
q_image: File[] | null;
pos: number;
init_answer: any;
visible: string;

View File

@@ -0,0 +1 @@
please review and add translations, e.g. `{t('[word]')}`

View File

@@ -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,
};

View File

@@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
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 { 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 (
<div>
<Modal
open={open}
onClose={handleClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={style}>
<Container maxWidth="sm">
<Paper sx={{ border: '1px solid var(--mui-palette-divider)', boxShadow: 'var(--mui-shadows-16)' }}>
<Stack
direction="row"
spacing={2}
sx={{ display: 'flex', p: 3 }}
>
<Avatar sx={{ bgcolor: 'var(--mui-palette-error-50)', color: 'var(--mui-palette-error-main)' }}>
<NoteIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography variant="h5">{t('Delete Lesson Type ?')}</Typography>
<Typography
color="text.secondary"
variant="body2"
>
{t('Are you sure you want to delete lesson type ?')}
</Typography>
</Stack>
<Stack
direction="row"
spacing={2}
sx={{ justifyContent: 'flex-end' }}
>
<Button
color="secondary"
onClick={handleClose}
>
{t('Cancel')}
</Button>
<LoadingButton
color="error"
variant="contained"
onClick={(e) => {
handleUserConfirmDelete();
}}
loading={isDeleteing}
>
{t('Delete')}
</LoadingButton>
</Stack>
</Stack>
</Stack>
</Paper>
</Container>
</Box>
</Modal>
</div>
);
}

View File

@@ -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_QUESTIONS } 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({
question_name: zod.string().min(1, 'name-is-required').max(255),
question_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<typeof schema>;
export const defaultValues = {
question_name: '',
question_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_questions']);
const [isCreating, setIsCreating] = React.useState<boolean>(false);
const {
control,
handleSubmit,
formState: { errors },
setValue,
watch,
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
setIsCreating(true);
const payload: CreateFormProps = {
question_name: values.question_name,
question_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_QUESTIONS).create(payload);
logger.debug(result);
toast.success(t('create.success'));
router.push(paths.dashboard.lp_questions.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<HTMLInputElement>(null);
const avatar = watch('avatar');
const handleAvatarChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = await fileToBase64(file);
setValue('avatar', url);
}
},
[setValue]
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack
divider={<Divider />}
spacing={4}
>
<Stack spacing={3}>
<Typography variant="h6">{t('create.basic-info')}</Typography>
<Grid
container
spacing={3}
>
<Grid xs={12}>
<Stack
direction="row"
spacing={3}
sx={{ alignItems: 'center' }}
>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '5%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
variant="rounded"
src={avatar}
sx={{
'--Avatar-size': '100px',
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
alignItems: 'center',
bgcolor: 'var(--mui-palette-background-level1)',
color: 'var(--mui-palette-text-primary)',
display: 'flex',
justifyContent: 'center',
}}
>
<CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Box>
<Stack
spacing={1}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1">{t('create.avatar')}</Typography>
<Typography variant="caption">{t('create.avatarRequirements')}</Typography>
<Button
color="secondary"
onClick={() => {
avatarInputRef.current?.click();
}}
variant="outlined"
>
{t('create.avatar_select')}
</Button>
<input
hidden
onChange={handleAvatarChange}
ref={avatarInputRef}
type="file"
/>
</Stack>
</Stack>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="question_name"
render={({ field }) => (
<FormControl
disabled={isCreating}
error={Boolean(errors.question_name)}
fullWidth
>
<InputLabel required>{t('create.question_name')}</InputLabel>
<OutlinedInput {...field} />
{errors.question_name ? <FormHelperText>{errors.question_name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="pos"
render={({ field }) => (
<FormControl
error={Boolean(errors.pos)}
fullWidth
>
<InputLabel required>{t('create.pos')}</InputLabel>
<OutlinedInput
{...field}
onChange={(e) => {
field.onChange(parseInt(e.target.value));
}}
type="number"
/>
{errors.pos ? <FormHelperText>{errors.pos.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="slug"
render={({ field }) => (
<FormControl
error={Boolean(errors.slug)}
fullWidth
>
<InputLabel required>{t('create.slug')}</InputLabel>
<OutlinedInput {...field} />
{errors.slug ? <FormHelperText>{errors.slug.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="visible"
render={({ field }) => (
<FormControl
error={Boolean(errors.visible)}
fullWidth
>
<InputLabel>{t('edit.visible')}</InputLabel>
<Select {...field}>
<MenuItem value="visible">{t('edit.visible')}</MenuItem>
<MenuItem value="hidden">{t('edit.hidden')}</MenuItem>
</Select>
{errors.visible ? <FormHelperText>{errors.visible.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="init_answer"
render={({ field }) => (
<FormControl
error={Boolean(errors.init_answer)}
fullWidth
>
<InputLabel required>{t('create.init_answer')}</InputLabel>
<OutlinedInput {...field} />
{errors.init_answer ? <FormHelperText>{errors.init_answer.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">{t('create.detail-information')}</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="description"
render={({ field }) => {
return (
<Box>
<Typography
variant="subtitle1"
color="text-secondary"
>
{t('create.description')}
</Typography>
<Box sx={{ mt: '8px', '& .tiptap-container': { height: '400px' } }}>
<TextEditor
{...field}
content=""
onUpdate={({ editor }) => {
field.onChange({ target: { value: editor.getHTML() } });
}}
placeholder={t('create.description.default')}
/>
</Box>
</Box>
);
}}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="remarks"
render={({ field }) => (
<Box>
<Typography
variant="subtitle1"
color="text.secondary"
>
{t('create.remarks')}
</Typography>
<Box sx={{ mt: '8px', '& .tiptap-container': { height: '400px' } }}>
<TextEditor
content=""
onUpdate={({ editor }) => {
field.onChange({ target: { value: editor.getText() } });
}}
hideToolbar
placeholder={t('create.remarks.default')}
/>
</Box>
</Box>
)}
/>
</Grid>
</Grid>
</Stack>
{/* */}
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.lesson_questions.list}
>
{t('create.cancelButton')}
</Button>
<LoadingButton
disabled={isCreating}
loading={isCreating}
type="submit"
variant="contained"
>
{t('create.createButton')}
</LoadingButton>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form>
);
}

View File

@@ -0,0 +1,499 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
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({
question_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
question_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<typeof schema>;
const defaultValues = {
question_name: '',
question_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_questions']);
const { cat_id: catId } = useParams<{ cat_id: string }>();
//
const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const {
control,
handleSubmit,
formState: { errors },
setValue,
reset,
watch,
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
setIsUpdating(true);
const tempUpdate: EditFormProps = {
question_name: values.question_name,
question_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_QUESTIONS).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<HTMLInputElement>(null);
const avatar = watch('avatar');
const handleAvatarChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
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<string>('');
const [textRemarks, setTextRemarks] = React.useState<string>('');
// load existing data when user arrive
const loadExistingData = React.useCallback(
async (id: string) => {
setShowLoading(true);
try {
const result = await pb.collection(COL_QUIZ_LP_QUESTIONS).getOne(id);
reset({ ...defaultValues, ...result, init_answer: JSON.stringify(result.init_answer) });
setTextDescription(result.description);
setTextRemarks(result.remarks);
if (result.question_image !== '') {
const fetchResult = await fetch(
`http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.question_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 <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack
divider={<Divider />}
spacing={4}
>
<Stack spacing={3}>
<Typography variant="h6">{t('edit.basic-info')}</Typography>
<Grid
container
spacing={3}
>
<Grid xs={12}>
<Stack
direction="row"
spacing={3}
sx={{ alignItems: 'center' }}
>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '5%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
variant="rounded"
src={avatar}
sx={{
'--Avatar-size': '100px',
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
alignItems: 'center',
bgcolor: 'var(--mui-palette-background-level1)',
color: 'var(--mui-palette-text-primary)',
display: 'flex',
justifyContent: 'center',
}}
>
<CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Box>
<Stack
spacing={1}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1">{t('edit.avatar')}</Typography>
<Typography variant="caption">{t('edit.avatarRequirements')}</Typography>
<Button
color="secondary"
onClick={() => {
avatarInputRef.current?.click();
}}
variant="outlined"
>
{t('edit.avatar_select')}
</Button>
<input
hidden
onChange={handleAvatarChange}
ref={avatarInputRef}
type="file"
/>
</Stack>
</Stack>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
disabled={isUpdating}
control={control}
name="question_name"
render={({ field }) => (
<FormControl
disabled={isUpdating}
error={Boolean(errors.question_name)}
fullWidth
>
<InputLabel required>{t('edit.question_name')}</InputLabel>
<OutlinedInput {...field} />
{errors.question_name ? <FormHelperText>{errors.question_name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isUpdating}
control={control}
name="pos"
render={({ field }) => (
<FormControl
error={Boolean(errors.pos)}
fullWidth
>
<InputLabel required>{t('edit.pos')}</InputLabel>
<OutlinedInput
{...field}
onChange={(e) => {
field.onChange(parseInt(e.target.value));
}}
type="number"
/>
{errors.pos ? <FormHelperText>{errors.pos.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isUpdating}
control={control}
name="slug"
render={({ field }) => (
<FormControl
error={Boolean(errors.slug)}
fullWidth
>
<InputLabel required>{t('edit.slug')}</InputLabel>
<OutlinedInput {...field} />
{errors.slug ? <FormHelperText>{errors.slug.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isUpdating}
control={control}
name="visible"
render={({ field }) => (
<FormControl
error={Boolean(errors.visible)}
fullWidth
>
<InputLabel>{t('edit.visible')}</InputLabel>
<Select {...field}>
<MenuItem value="visible">{t('edit.visible')}</MenuItem>
<MenuItem value="hidden">{t('edit.hidden')}</MenuItem>
</Select>
{errors.visible ? <FormHelperText>{errors.visible.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isUpdating}
control={control}
name="init_answer"
render={({ field }) => (
<FormControl
error={Boolean(errors.init_answer)}
fullWidth
>
<InputLabel required>{t('edit.init_answer')}</InputLabel>
<OutlinedInput {...field} />
{errors.init_answer ? <FormHelperText>{errors.init_answer.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">{t('edit.detail-information')}</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
disabled={isUpdating}
control={control}
name="description"
render={({ field }) => {
return (
<Box>
<Typography
variant="subtitle1"
color="text-secondary"
>
{t('edit.description')}
</Typography>
<Box sx={{ mt: '8px', '& .tiptap-container': { height: '200px' } }}>
<TextEditor
{...field}
content={textDescription}
onUpdate={({ editor }) => {
field.onChange({ target: { value: editor.getHTML() } });
}}
placeholder={t('edit.description.default')}
/>
</Box>
</Box>
);
}}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
disabled={isUpdating}
control={control}
name="remarks"
render={({ field }) => (
<Box>
<Typography
variant="subtitle1"
color="text.secondary"
>
{t('edit.remarks')}
</Typography>
<Box sx={{ mt: '8px', '& .tiptap-container': { height: '200px' } }}>
<TextEditor
content={textRemarks}
onUpdate={({ editor }) => {
field.onChange({ target: { value: editor.getText() } });
}}
hideToolbar
placeholder={t('edit.remarks.default')}
/>
</Box>
</Box>
)}
/>
</Grid>
</Grid>
</Stack>
{/* */}
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.lp_questions.list}
>
{t('edit.cancelButton')}
</Button>
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit"
variant="contained"
>
{t('edit.updateButton')}
</LoadingButton>
</CardActions>
</Card>
</form>
);
}

View File

@@ -23,7 +23,7 @@ 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 { useLpCategoriesSelection } from './lp-questions-selection-context';
import { LpCategory } from './type';
export interface Filters {

View File

@@ -25,7 +25,7 @@ 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 { useLpCategoriesSelection } from './lp-questions-selection-context';
import type { LpCategory } from './type';
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<LpCategory>[] {

View File

@@ -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 => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{row.type}
</Typography>
),
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 <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Notification>[];
export interface NotificationsProps {
notifications: Notification[];
}
export function Notifications({ notifications }: NotificationsProps): React.JSX.Element {
return (
<Card>
<CardHeader
avatar={
<Avatar>
<EnvelopeSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Notifications"
/>
<CardContent>
<Stack spacing={3}>
<Stack spacing={2}>
<Select defaultValue="last_invoice" name="type" sx={{ maxWidth: '100%', width: '320px' }}>
<Option value="last_invoice">Resend last invoice</Option>
<Option value="password_reset">Send password reset</Option>
<Option value="verification">Send verification</Option>
</Select>
<div>
<Button startIcon={<EnvelopeSimpleIcon />} variant="contained">
Send email
</Button>
</div>
</Stack>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Notification> columns={columns} rows={notifications} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -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 => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="subtitle2">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)}
</Typography>
),
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 <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => {
return <Link variant="inherit">{row.invoiceId}</Link>;
},
name: 'Invoice ID',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Payment>[];
export interface PaymentsProps {
ordersValue: number;
payments: Payment[];
refundsValue: number;
totalOrders: number;
}
export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element {
return (
<Card>
<CardHeader
action={
<Button color="secondary" startIcon={<PlusIcon />}>
Create Payment
</Button>
}
avatar={
<Avatar>
<ShoppingCartSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Payments"
/>
<CardContent>
<Stack spacing={3}>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Stack
direction="row"
divider={<Divider flexItem orientation="vertical" />}
spacing={3}
sx={{ justifyContent: 'space-between', p: 2 }}
>
<div>
<Typography color="text.secondary" variant="overline">
Total orders
</Typography>
<Typography variant="h6">{new Intl.NumberFormat('en-US').format(totalOrders)}</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Orders value
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)}
</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Refunds
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)}
</Typography>
</div>
</Stack>
</Card>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Payment> columns={columns} rows={payments} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card sx={{ borderRadius: 1, height: '100%' }} variant="outlined">
<CardContent>
<Stack spacing={2}>
<Typography>
{address.street},
<br />
{address.city}, {address.state}, {address.country},
<br />
{address.zipCode}
</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
{address.primary ? <Chip color="warning" label="Primary" variant="soft" /> : <span />}
<Button color="secondary" size="small" startIcon={<PencilSimpleIcon />}>
Edit
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,61 @@
export interface LpQuestion {
isEmpty?: boolean;
//
id: string;
collectionId: string;
//
question_name: string;
question_image_url?: string;
question_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 {
question_name: string;
question_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 {
question_name: string;
question_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;
}

View File

@@ -1,8 +1,8 @@
import { dayjs } from '@/lib/dayjs';
import { CreateFormProps, LpCategory } from './type';
import { CreateFormProps, LpQuestion } from './type';
export const defaultLpCategory: LpCategory = {
export const defaultLpQuestion: LpQuestion = {
isEmpty: false,
id: 'default-id',
cat_name: 'default-category-name',
@@ -38,7 +38,7 @@ export const defaultLpCategory: LpCategory = {
// imageUrl: '',
// };
export const emptyLpCategory: LpCategory = {
...defaultLpCategory,
export const emptyLpCategory: LpQuestion = {
...defaultLpQuestion,
isEmpty: true,
};

View File

@@ -1,9 +1,7 @@
'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 deleteQuizLPQuestions from '@/db/QuizLPQuestions/Delete';
import { LoadingButton } from '@mui/lab';
import { Button, Container, Modal, Paper } from '@mui/material';
import Avatar from '@mui/material/Avatar';
@@ -11,7 +9,6 @@ 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';
@@ -46,7 +43,7 @@ export default function ConfirmDeleteModal({
function handleUserConfirmDelete(): void {
if (idToDelete) {
setIsDeleteing(true);
deleteQuizLPCategories(idToDelete)
deleteQuizLPQuestions(idToDelete)
.then(() => {
reloadRows();
handleClose();

View File

@@ -3,7 +3,7 @@
import * as React from 'react';
import RouterLink from 'next/link';
import { useRouter } from 'next/navigation';
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
import { Avatar, Divider, MenuItem, Select } from '@mui/material';
@@ -66,9 +66,9 @@ export const defaultValues = {
description: '',
} satisfies Values;
export function LpCategoryCreateForm(): React.JSX.Element {
export function LpQuestionCreateForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
const { t } = useTranslation(['lp_questions']);
const [isCreating, setIsCreating] = React.useState<boolean>(false);
@@ -101,11 +101,11 @@ export function LpCategoryCreateForm(): React.JSX.Element {
};
try {
const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).create(payload);
const result = await pb.collection(COL_QUIZ_LP_QUESTIONS).create(payload);
logger.debug(result);
toast.success(t('create.success'));
router.push(paths.dashboard.lp_categories.list);
router.push(paths.dashboard.lp_questions.list);
} catch (error) {
logger.error(error);
toast.error(t('create.failed'));
@@ -396,7 +396,7 @@ export function LpCategoryCreateForm(): React.JSX.Element {
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.lesson_categories.list}
href={paths.dashboard.lp_questions.list}
>
{t('create.cancelButton')}
</Button>

View File

@@ -4,7 +4,7 @@ import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
@@ -88,9 +88,9 @@ const defaultValues = {
description: '',
} satisfies Values;
export function LpCategoryEditForm(): React.JSX.Element {
export function LpQuestionEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
const { t } = useTranslation(['lp_questions']);
const { cat_id: catId } = useParams<{ cat_id: string }>();
//
@@ -129,10 +129,10 @@ export function LpCategoryEditForm(): React.JSX.Element {
};
try {
const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).update(catId, tempUpdate);
const result = await pb.collection(COL_QUIZ_LP_QUESTIONS).update(catId, tempUpdate);
logger.debug(result);
toast.success(t('edit.success'));
router.push(paths.dashboard.lp_categories.list);
router.push(paths.dashboard.lp_questions.list);
} catch (error) {
logger.error(error);
toast.error(t('update.failed'));
@@ -171,7 +171,7 @@ export function LpCategoryEditForm(): React.JSX.Element {
setShowLoading(true);
try {
const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).getOne(id);
const result = await pb.collection(COL_QUIZ_LP_QUESTIONS).getOne(id);
reset({ ...defaultValues, ...result, init_answer: JSON.stringify(result.init_answer) });
setTextDescription(result.description);
@@ -480,7 +480,7 @@ export function LpCategoryEditForm(): React.JSX.Element {
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.lp_categories.list}
href={paths.dashboard.lp_questions.list}
>
{t('edit.cancelButton')}
</Button>

View File

@@ -23,8 +23,8 @@ 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';
import { useLpCategoriesSelection } from './lp-questions-selection-context';
import { LpQuestion } from './type';
export interface Filters {
email?: string;
@@ -40,10 +40,10 @@ export type SortDir = 'asc' | 'desc';
export interface LpCategoriesFiltersProps {
filters?: Filters;
sortDir?: SortDir;
fullData: LpCategory[];
fullData: LpQuestion[];
}
export function LpCategoriesFilters({
export function LpQuestionsFilters({
filters = {},
sortDir = 'desc',
fullData,
@@ -60,13 +60,13 @@ export function LpCategoriesFilters({
const selection = useLpCategoriesSelection();
function getVisible(): number {
return fullData.reduce((count, item: LpCategory) => {
return fullData.reduce((count, item: LpQuestion) => {
return item.visible === 'visible' ? count + 1 : count;
}, 0);
}
function getHidden(): number {
return fullData.reduce((count, item: LpCategory) => {
return fullData.reduce((count, item: LpQuestion) => {
return item.visible === 'hidden' ? count + 1 : count;
}, 0);
}
@@ -114,7 +114,7 @@ export function LpCategoriesFilters({
}
// NOTE: modify according to COLLECTION
router.push(`${paths.dashboard.lp_categories.list}?${searchParams.toString()}`);
router.push(`${paths.dashboard.lp_questions.list}?${searchParams.toString()}`);
},
[router]
);

View File

@@ -16,7 +16,7 @@ interface LessonCategoriesPaginationProps {
rowsPerPage: number;
}
export function LpCategoriesPagination({
export function LpQuestionsPagination({
count,
page,
//

View File

@@ -6,7 +6,7 @@ import * as React from 'react';
import { useSelection } from '@/hooks/use-selection';
import type { Selection } from '@/hooks/use-selection';
import { LpCategory } from './type';
import { LpQuestion } from './type';
function noop(): void {
return undefined;
@@ -26,12 +26,12 @@ export const LpCategoriesSelectionContext = React.createContext<LpCategoriesSele
interface LpCategoriesSelectionProviderProps {
children: React.ReactNode;
lessonCategories: LpCategory[];
lessonQuestions: LpQuestion[];
}
export function LpCategoriesSelectionProvider({
export function LpQuestionsSelectionProvider({
children,
lessonCategories = [],
lessonQuestions: lessonCategories = [],
}: LpCategoriesSelectionProviderProps): React.JSX.Element {
const customerIds = React.useMemo(() => lessonCategories.map((customer) => customer.id), [lessonCategories]);
const selection = useSelection(customerIds);

View File

@@ -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-questions-selection-context';
import type { LpQuestion } from './type';
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<LpQuestion>[] {
return [
{
formatter: (row): React.JSX.Element => (
<Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center' }}
>
<Link
color="inherit"
component={RouterLink}
href={paths.dashboard.lp_questions.details(row.id)}
sx={{ whiteSpace: 'nowrap' }}
variant="subtitle2"
>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center' }}
>
<Avatar
src={`http://127.0.0.1:8090/api/files/${row.collectionId}/${row.id}/${row.cat_image}`}
variant="rounded"
>
<ImagesIcon size={32} />
</Avatar>{' '}
<div>
<Box sx={{ whiteSpace: 'nowrap' }}>{row.cat_name}</Box>
<Typography
color="text.secondary"
variant="body2"
>
slug: {row.cat_name}
</Typography>
</div>
</Stack>
</Link>
</Stack>
),
name: 'Name',
width: '200px',
},
{
formatter: (row): React.JSX.Element => (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center' }}
>
<LinearProgress
sx={{ flex: '1 1 auto' }}
value={row.quota}
variant="determinate"
/>
<Typography
color="text.secondary"
variant="body2"
>
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)}
</Typography>
</Stack>
),
// 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: (
<CheckCircleIcon
color="var(--mui-palette-success-main)"
weight="fill"
/>
),
},
blocked: { label: 'Blocked', icon: <MinusIcon color="var(--mui-palette-error-main)" /> },
pending: {
label: 'Pending',
icon: (
<ClockIcon
color="var(--mui-palette-warning-main)"
weight="fill"
/>
),
},
NA: {
label: 'NA',
icon: (
<ClockIcon
color="var(--mui-palette-warning-main)"
weight="fill"
/>
),
},
} as const;
const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null };
return (
<Button
onClick={() => {
toast.error('sorry but not implementd');
}}
style={{ backgroundColor: 'transparent' }}
>
{/* <Chip icon={icon} label={label} size="small" variant="outlined" /> */}
{label}
</Button>
);
},
name: 'visible',
width: '150px',
},
{
formatter(row) {
return dayjs(row.createdAt).format('MMM D, YYYY');
},
name: 'Created at',
width: '100px',
},
{
formatter: (row): React.JSX.Element => (
<Stack
direction="row"
spacing={1}
>
<LoadingButton
//
color="secondary"
component={RouterLink}
href={paths.dashboard.lp_questions.details(row.id)}
>
<PencilSimpleIcon size={24} />
</LoadingButton>
<LoadingButton
color="error"
disabled={row.isEmpty}
onClick={() => {
handleDeleteClick(row.id);
}}
>
<TrashSimpleIcon size={24} />
</LoadingButton>
</Stack>
),
name: 'Actions',
hideName: true,
width: '100px',
align: 'right',
},
];
}
export interface LessonCategoriesTableProps {
rows: LpQuestion[];
reloadRows: () => void;
}
export function LpQuestionsTable({ rows, reloadRows }: LessonCategoriesTableProps): React.JSX.Element {
const { t } = useTranslation(['lp_questions']);
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 (
<React.Fragment>
<ConfirmDeleteModal
idToDelete={idToDelete}
open={open}
reloadRows={reloadRows}
setOpen={setOpen}
/>
<DataTable<LpQuestion>
columns={columns(handleDeleteClick)}
onDeselectAll={deselectAll}
onDeselectOne={(_, row) => {
deselectOne(row.id);
}}
onSelectAll={selectAll}
onSelectOne={(_, row) => {
selectOne(row.id);
}}
rows={rows}
selectable
selected={selected}
/>
{!rows.length ? (
<Box sx={{ p: 3 }}>
<Typography
color="text.secondary"
sx={{ textAlign: 'center' }}
variant="body2"
>
{t('no-lesson-categories-found')}
</Typography>
</Box>
) : null}
</React.Fragment>
);
}

View File

@@ -1,4 +1,4 @@
export interface LpCategory {
export interface LpQuestion {
isEmpty?: boolean;
//
id: string;

View File

@@ -2,27 +2,34 @@
## getting started
Imagine there is a software developer and a QA engineer to solve the problems together
They will:
no need to reply me what you are going on and your digest in this phase.
just reply me "OK" when done
base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project`
please read `<base_dir>/001_documentation/Requirements/REQ0006/schema.dbml`
- read `<base_dir>/001_documentation/Requirements/REQ0006/schema.dbml`
this is file in dbml syntax state the main database
please read `<base_dir>/002_source/cms/src/db/schema.json`
- read `<base_dir>/002_source/cms/src/db/schema.json`
this is the file of live pocketbase schema output
please read `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/constants.ts`
- read `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/constants.ts`
this is the content of `@/constants`
please look into the md files in folder `<base_dir>/002_source/cms/_AI_GUIDELINE`
- look into the md files in folder `<base_dir>/002_source/cms/_AI_GUIDELINE`
- read, remember and link up the ideas in file stated above,
i will tell them the task afterwards
The software engineer will provide solutions, while QA engineer will feedback the opinion.
please read, remember and link up the ideas in file stated above,
i will tell you the task afterwards
this is now not in debug phase,
so, no need to reply me what you are going on or your insight throught the prompt.
so, no need to reply me what they are going on or their insight throught the prompt.
just reply me "OK" when done
---

View File

@@ -0,0 +1,12 @@
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
interface CreateForm {
// TODO: Add QuizLPCategories fields
}
export default function createQuizLPCategory(data: CreateForm): Promise<RecordModel> {
return pb.collection(COL_QUIZ_LP_CATEGORIES).create(data);
}

View File

@@ -0,0 +1,7 @@
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import { pb } from '@/lib/pb';
export default function deleteQuizLPCategories(id: string): Promise<boolean> {
return pb.collection(COL_QUIZ_LP_CATEGORIES).delete(id);
}

View File

@@ -0,0 +1,8 @@
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
export default function getAllQuizLPCategories(): Promise<RecordModel[]> {
return pb.collection(COL_QUIZ_LP_CATEGORIES).getFullList();
}

View File

@@ -0,0 +1,9 @@
// REQ0006
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetAllCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_QUIZ_LP_CATEGORIES).getList(1, 9999, {});
return count;
}

View File

@@ -0,0 +1,8 @@
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
export default function getQuizLPCategoryById(id: string): Promise<RecordModel> {
return pb.collection(COL_QUIZ_LP_CATEGORIES).getOne(id);
}

View File

@@ -0,0 +1,14 @@
// REQ0006
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import { pb } from '@/lib/pb';
export default async function getHiddenQuizLPCategoriesCount(): Promise<number> {
try {
const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).getList(1, 9999, { filter: 'visible = "hidden"' });
const { totalItems: count } = result;
return count;
} catch (error) {
return 0;
}
}

View File

@@ -0,0 +1,14 @@
// REQ0006
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import { pb } from '@/lib/pb';
export default async function getVisibleQuizLPCategoriesCount(): Promise<number> {
try {
const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).getList(1, 9999, { filter: 'visible = "visible"' });
const { totalItems: count } = result;
return count;
} catch (error) {
return 0;
}
}

View File

@@ -0,0 +1,9 @@
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { CreateFormProps } from '@/components/dashboard/lp_categories/type';
export default function updateQuizLPCategory(id: string, data: CreateFormProps): Promise<RecordModel> {
return pb.collection(COL_QUIZ_LP_CATEGORIES).update(id, data);
}

View File

@@ -0,0 +1,18 @@
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/db/QuizLPCategories`
it was clone from
`MFCategories`
please help to modify to
`LPCategories`
please also help to modify the name of
`variables`, `constants`, `functions`, `classes`, components's name, paths
the db fields structures between them are the same
do not move the files
do not create directories
keep current folder structure is important
thanks

View File

@@ -0,0 +1,12 @@
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
interface CreateForm {
// TODO: Add QuizLPQuestions fields
}
export default function createQuizLPQuestion(data: CreateForm): Promise<RecordModel> {
return pb.collection(COL_QUIZ_LP_QUESTIONS).create(data);
}

View File

@@ -0,0 +1,7 @@
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import { pb } from '@/lib/pb';
export default function deleteQuizLPQuestions(id: string): Promise<boolean> {
return pb.collection(COL_QUIZ_LP_QUESTIONS).delete(id);
}

View File

@@ -0,0 +1,8 @@
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
export default function getAllQuizLPQuestions(): Promise<RecordModel[]> {
return pb.collection(COL_QUIZ_LP_QUESTIONS).getFullList();
}

View File

@@ -0,0 +1,9 @@
// REQ0006
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetAllCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_QUIZ_LP_QUESTIONS).getList(1, 9999, {});
return count;
}

View File

@@ -0,0 +1,8 @@
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
export default function getQuizLPQuestionById(id: string): Promise<RecordModel> {
return pb.collection(COL_QUIZ_LP_QUESTIONS).getOne(id);
}

View File

@@ -0,0 +1,14 @@
// REQ0006
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function getHiddenQuizLPQuestionsCount(): Promise<number> {
try {
const result = await pb.collection(COL_QUIZ_LP_QUESTIONS).getList(1, 9999, { filter: 'visible = "hidden"' });
const { totalItems: count } = result;
return count;
} catch (error) {
return 0;
}
}

View File

@@ -0,0 +1,14 @@
// REQ0006
import { COL_QUIZ_LP_QUESTIONS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function getVisibleQuizLPQuestionsCount(): Promise<number> {
try {
const result = await pb.collection(COL_QUIZ_LP_QUESTIONS).getList(1, 9999, { filter: 'visible = "visible"' });
const { totalItems: count } = result;
return count;
} catch (error) {
return 0;
}
}

View File

@@ -0,0 +1,9 @@
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { CreateFormProps } from '@/components/dashboard/lp_categories/type';
export default function updateQuizLPCategory(id: string, data: CreateFormProps): Promise<RecordModel> {
return pb.collection(COL_QUIZ_LP_CATEGORIES).update(id, data);
}

View File

@@ -0,0 +1,18 @@
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/db/QuizLPQuestions`
it was clone from
`LPCategories`
please help to modify to
`LPQuestions`
please also help to modify the name of
`variables`, `constants`, `functions`, `classes`, components's name, paths
the db fields structures between them are the same
do not move the files
do not create directories
keep current folder structure is important
thanks

View File

@@ -1479,6 +1479,109 @@
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1125157303",
"max": 0,
"min": 0,
"name": "cat_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "file2034676914",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "cat_image",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
},
{
"hidden": false,
"id": "number2161764012",
"max": null,
"min": null,
"name": "pos",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json3915970527",
"maxSize": 0,
"name": "init_answer",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2058414169",
"max": 0,
"min": 0,
"name": "visible",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2560465762",
"max": 0,
"min": 0,
"name": "slug",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1156222427",
"max": 0,
"min": 0,
"name": "remarks",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"convertURLs": false,
"hidden": false,
"id": "editor1843675174",
"maxSize": 0,
"name": "description",
"presentable": false,
"required": false,
"system": false,
"type": "editor"
},
{
"hidden": false,
"id": "autodate2990389176",
@@ -1685,6 +1788,20 @@
"presentable": false,
"system": false,
"type": "autodate"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2058414169",
"max": 0,
"min": 0,
"name": "visible",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}
],
"indexes": [],
@@ -2701,4 +2818,4 @@
"indexes": [],
"system": false
}
]
]