Compare commits

...

21 Commits

Author SHA1 Message Date
louiscklaw
f44539bf63 ``refactor Update .gitignore to exclude files with 'old' suffix in any directory`` 2025-05-15 11:35:47 +08:00
louiscklaw
407f622f24 ``refactor Prettier config to add comment placeholder for potential future plugin additions `` 2025-05-15 11:35:40 +08:00
louiscklaw
7e2844dd74 ```
refactor Student and Teacher create/edit forms to implement i18n support, update UI components, and standardize API calls
```
2025-05-15 11:35:29 +08:00
louiscklaw
097918340c ```
refactor Teachers list page to remove outdated implementation, simplifying component structure and improving maintainability
```
2025-05-15 11:31:03 +08:00
louiscklaw
3620837a6a ``refactor UserMeta creation and authentication client to improve documentation and type consistency`` 2025-05-15 11:27:02 +08:00
louiscklaw
c83e8c1b6e ```
refactor Student and UserMeta type definitions to deprecate obsolete fields, standardize structure, and update billing address format
```
2025-05-15 11:13:15 +08:00
louiscklaw
af15a6bce0 ```
refactor Student type definitions to deprecate obsolete fields, standardize structure, and update billing address format
```
2025-05-15 11:12:56 +08:00
louiscklaw
d0215cf23f ```
refactor GetById APIs for Students, Teachers, and UserMetas to use consistent type definitions and expand parameters
```
2025-05-15 11:12:29 +08:00
louiscklaw
e523f80123 ```
refactor UserMeta type definitions to deprecate obsolete fields and standardize structure
```
2025-05-15 11:10:40 +08:00
louiscklaw
7f8f8824a7 ```
refactor getImageUrlFromFile to use Pocketbase records and correct path in teachers routes
```
2025-05-15 11:10:12 +08:00
louiscklaw
2aa96eec62 ```
refactor student and teacher APIs to use COL_USER_METAS collection, standardize role assignment, and update type definitions
```
2025-05-15 09:27:38 +08:00
louiscklaw
ecab41abbf `` fix GetUserById API to include requestKey option for Pocketbase compatibility `` 2025-05-15 09:27:20 +08:00
louiscklaw
5a1832ca89 ```
remove obsolete GetAllCount API and related guidelines from Users.old module
```
2025-05-15 09:27:08 +08:00
louiscklaw
160c93b83d `` refactor GetUserById function and add Create/UpdateUser APIs with type definitions `` 2025-05-15 09:26:36 +08:00
louiscklaw
d4fcc1dd8f ```
add UserActivationEditForm component for user activation management
```
2025-05-15 09:24:41 +08:00
louiscklaw
8e3d463f78 ```
use dynamic route parameter for user ID in UserActivationEditForm
```
2025-05-15 09:24:27 +08:00
louiscklaw
fbf79b040f `` remove placeholder _PROMPT.md file and reference to external draft for UserMeta editing page `` 2025-05-15 09:24:21 +08:00
louiscklaw
e34782844e ```
add user profile navigation logic and update button in SettingContainer
```
2025-05-15 09:19:59 +08:00
louiscklaw
ba8e9cca69 fix translation, 2025-05-15 08:35:33 +08:00
louiscklaw
cc9fe057c1 ```
add abbreviations section and clarify guideline reading requirement
```
2025-05-14 18:45:23 +08:00
louiscklaw
cdd95faa89 ```
add new student menu route and component, update login default values and redirect logic
```
2025-05-14 18:31:04 +08:00
57 changed files with 986 additions and 535 deletions

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ _del
*.log
*.del
**/*del
**/*old
**/volumes/**
006_lab

View File

@@ -31,3 +31,7 @@
- `001_documentation/Requirements/REQ0019/index.md` describes updated system architecture
- if the directory contains `_GUIDELINES.md`, please read it before operation
## Abbreviations
T.B.A.

View File

@@ -28,7 +28,10 @@ const config = {
'',
'^[./]',
],
plugins: ['@ianvs/prettier-plugin-sort-imports'],
plugins: [
'@ianvs/prettier-plugin-sort-imports',
//
],
overrides: [
{
files: ['*.tsx'],

View File

@@ -1,19 +1,25 @@
'use client';
// src/app/dashboard/students/create/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import type { Metadata } from 'next';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
import { useTranslation } from 'react-i18next';
import { config } from '@/config';
import { paths } from '@/paths';
import { StudentCreateForm } from '@/components/dashboard/student/student-create-form';
export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['students']);
return (
<Box
sx={{
@@ -29,16 +35,19 @@ export default function Page(): React.JSX.Element {
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.customers.list}
href={paths.dashboard.students.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Customers
{t('students')}
</Link>
</div>
<div>
<Typography variant="h4">Create customer</Typography>
<Typography variant="h4">
{t('create-student')}
{/* */}
</Typography>
</div>
</Stack>
<StudentCreateForm />

View File

@@ -1,6 +1,9 @@
'use client';
// src/app/dashboard/students/edit/[customerId]/page.tsx
// src/app/dashboard/students/edit/[id]/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';

View File

@@ -1,4 +1,9 @@
'use client';
// src/app/dashboard/teachers/create/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';
@@ -6,12 +11,15 @@ import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
import { useTranslation } from 'react-i18next';
import { config } from '@/config';
import { paths } from '@/paths';
import { TeacherCreateForm } from '@/components/dashboard/teacher/teacher-create-form';
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['teachers']);
return (
<Box
sx={{
@@ -32,11 +40,14 @@ export default function Page(): React.JSX.Element {
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Teachers
{t('teachers')}
</Link>
</div>
<div>
<Typography variant="h4">Create teacher</Typography>
<Typography variant="h4">
{t('create-teacher')}
{/* */}
</Typography>
</div>
</Stack>
<TeacherCreateForm />

View File

@@ -1,5 +1,9 @@
'use client';
// src/app/dashboard/teachers/edit/[id]/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';
@@ -10,7 +14,8 @@ import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/Arrow
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form';
// TODO: remove me
// import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form';
import { TeacherEditForm } from '@/components/dashboard/teacher/teacher-edit-form';
export default function Page(): React.JSX.Element {

View File

@@ -1,216 +0,0 @@
// src/app/dashboard/teachers/list/page.tsx
'use client';
// RULES:
// contains list page for teachers (Teachers)
//
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { COL_USER_METAS } 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 { TeachersFilters } from '@/components/dashboard/teacher/teachers-filters';
import { TeachersPagination } from '@/components/dashboard/teacher/teachers-pagination';
import { TeachersSelectionProvider } from '@/components/dashboard/teacher/teachers-selection-context';
import { TeachersTable } from '@/components/dashboard/teacher/teachers-table';
import type { Teacher } from '@/components/dashboard/teacher/type.d';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import ErrorDisplay from '@/components/dashboard/error';
import { defaultTeacher } from '@/components/dashboard/teacher/_constants';
import FormLoading from '@/components/loading';
export default function Page({ searchParams }: PageProps): React.JSX.Element {
const { t } = useTranslation(['teachers']);
const router = useRouter();
const { email, phone, sortDir, status } = searchParams;
const [teacherData, setTeacherData] = React.useState<Teacher[]>([]);
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 [f, setF] = React.useState<Teacher[]>([]);
const [currentPage, setCurrentPage] = React.useState<number>(0);
//
const [recordCount, setRecordCount] = React.useState<number>(0);
const [listOption, setListOption] = React.useState({ filter: '' });
const [listSort, setListSort] = React.useState({});
function isListOptionChanged() {
return JSON.stringify(listOption) === '{}';
}
//
const reloadRows = async (): Promise<void> => {
try {
const listOptionTeacherOnly = isListOptionChanged()
? { filter: `role = "teacher"` }
: { filter: [listOption.filter, `role = "teacher"`].join(' && ') };
const models: ListResult<RecordModel> = await pb
.collection(COL_USER_METAS)
.getList(currentPage + 1, rowsPerPage, listOptionTeacherOnly);
const { items, totalItems } = models;
const tempTeacher: Teacher[] = items.map((lt) => {
return { ...defaultTeacher, ...lt };
});
setTeacherData(tempTeacher);
setRecordCount(totalItems);
setF(tempTeacher);
} catch (error) {
logger.error(error);
setShowError({
//
show: true,
detail: JSON.stringify(error, null, 2),
});
} finally {
setShowLoading(false);
}
};
const [lastListOption, setLastListOption] = React.useState({});
const isFirstRun = React.useRef(false);
React.useEffect(() => {
if (!isFirstRun.current) {
isFirstRun.current = true;
} else if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) {
// reset page number as tab changes
setLastListOption(listOption);
setCurrentPage(0);
void reloadRows();
} else {
void reloadRows();
}
}, [currentPage, rowsPerPage, listOption]);
React.useEffect(() => {
const tempFilter = [];
let tempSortDir = '';
if (status) {
tempFilter.push(`status = "${status}"`);
}
if (sortDir) {
tempSortDir = `-created`;
}
if (email) {
tempFilter.push(`email ~ "%${email}%"`);
}
if (phone) {
tempFilter.push(`phone ~ "%${phone}%"`);
}
let preFinalListOption = { filter: '' };
if (tempFilter.length > 0) {
preFinalListOption = { filter: tempFilter.join(' && ') };
}
if (tempSortDir.length > 0) {
preFinalListOption = { ...preFinalListOption, sort: tempSortDir };
}
setListOption(preFinalListOption);
}, [sortDir, email, phone, status]);
if (showLoading) return <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code={-1}
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.teachers.create);
}}
startIcon={<PlusIcon />}
variant="contained"
>
{t('list.add')}
</LoadingButton>
</Box>
</Stack>
<TeachersSelectionProvider teachers={f}>
<Card>
<TeachersFilters
filters={{ email, phone, status }}
fullData={teacherData}
sortDir={sortDir}
/>
<Divider />
<Box sx={{ overflowX: 'auto' }}>
<TeachersTable
reloadRows={reloadRows}
rows={f}
/>
</Box>
<Divider />
<TeachersPagination
count={recordCount}
page={currentPage}
rowsPerPage={rowsPerPage}
setPage={setCurrentPage}
setRowsPerPage={setRowsPerPage}
/>
</Card>
</TeachersSelectionProvider>
</Stack>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify(f, null, 2)}</pre>
</Box>
</Box>
);
}
interface PageProps {
searchParams: {
email?: string;
phone?: string;
sortDir?: 'asc' | 'desc';
status?: string;
//
};
}

View File

@@ -1,3 +0,0 @@
this `tsx` file is clone from elsewhere, please understand, modify and update the content of `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/user_metas/edit/[id]/page.tsx.draft` to handle `UserMeta` record thanks, modify comments/variables/paths/functions name please
e.g. why `lessonCategories` still exist ?

View File

@@ -1,8 +1,9 @@
'use client';
// src/app/dashboard/user_metas/edit/[id]/page.tsx
// src/app/dashboard/user_metas/edit/[id]/page.tsx
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams } from 'next/navigation';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
@@ -11,11 +12,12 @@ import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/Arrow
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form';
import { UserActivationEditForm } from '@/components/dashboard/user_meta/user-activation-edit-form';
import { UserMetaEditForm } from '@/components/dashboard/user_meta/user-meta-edit-form';
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['lp_categories']);
const { t } = useTranslation(['user_metas']);
const { id: userId } = useParams<{ id: string }>();
React.useEffect(() => {
// console.log('helloworld');
@@ -49,6 +51,7 @@ export default function Page(): React.JSX.Element {
</div>
</Stack>
<UserMetaEditForm />
<UserActivationEditForm userId={userId} />
</Stack>
</Box>
);

View File

@@ -15,12 +15,6 @@ 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 { UserMetasFilters } from '@/components/dashboard/user_meta/user-metas-filters';
import { UserMetasPagination } from '@/components/dashboard/user_meta/user-metas-pagination';
import { UserMetasSelectionProvider } from '@/components/dashboard/user_meta/user-metas-selection-context';
import { UserMetasTable } from '@/components/dashboard/user_meta/user-metas-table';
import type { UserMeta } from '@/components/dashboard/user_meta/type.d';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
@@ -29,10 +23,15 @@ import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import ErrorDisplay from '@/components/dashboard/error';
import { defaultUserMeta } from '@/components/dashboard/user_meta/_constants';
import type { UserMeta } from '@/components/dashboard/user_meta/type.d';
import { UserMetasFilters } from '@/components/dashboard/user_meta/user-metas-filters';
import { UserMetasPagination } from '@/components/dashboard/user_meta/user-metas-pagination';
import { UserMetasSelectionProvider } from '@/components/dashboard/user_meta/user-metas-selection-context';
import { UserMetasTable } from '@/components/dashboard/user_meta/user-metas-table';
import FormLoading from '@/components/loading';
export default function Page({ searchParams }: PageProps): React.JSX.Element {
const { t } = useTranslation(['teachers']);
const { t } = useTranslation(['user_metas']);
const router = useRouter();
const { email, phone, sortDir, status } = searchParams;

View File

@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next';
import { PropertyItem } from '@/components/core/property-item';
import { PropertyList } from '@/components/core/property-list';
// import { CrCategory } from '@/components/dashboard/cr/categories/type';
import type { UserMeta } from '@/components/dashboard/user_meta/type.d';
import type { UserMeta } from '@/components/dashboard/user_meta/type_move.d';
export default function BasicDetailCard({
userMeta,

View File

@@ -1,5 +1,9 @@
'use client';
// src/app/dashboard/user_metas/view/[id]/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
@@ -7,7 +11,7 @@ import SampleAddressCard from '@/app/dashboard/Sample/AddressCard';
import { SampleNotifications } from '@/app/dashboard/Sample/Notifications';
import SamplePaymentCard from '@/app/dashboard/Sample/PaymentCard';
import SampleSecurityCard from '@/app/dashboard/Sample/SecurityCard';
import { COL_USER_METAS } from '@/constants';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
@@ -21,16 +25,14 @@ 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 { defaultUserMeta } from '@/components/dashboard/user_meta/_constants';
import { Notifications } from '@/components/dashboard/user_meta/notifications';
import type { UserMeta } from '@/components/dashboard/user_meta/type_move.d';
import FormLoading from '@/components/loading';
import BasicDetailCard from './BasicDetailCard';
import TitleCard from './TitleCard';
import { defaultUserMeta } from '@/components/dashboard/user_meta/_constants';
import type { UserMeta } from '@/components/dashboard/user_meta/type.d';
import { COL_USER_METAS } from '@/constants';
export default function Page(): React.JSX.Element {
const { t } = useTranslation();

View File

@@ -1,3 +1,6 @@
// PURPOSE
// T.B.A.
//
const helloworld = 'helloworld';
export { helloworld };

View File

@@ -1,14 +1,13 @@
'use client';
// src/components/dashboard/student/student-create-form.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useRouter } from 'next/navigation';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { createStudent } from '@/db/Students/Create';
import { getStudentById } from '@/db/Students/GetById';
import { UpdateStudentById } from '@/db/Students/UpdateById';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
@@ -41,14 +40,10 @@ 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 { Option } from '@/components/core/option';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import { CreateFormProps, Student } from './type.d';
import { CreateFormProps } from './type.d';
// TODO: review schema
const schema = zod.object({
@@ -135,11 +130,11 @@ export function StudentCreateForm(): React.JSX.Element {
// }
const record = await createStudent(tempCreate);
toast.success('Student created');
// router.push(paths.dashboard.students.view(record.id));
toast.success('student-created');
router.push(paths.dashboard.students.view(record.id));
} catch (err) {
logger.error(err);
toast.error('Failed to create Student');
toast.error('failed-to-create-student');
} finally {
setIsUpdating(false);
}

View File

@@ -1,12 +1,13 @@
'use client';
// src/components/dashboard/student/student-edit-form.tsx
// PURPOSE:
// handle change details for student collection
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_CUSTOMERS, COL_USER_METAS } from '@/constants';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { getStudentById } from '@/db/Students/GetById';
import { UpdateStudentById } from '@/db/Students/UpdateById';
@@ -40,12 +41,13 @@ 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 getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import type { Student } from './type.d';
// TODO: review schema
const schema = zod.object({
@@ -116,8 +118,6 @@ export function StudentEditForm(): React.JSX.Element {
setIsUpdating(true);
const updateData = {
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
//
name: values.name,
email: values.email,
phone: values.phone,
@@ -125,16 +125,17 @@ export function StudentEditForm(): React.JSX.Element {
//
// billingAddress: values.billingAddress,
//
taxId: values.taxId,
timezone: values.timezone,
language: values.language,
currency: values.currency,
taxId: values.taxId,
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
};
try {
// await pb.collection(COL_USER_METAS).update(studentId, updateData);
await UpdateStudentById(studentId, updateData);
toast.success('Student updated successfully');
//
toast.success(t('student-updated-successfully'));
router.push(paths.dashboard.students.list);
if (billingAddressId) {
@@ -142,7 +143,7 @@ export function StudentEditForm(): React.JSX.Element {
}
} catch (error) {
logger.error(error);
toast.error('Failed to update student');
toast.error(t('failed-to-update-student'));
} finally {
setIsUpdating(false);
}
@@ -176,22 +177,21 @@ export function StudentEditForm(): React.JSX.Element {
setShowLoading(true);
try {
const result = await getStudentById(id);
const result = (await getStudentById(id)) as unknown as Student;
//
reset({ ...defaultValues, ...result });
setBillingAddressId(result.billingAddress.id);
if (result.avatar) {
const fetchResult = await fetch(
`http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar}`
);
const fetchResult = await fetch(getImageUrlFromFile(result.collectionId, result.id, result.avatar));
const blob = await fetchResult.blob();
const url = await fileToBase64(blob);
setValue('avatar', url);
}
} catch (error) {
logger.error(error);
toast.error('Failed to load student data');
toast.error(t('failed-to-load-student-data'));
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
@@ -365,6 +365,7 @@ export function StudentEditForm(): React.JSX.Element {
)}
/>
</Grid>
{/* */}
</Grid>
</Stack>
{/* */}

View File

@@ -1,20 +1,67 @@
'use client';
// src/components/dashboard/student/type.d.tsx
// RULES: sorting direction for student lists
import type { BillingAddress } from '@/db/billingAddress/type';
// RULES: sorting direction for teacher lists
export type SortDir = 'asc' | 'desc';
export interface DBStudent {
name: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone: string;
quota: number;
company: string;
//
// billingAddress: BillingAddress[] | [];
expand: { billingAddress?: BillingAddress[] };
// status is obsoleted, replace by state
status: 'pending' | 'active' | 'blocked';
state: 'pending' | 'active' | 'blocked';
//
timezone: string;
language: string;
currency: string;
//
id: string;
created: string;
updated?: string;
collectionId: string;
}
// RULES: core student data structure
export interface Student {
id: string;
collectionId: string;
name: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone?: string;
quota: number;
company?: string;
//
billingAddress: BillingAddress | Record<string, never>;
// status is obsoleted, replace by state
status: 'pending' | 'active' | 'blocked';
state: 'pending' | 'active' | 'blocked';
//
timezone: string;
language: string;
currency: string;
//
id: string;
createdAt: Date;
updatedAt?: Date;
collectionId: string;
}
// RULES: form data structure for creating new student
@@ -23,6 +70,7 @@ export interface CreateFormProps {
email: string;
phone?: string;
company?: string;
//
// handle seperately
// billingAddress?: {
// country: string;
@@ -32,6 +80,7 @@ export interface CreateFormProps {
// line1: string;
// line2?: string;
// };
//
taxId?: string;
timezone: string;
language: string;
@@ -64,6 +113,7 @@ export interface EditFormProps {
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
}
// RULES: filter props for student search and filtering
export interface CustomersFiltersProps {
filters?: Filters;

View File

@@ -1,9 +1,19 @@
'use client';
// src/components/dashboard/teacher/teacher-create-form.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useRouter } from 'next/navigation';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { createTeacher } from '@/db/Teachers/Create';
import { getTeacherById } from '@/db/Teachers/GetById';
import { UpdateTeacherById } from '@/db/Teachers/UpdateById';
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';
@@ -16,41 +26,38 @@ import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import 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 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 { Option } from '@/components/core/option';
import { toast } from '@/components/core/toaster';
import { createTeacher } from '@/db/Teachers/Create';
import isDevelopment from '@/lib/check-is-development';
import FormLoading from '@/components/loading';
function fileToBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = () => {
reject(new Error('Error converting file to base64'));
};
});
}
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import { CreateFormProps } from './type.d';
// TODO: review schema
const schema = zod.object({
avatar: zod.string().optional(),
name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
phone: zod.string().min(1, 'Phone is required').max(15),
company: zod.string().max(255),
phone: zod.string().min(1, 'Phone is required').max(25),
company: zod.string().max(255).optional(),
billingAddress: zod.object({
country: zod.string().min(1, 'Country is required').max(255),
state: zod.string().min(1, 'State is required').max(255),
@@ -63,12 +70,12 @@ const schema = zod.object({
timezone: zod.string().min(1, 'Timezone is required').max(255),
language: zod.string().min(1, 'Language is required').max(255),
currency: zod.string().min(1, 'Currency is required').max(255),
avatar: zod.string().optional(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
avatar: '',
name: 'new name',
email: '123@123.com',
phone: '91234567',
@@ -85,10 +92,18 @@ const defaultValues = {
timezone: 'new_york',
language: 'en',
currency: 'USD',
avatar: '',
} satisfies Values;
export function TeacherCreateForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['students']);
//
const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const {
control,
@@ -100,14 +115,31 @@ export function TeacherCreateForm(): React.JSX.Element {
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
// Use standard create method from db/Customers/Create
const tempCreate: CreateFormProps = {
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
//
name: values.name,
email: values.email,
phone: values.phone,
company: values.company,
timezone: values.timezone,
language: values.language,
currency: values.currency,
taxId: values.taxId,
state: 'pending',
meta: {},
};
try {
// Use standard create method from db/Customers/Create
const record = await createTeacher(values);
toast.success('Customer created');
const record = await createTeacher(tempCreate);
toast.success('teacher-created');
router.push(paths.dashboard.teachers.details(record.id));
} catch (err) {
logger.error(err);
toast.error('Failed to create customer');
toast.error('failed-to-create-teacher');
} finally {
setIsUpdating(false);
}
},
[router]
@@ -137,7 +169,7 @@ export function TeacherCreateForm(): React.JSX.Element {
spacing={4}
>
<Stack spacing={3}>
<Typography variant="h6">Account information</Typography>
<Typography variant="h6">{t('create.basic-info')}</Typography>
<Grid
container
spacing={3}
@@ -151,12 +183,13 @@ export function TeacherCreateForm(): React.JSX.Element {
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '50%',
borderRadius: '5%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
variant="rounded"
src={avatar}
sx={{
'--Avatar-size': '100px',
@@ -175,8 +208,8 @@ export function TeacherCreateForm(): React.JSX.Element {
spacing={1}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1">Avatar</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography>
<Typography variant="subtitle1">{t('create.avatar')}</Typography>
<Typography variant="caption">{t('create.avatarRequirements')}</Typography>
<Button
color="secondary"
onClick={() => {
@@ -184,7 +217,7 @@ export function TeacherCreateForm(): React.JSX.Element {
}}
variant="outlined"
>
Select
{t('create.avatar_select')}
</Button>
<input
hidden
@@ -226,7 +259,7 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email address</InputLabel>
<InputLabel required>{t('create.email-address')}</InputLabel>
<OutlinedInput
{...field}
type="email"
@@ -248,7 +281,7 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone number</InputLabel>
<InputLabel required>{t('create.phone-number')}</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
@@ -268,7 +301,10 @@ export function TeacherCreateForm(): React.JSX.Element {
fullWidth
>
<InputLabel>Company</InputLabel>
<OutlinedInput {...field} />
<OutlinedInput
{...field}
placeholder="no company name"
/>
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
@@ -276,8 +312,9 @@ export function TeacherCreateForm(): React.JSX.Element {
</Grid>
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">Billing information</Typography>
<Typography variant="h6">{t('create.billing-information')}</Typography>
<Grid
container
spacing={3}
@@ -296,10 +333,12 @@ export function TeacherCreateForm(): React.JSX.Element {
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<Option value="">Choose a country</Option>
<Option value="us">United States</Option>
<Option value="de">Germany</Option>
<Option value="es">Spain</Option>
<MenuItem value="">Choose a country</MenuItem>
<MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem>
<MenuItem value="DE">Germany</MenuItem>
<MenuItem value="ES">Spain</MenuItem>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress?.country?.message}</FormHelperText>
@@ -362,7 +401,7 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip code</InputLabel>
<InputLabel required>{t('create.zip-code')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress?.zipCode?.message}</FormHelperText>
@@ -383,7 +422,7 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address</InputLabel>
<InputLabel required>{t('create.address-line-1')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress?.line1?.message}</FormHelperText>
@@ -424,7 +463,7 @@ export function TeacherCreateForm(): React.JSX.Element {
/>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional information</Typography>
<Typography variant="h6">{t('create.additional-information')}</Typography>
<Grid
container
spacing={3}
@@ -443,10 +482,14 @@ export function TeacherCreateForm(): React.JSX.Element {
>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<Option value="">Select a timezone</Option>
<Option value="new_york">US - New York</Option>
<Option value="california">US - California</Option>
<Option value="london">UK - London</Option>
<MenuItem value="">Select a timezone</MenuItem>
<MenuItem value="Europe/London">London</MenuItem>
<MenuItem value="Asia/Tokyo">Tokyo</MenuItem>
<MenuItem value="America/Boa_Vista">Boa Vista</MenuItem>
<MenuItem value="America/Grand_Turk">Grand Turk</MenuItem>
<MenuItem value="Asia/Manila">Manila</MenuItem>
<MenuItem value="Asia/Urumqi">Urumqi</MenuItem>
<MenuItem value="Africa/Tunis">Tunis</MenuItem>
</Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
@@ -467,10 +510,11 @@ export function TeacherCreateForm(): React.JSX.Element {
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<Option value="">Select a language</Option>
<Option value="en">English</Option>
<Option value="es">Spanish</Option>
<Option value="de">German</Option>
<MenuItem value="">Select a language</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="de">German</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
@@ -489,12 +533,12 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel>Currency</InputLabel>
<InputLabel required>{t('create.currency')}</InputLabel>
<Select {...field}>
<Option value="">Select a currency</Option>
<Option value="USD">USD</Option>
<Option value="EUR">EUR</Option>
<Option value="RON">RON</Option>
<MenuItem value="">no currency selected</MenuItem>
<MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
@@ -511,14 +555,17 @@ export function TeacherCreateForm(): React.JSX.Element {
component={RouterLink}
href={paths.dashboard.teachers.list}
>
Cancel
{t('create.cancelButton')}
</Button>
<Button
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit"
variant="contained"
>
Create teacher
</Button>
{t('create.updateButton')}
</LoadingButton>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>

View File

@@ -1,12 +1,16 @@
'use client';
// src/components/dashboard/teacher/teacher-edit-form.tsx
// PURPOSE:
// handle change details for teachers collection
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_USER_METAS } from '@/constants';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { getTeacherById } from '@/db/Teachers/GetById';
import { UpdateTeacherById } from '@/db/Teachers/UpdateById';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
@@ -37,14 +41,15 @@ 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 getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import type { Teacher } from './type.d';
// TODO: review this
// TODO: review schema
const schema = zod.object({
name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
@@ -89,7 +94,7 @@ const defaultValues = {
export function TeacherEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
const { t } = useTranslation(['teachers']);
const { id: teacherId } = useParams<{ id: string }>();
//
@@ -97,6 +102,7 @@ export function TeacherEditForm(): React.JSX.Element {
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const [billingAddressId, setBillingAddressId] = React.useState<string | null>(null);
const {
control,
@@ -116,7 +122,9 @@ export function TeacherEditForm(): React.JSX.Element {
email: values.email,
phone: values.phone,
company: values.company,
billingAddress: values.billingAddress,
//
// billingAddress: values.billingAddress,
//
taxId: values.taxId,
timezone: values.timezone,
language: values.language,
@@ -125,12 +133,17 @@ export function TeacherEditForm(): React.JSX.Element {
};
try {
await pb.collection(COL_USER_METAS).update(teacherId, updateData);
toast.success('Teacher updated successfully');
await UpdateTeacherById(teacherId, updateData);
//
toast.success(t('teacher-updated-successfully'));
router.push(paths.dashboard.teachers.list);
if (billingAddressId) {
await UpdateBillingAddressById(billingAddressId, values.billingAddress);
}
} catch (error) {
logger.error(error);
toast.error('Failed to update teacher');
toast.error(t('failed-to-update-teacher'));
} finally {
setIsUpdating(false);
}
@@ -164,21 +177,21 @@ export function TeacherEditForm(): React.JSX.Element {
setShowLoading(true);
try {
const result = await pb.collection(COL_USER_METAS).getOne(id);
const result = (await getTeacherById(id)) as unknown as Teacher;
//
reset({ ...defaultValues, ...result });
console.log({ result });
setBillingAddressId(result.billingAddress.id);
if (result.avatar) {
const fetchResult = await fetch(
`http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar}`
);
const fetchResult = await fetch(getImageUrlFromFile(result.collectionId, result.id, result.avatar));
const blob = await fetchResult.blob();
const url = await fileToBase64(blob);
setValue('avatar', url);
}
} catch (error) {
logger.error(error);
toast.error('Failed to load teacher data');
toast.error(t('failed-to-load-teacher-data'));
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
@@ -301,7 +314,7 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email</InputLabel>
<InputLabel required>{t('edit.email-address')}</InputLabel>
<OutlinedInput
{...field}
type="email"
@@ -323,7 +336,7 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone</InputLabel>
<InputLabel required>{t('edit.phone-number')}</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
@@ -352,11 +365,12 @@ export function TeacherEditForm(): React.JSX.Element {
)}
/>
</Grid>
{/* */}
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">Billing Information</Typography>
<Typography variant="h6">{t('edit.billing-information')}</Typography>
<Grid
container
spacing={3}
@@ -375,9 +389,12 @@ export function TeacherEditForm(): React.JSX.Element {
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<MenuItem value="">No Country selected</MenuItem>
<MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem>
<MenuItem value="DE">Germany</MenuItem>
<MenuItem value="ES">Spain</MenuItem>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress.country.message}</FormHelperText>
@@ -440,7 +457,7 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip Code</InputLabel>
<InputLabel required>{t('edit.zip-code')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText>
@@ -461,7 +478,7 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address Line 1</InputLabel>
<InputLabel required>{t('edit.address-line-1')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress.line1.message}</FormHelperText>
@@ -496,7 +513,7 @@ export function TeacherEditForm(): React.JSX.Element {
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional Information</Typography>
<Typography variant="h6">{t('edit.additional-information')}</Typography>
<Grid
container
spacing={3}
@@ -543,8 +560,10 @@ export function TeacherEditForm(): React.JSX.Element {
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<MenuItem value="">no language selected</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="de">German</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
@@ -564,8 +583,9 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel required>Currency</InputLabel>
<InputLabel required>{t('edit.currency')}</InputLabel>
<Select {...field}>
<MenuItem value="">no currency selected</MenuItem>
<MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem>

View File

@@ -1,5 +1,9 @@
'use client';
// src/components/dashboard/teacher/teachers-table.tsx
// PURPOSE:
// handle change details for teachers collection
//
import * as React from 'react';
import RouterLink from 'next/link';
import { LoadingButton } from '@mui/lab';
@@ -213,7 +217,6 @@ export function TeachersTable({ rows, reloadRows }: TeachersTableProps): React.J
sx={{ textAlign: 'center' }}
variant="body2"
>
{/* TODO: update this */}
{t('no-teachers-found')}
</Typography>
</Box>

View File

@@ -31,21 +31,23 @@ export interface CreateFormProps {
email: string;
phone?: string;
company?: string;
billingAddress?: {
country: string;
state: string;
city: string;
zipCode: string;
line1: string;
line2?: string;
};
// handle seperately
// billingAddress?: {
// country: string;
// state: string;
// city: string;
// zipCode: string;
// line1: string;
// line2?: string;
// };
taxId?: string;
timezone: string;
language: string;
currency: string;
avatar?: string;
avatar?: File | null;
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
state?: 'pending' | 'active' | 'blocked';
meta: Record<string, null>;
}
// RULES: form data structure for editing existing teacher
@@ -77,6 +79,8 @@ export interface TeachersFiltersProps {
sortDir?: SortDir;
fullData: Teacher[];
}
// RULES: available filter options for student data
export interface Filters {
email?: string;
phone?: string;

View File

@@ -3,6 +3,7 @@
// empty valur for customer
import { dayjs } from '@/lib/dayjs';
import type { UserMeta } from './type.d';
export const defaultUserMeta: UserMeta = {

View File

@@ -1,26 +1,11 @@
'use client';
// src/components/dashboard/user_meta/type.d.tsx
// RULES: sorting direction for user meta lists
import type { BillingAddress } from '@/db/billingAddress/type';
// RULES: sorting direction for teacher lists
export type SortDir = 'asc' | 'desc';
// obsoleted
// export interface BillingAddress {
// city: string;
// country: string;
// line1: string;
// line2: string;
// state: string;
// zipCode: string;
// //
// id: string;
// collectionId: string;
// collectionName: string;
// updated: string;
// created: string;
// }
export interface DBUserMeta {
name: string;
//
@@ -50,7 +35,7 @@ export interface DBUserMeta {
collectionId: string;
}
// RULES: core teacher data structure
// RULES: core user meta data structure
export interface UserMeta {
name: string;
//
@@ -72,7 +57,6 @@ export interface UserMeta {
timezone: string;
language: string;
currency: string;
//
id: string;
createdAt: Date;
@@ -80,12 +64,14 @@ export interface UserMeta {
collectionId: string;
}
// RULES: form data structure for creating new teacher
// RULES: form data structure for creating new user meta
export interface CreateFormProps {
name: string;
email: string;
phone?: string;
company?: string;
//
// handle seperately ?
billingAddress?: {
country: string;
state: string;
@@ -94,6 +80,7 @@ export interface CreateFormProps {
line1: string;
line2?: string;
};
//
taxId?: string;
timezone: string;
language: string;
@@ -103,7 +90,7 @@ export interface CreateFormProps {
// status?: 'pending' | 'active' | 'blocked';
}
// RULES: form data structure for editing existing teacher
// RULES: form data structure for editing existing user meta
export interface EditFormProps {
name: string;
email: string;
@@ -126,12 +113,13 @@ export interface EditFormProps {
// status?: 'pending' | 'active' | 'blocked';
}
// RULES: filter props for teacher search and filtering
// RULES: filter props for user meta search and filtering
export interface UserMetasFiltersProps {
filters?: Filters;
sortDir?: SortDir;
fullData: UserMeta[];
}
// RULES: available filter options for user meta data
export interface Filters {
email?: string;
phone?: string;

View File

@@ -0,0 +1,193 @@
'use client';
//
// src/components/dashboard/user_meta/user-activation-edit-form.tsx
// RULES
// handle user change activation of other users
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_USERS } from '@/constants';
import { getUserById } from '@/db/Users/GetById';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
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 Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
//
//
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 { pb } from '@/lib/pb';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
// TODO: review this
const schema = zod.object({
verified: zod.string(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
verified: 'false',
} satisfies Values;
export function UserActivationEditForm({ userId }: { userId: string }): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['user_metas']);
const { id: userMetaId } = useParams<{ 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 updateData = {
verified: false,
};
try {
await pb.collection(COL_USERS).update(userId, updateData);
toast.success(t('user-updated-successfully'));
// router.push(paths.dashboard.user_metas.list);
} catch (error) {
logger.error(error);
toast.error(t('failed-to-update-user-meta'));
} finally {
setIsUpdating(false);
}
},
[userMetaId, router]
);
// 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) => {
try {
const result = await getUserById(userId);
reset({ verified: result.verified.toString() });
setShowLoading(false);
} catch (error) {
logger.error(error);
toast.error('failed-to-load-user-meta-data');
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
}
},
[reset, setValue]
);
React.useEffect(() => {
void loadExistingData(userId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
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}
>
<Controller
control={control}
name="verified"
render={({ field }) => (
<FormControl
error={Boolean(errors.verified)}
fullWidth
>
<InputLabel required>
{t('user-activation')} {t('optional')}
</InputLabel>
<Select {...field}>
<MenuItem value="true">{t('activated')}</MenuItem>
<MenuItem value="false">{t('not-actviate')}</MenuItem>
</Select>
{errors.verified ? <FormHelperText>{errors.verified.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<div>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.user_metas.list}
>
{t('edit.cancelButton')}
</Button>
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit"
variant="contained"
>
{t('edit.updateButton')}
</LoadingButton>
</div>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form>
);
}

View File

@@ -1,12 +1,13 @@
'use client';
// src/components/dashboard/user_meta/user-meta-edit-form.tsx
// PURPOSE:
// handle change details for user meta collection
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_CUSTOMERS, COL_USER_METAS } from '@/constants';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { getUserMetaById } from '@/db/UserMetas/GetById';
import { UpdateUserMetaById } from '@/db/UserMetas/UpdateById';
@@ -40,14 +41,15 @@ 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 getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import type { UserMeta } from './type.d';
// TODO: review this
// TODO: review schema
const schema = zod.object({
name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
@@ -92,7 +94,7 @@ const defaultValues = {
export function UserMetaEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
const { t } = useTranslation(['user_metas']);
const { id: userMetaId } = useParams<{ id: string }>();
//
@@ -100,6 +102,7 @@ export function UserMetaEditForm(): React.JSX.Element {
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const [billingAddressId, setBillingAddressId] = React.useState<string | null>(null);
const {
control,
@@ -119,7 +122,9 @@ export function UserMetaEditForm(): React.JSX.Element {
email: values.email,
phone: values.phone,
company: values.company,
billingAddress: values.billingAddress,
//
// billingAddress: values.billingAddress,
//
taxId: values.taxId,
timezone: values.timezone,
language: values.language,
@@ -128,12 +133,17 @@ export function UserMetaEditForm(): React.JSX.Element {
};
try {
await pb.collection(COL_USER_METAS).update(userMetaId, updateData);
toast.success('Teacher updated successfully');
router.push(paths.dashboard.teachers.list);
await UpdateUserMetaById(userMetaId, updateData);
//
toast.success(t('user-updated-successfully'));
router.push(paths.dashboard.user_metas.list);
if (billingAddressId) {
await UpdateBillingAddressById(billingAddressId, values.billingAddress);
}
} catch (error) {
logger.error(error);
toast.error('Failed to update teacher');
toast.error(t('failed-to-update-user-meta'));
} finally {
setIsUpdating(false);
}
@@ -167,21 +177,21 @@ export function UserMetaEditForm(): React.JSX.Element {
setShowLoading(true);
try {
const result = await pb.collection(COL_USER_METAS).getOne(id);
const result = (await getUserMetaById(id)) as unknown as UserMeta;
//
reset({ ...defaultValues, ...result });
console.log({ result });
setBillingAddressId(result.billingAddress.id);
if (result.avatar) {
const fetchResult = await fetch(
`http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar}`
);
const fetchResult = await fetch(getImageUrlFromFile(result.collectionId, result.id, result.avatar));
const blob = await fetchResult.blob();
const url = await fileToBase64(blob);
setValue('avatar', url);
}
} catch (error) {
logger.error(error);
toast.error('Failed to load teacher data');
toast.error(t('failed-to-load-user-meta-data'));
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
@@ -304,7 +314,7 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email</InputLabel>
<InputLabel required>{t('edit.email-address')}</InputLabel>
<OutlinedInput
{...field}
type="email"
@@ -326,7 +336,7 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone</InputLabel>
<InputLabel required>{t('edit.phone-number')}</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
@@ -355,11 +365,12 @@ export function UserMetaEditForm(): React.JSX.Element {
)}
/>
</Grid>
{/* */}
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">Billing Information</Typography>
<Typography variant="h6">{t('edit.billing-information')}</Typography>
<Grid
container
spacing={3}
@@ -378,9 +389,12 @@ export function UserMetaEditForm(): React.JSX.Element {
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<MenuItem value="">No Country selected</MenuItem>
<MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem>
<MenuItem value="DE">Germany</MenuItem>
<MenuItem value="ES">Spain</MenuItem>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress.country.message}</FormHelperText>
@@ -443,7 +457,7 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip Code</InputLabel>
<InputLabel required>{t('edit.zip-code')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText>
@@ -464,7 +478,7 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address Line 1</InputLabel>
<InputLabel required>{t('edit.address-line-1')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress.line1.message}</FormHelperText>
@@ -499,7 +513,7 @@ export function UserMetaEditForm(): React.JSX.Element {
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional Information</Typography>
<Typography variant="h6">{t('edit.additional-information')}</Typography>
<Grid
container
spacing={3}
@@ -546,8 +560,10 @@ export function UserMetaEditForm(): React.JSX.Element {
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<MenuItem value="">no language selected</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="de">German</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
@@ -567,8 +583,9 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel required>Currency</InputLabel>
<InputLabel required>{t('edit.currency')}</InputLabel>
<Select {...field}>
<MenuItem value="">no currency selected</MenuItem>
<MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem>
@@ -586,7 +603,7 @@ export function UserMetaEditForm(): React.JSX.Element {
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.teachers.list}
href={paths.dashboard.user_metas.list}
>
{t('edit.cancelButton')}
</Button>

View File

@@ -1,11 +1,15 @@
'use client';
// src/components/dashboard/user_meta/user-metas-filters.tsx
// RULES:
// T.B.A.
//
import * as React from 'react';
import { useRouter } from 'next/navigation';
import GetActiveCount from '@/db/UserMetas/GetActiveCount';
import getAllUserMetasCount from '@/db/UserMetas/GetAllCount';
import GetBlockedCount from '@/db/UserMetas/GetBlockedCount';
import GetPendingCount from '@/db/UserMetas/GetPendingCount';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
@@ -18,17 +22,14 @@ import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { FilterButton } from '@/components/core/filter-button';
import { Option } from '@/components/core/option';
import { useUserMetasSelection } from './user-metas-selection-context';
import GetBlockedCount from '@/db/UserMetas/GetBlockedCount';
import GetPendingCount from '@/db/UserMetas/GetPendingCount';
import GetActiveCount from '@/db/UserMetas/GetActiveCount';
import PhoneFilterPopover from './phone-filter-popover';
import EmailFilterPopover from './email-filter-popover';
import type { UserMetasFiltersProps, Filters, SortDir } from './type.d';
import { logger } from '@/lib/default-logger';
import PhoneFilterPopover from './phone-filter-popover';
import type { Filters, SortDir, UserMetasFiltersProps } from './type.d';
import { useUserMetasSelection } from './user-metas-selection-context';
export function UserMetasFilters({
filters = {},

View File

@@ -1,5 +1,9 @@
'use client';
// src/components/dashboard/user_meta/user-metas-table.tsx
// RULES:
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import { LoadingButton } from '@mui/lab';
@@ -18,14 +22,16 @@ 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 { 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 { useUserMetasSelection } from './user-metas-selection-context';
import type { UserMeta } from './type.d';
import { useUserMetasSelection } from './user-metas-selection-context';
function columns(handleDeleteClick: (userMetaId: string) => void): ColumnDef<UserMeta>[] {
return [
@@ -168,6 +174,7 @@ export interface UserMetasTableProps {
}
export function UserMetasTable({ rows, reloadRows }: UserMetasTableProps): React.JSX.Element {
const { t } = useTranslation(['user_metas']);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useUserMetasSelection();
const [idToDelete, setIdToDelete] = React.useState('');
@@ -207,7 +214,7 @@ export function UserMetasTable({ rows, reloadRows }: UserMetasTableProps): React
sx={{ textAlign: 'center' }}
variant="body2"
>
No user metadata found
{t('no-user-meta-found')}
</Typography>
</Box>
) : null}

View File

@@ -1,12 +1,12 @@
// api method for crate student record
// RULES:
// TBA
import { COL_STUDENTS, COL_USER_METAS } from '@/constants';
import { COL_USER_METAS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { CreateFormProps } from '@/components/dashboard/student/type.d';
export async function createStudent(data: CreateFormProps): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).create(data);
return pb.collection(COL_USER_METAS).create({ ...data, role: 'student' });
}

View File

@@ -1,12 +1,16 @@
// src/db/Students/GetById.tsx
//
import { COL_USER_METAS } from '@/constants';
import { pb } from '@/lib/pb';
import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type.d';
import type { DBStudent, Student } from '@/components/dashboard/student/type';
export async function getStudentById(id: string): Promise<UserMeta> {
const record = await pb.collection(COL_USER_METAS).getOne<DBUserMeta>(id, { expand: 'billingAddress, helloworld' });
export async function getStudentById(id: string): Promise<Student> {
const record = await pb
.collection(COL_USER_METAS)
.getOne<DBStudent>(id, { expand: 'billingAddress, helloworld', requestKey: null });
const temp: UserMeta = {
const temp: Student = {
id: record.id,
name: record.name,
email: record.email,

View File

@@ -1,3 +1,7 @@
// src/db/Students/Helloworld.tsx
// RULES:
// T.B.A.
//
export function helloCustomer() {
return 'Hello from Customers module!';
}

View File

@@ -1,15 +1,71 @@
import type { BillingAddress } from '@/components/dashboard/user_meta/type.d';
// src/db/Students/type.d.ts
//
// PURPOSE
// type for student record
//
// RULES: sorting direction for user meta lists
import type { BillingAddress } from '../billingAddress/type';
// Student type definitions
export interface Student {
id: string;
export interface DBStudentOld {
//
name: string;
avatar: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone: string;
quota: number;
status: 'active' | 'blocked' | 'pending';
company: string;
//
// billingAddress: BillingAddress[] | [];
expand: { billingAddress?: BillingAddress[] };
// status is obsoleted, replace by state
status: 'pending' | 'active' | 'blocked';
state: 'pending' | 'active' | 'blocked';
//
timezone: string;
language: string;
currency: string;
//
id: string;
created: string;
updated?: string;
collectionId: string;
}
// RULES: core user meta data structure
export interface Student {
id: string;
name: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone?: string;
quota: number;
company?: string;
//
billingAddress: BillingAddress | Record<string, never>;
// status is obsoleted, replace by state
status: 'pending' | 'active' | 'blocked';
state: 'pending' | 'active' | 'blocked';
//
timezone: string;
language: string;
currency: string;
//
id: string;
createdAt: Date;
updatedAt?: Date;
collectionId: string;
}
export interface UpdateStudent {
@@ -34,6 +90,7 @@ export interface UpdateStudent {
timezone?: string;
language?: string;
currency?: string;
//
taxId?: string;
}

View File

@@ -1,11 +1,12 @@
// api method for crate teacher record
// RULES:
// TBA
import { pb } from '@/lib/pb';
import { COL_TEACHERS } from '@/constants';
import type { CreateFormProps } from '@/components/dashboard/teacher/type.d';
import { COL_USER_METAS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { CreateFormProps } from '@/components/dashboard/teacher/type.d';
export async function createTeacher(data: CreateFormProps): Promise<RecordModel> {
return pb.collection(COL_TEACHERS).create(data);
return pb.collection(COL_USER_METAS).create({ ...data, role: 'teacher' });
}

View File

@@ -1,7 +1,8 @@
import { pb } from '@/lib/pb';
import { COL_TEACHERS } from '@/constants';
import { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
export async function getAllTeachers(options = {}): Promise<RecordModel[]> {
return pb.collection(COL_TEACHERS).getFullList(options);
}

View File

@@ -1,7 +1,30 @@
import { pb } from '@/lib/pb';
import { COL_TEACHERS } from '@/constants';
import { RecordModel } from 'pocketbase';
// src/db/Teachers/GetById.tsx
//
import { COL_USER_METAS } from '@/constants';
export async function getTeacherById(id: string): Promise<RecordModel> {
return pb.collection(COL_TEACHERS).getOne(id);
import { pb } from '@/lib/pb';
import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type.d';
export async function getTeacherById(id: string): Promise<UserMeta> {
const record = await pb.collection(COL_USER_METAS).getOne<DBUserMeta>(id, { expand: 'billingAddress, helloworld' });
const temp: UserMeta = {
id: record.id,
name: record.name,
email: record.email,
quota: record.quota,
billingAddress: record.expand.billingAddress ? record.expand.billingAddress[0] : {},
status: record.status,
state: record.state,
createdAt: new Date(record.created),
collectionId: record.collectionId,
avatar: record.avatar,
phone: record.phone,
company: record.company,
timezone: record.timezone,
language: record.language,
currency: record.currency,
};
return temp;
}

View File

@@ -0,0 +1,10 @@
import { COL_USER_METAS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { UpdateTeacher } from './type';
export async function UpdateTeacherById(id: string, data: Partial<UpdateTeacher>): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).update(id, data);
}

View File

@@ -0,0 +1,41 @@
//
// RULES
// type for teacher record
// Teacher type definitions
export interface Teacher {
id: string;
name: string;
avatar: string;
email: string;
phone: string;
quota: number;
status: 'active' | 'blocked' | 'pending';
createdAt: Date;
}
export interface UpdateTeacher {
name?: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
// avatar_file?: string;
avatar: File | null;
//
email?: string;
phone?: string;
quota?: number;
company?: string;
//
// relation handle seperately
// billingAddress: BillingAddress | Record<string, never>;
// status is obsoleted, replace by state
// status: 'pending' | 'active' | 'blocked';
state?: 'pending' | 'active' | 'blocked';
//
timezone?: string;
language?: string;
currency?: string;
//
taxId?: string;
}

View File

@@ -1,11 +1,14 @@
// api method for crate customer record
// RULES:
// TBA
import { pb } from '@/lib/pb';
// src/db/UserMetas/Create.tsx
//
// PURPOSE:
// create user meta
//
import { COL_USER_METAS } from '@/constants';
import type { CreateFormProps } from '@/components/dashboard/user_meta/type.d';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { CreateFormProps } from '@/components/dashboard/user_meta/type.d';
export async function createUserMeta(data: CreateFormProps): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).create(data);
}

View File

@@ -1,7 +1,32 @@
import { pb } from '@/lib/pb';
// src/db/UserMetas/GetById.tsx
//
import { COL_USER_METAS } from '@/constants';
import { RecordModel } from 'pocketbase';
export async function getUserMetaById(id: string): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).getOne(id);
import { pb } from '@/lib/pb';
import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type';
export async function getUserMetaById(id: string): Promise<UserMeta> {
const record = await pb
.collection(COL_USER_METAS)
.getOne<DBUserMeta>(id, { expand: 'billingAddress, helloworld', requestKey: null });
const temp: UserMeta = {
id: record.id,
name: record.name,
email: record.email,
quota: record.quota,
billingAddress: record.expand.billingAddress ? record.expand.billingAddress[0] : {},
status: record.status,
state: record.state,
createdAt: new Date(record.created),
collectionId: record.collectionId,
avatar: record.avatar,
phone: record.phone,
company: record.company,
timezone: record.timezone,
language: record.language,
currency: record.currency,
};
return temp;
}

View File

@@ -1,5 +1,38 @@
// src/db/UserMetas/type.d.ts
//
// RULES: sorting direction for user meta lists
import type { BillingAddress } from '@/components/dashboard/user_meta/type.d';
export interface DBUserMeta {
name: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone: string;
quota: number;
company: string;
//
// billingAddress: BillingAddress[] | [];
expand: { billingAddress?: BillingAddress[] };
// status is obsoleted, replace by state
status: 'pending' | 'active' | 'blocked';
state: 'pending' | 'active' | 'blocked';
//
timezone: string;
language: string;
currency: string;
//
id: string;
created: string;
updated?: string;
collectionId: string;
}
// UserMeta type definitions
export interface UserMeta {
id: string;

View File

@@ -1,15 +0,0 @@
// REQ0006
import { COL_USERS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetAllCount(): Promise<number> {
try {
const result = await pb.collection(`users`).getList(1, 9999, { filter: 'email != ""' });
const { totalItems: count } = result;
return count;
} catch (error) {
console.error(error);
return -99;
}
}

View File

@@ -1,30 +0,0 @@
# GUIDELINES
This folder contains drivers for `User`/`Users` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function, i'll handle it in the caller
- type information defined in `@/db/Users/type.d.tsx`
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_USERS } from '@/constants';
export async function createUser(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))
}
```

View File

@@ -1,15 +1,9 @@
import { pb } from '@/lib/pb';
import { COL_USERS } from '@/constants';
import type { User } from '@/types/user';
export async function getUserById(id: string): Promise<User> {
try {
const user = await pb.collection(COL_USERS).getOne<User>(id);
return user;
} catch (err) {
if (err instanceof Error && err.message.includes('404')) {
throw new Error(`User with ID ${id} not found`);
}
throw err;
}
import { pb } from '@/lib/pb';
import type { User } from './type.d';
export function getUserById(id: string): Promise<User> {
return pb.collection(COL_USERS).getOne<User>(id);
}

View File

@@ -0,0 +1,10 @@
import { COL_USERS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { UpdateUser } from './type.d';
export async function UpdateUserById(id: string, data: Partial<UpdateUser>): Promise<RecordModel> {
return pb.collection(COL_USERS).update(id, data);
}

View File

@@ -21,9 +21,12 @@ the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-onlin
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_USERS } from '@/constants';
import { pb } from '@/lib/pb';
import type { User } from './type.d';
export async function createUser(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))

15
002_source/cms/src/db/Users/type.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
//
// RULES
// pocketbase Users collection schema
//
// User type definitions
export interface User {
verified: boolean;
//
id: string;
createdAt: Date;
}
export interface UpdateUser {
verified?: boolean;
}

View File

@@ -1,7 +1,11 @@
import { COL_BILLING_ADDRESS, COL_STUDENTS, COL_USER_METAS } from '@/constants';
import { RecordModel } from 'pocketbase';
// src/db/billingAddress/GetById.tsx
//
// PURPOSE:
// to get billing address by its id
//
import { COL_BILLING_ADDRESS } from '@/constants';
import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type.d';
@@ -10,8 +14,6 @@ export async function getBillingAddressById(id: string): Promise<UserMeta> {
.collection(COL_BILLING_ADDRESS)
.getOne<DBUserMeta>(id, { expand: 'billingAddress, helloworld' });
console.log({ record });
const temp: UserMeta = {
id: record.id,
name: record.name,

View File

@@ -1,9 +1,12 @@
'use client';
// src/lib/auth/custom/client.ts
//
import { getUserMetaById } from '@/db/UserMetas/GetById';
import type { User } from '@/types/user';
import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import type { User } from '@/types/user';
function generateToken(): string {
const arr = new Uint8Array(12);
@@ -11,14 +14,6 @@ function generateToken(): string {
return Array.from(arr, (v) => v.toString(16).padStart(2, '0')).join('');
}
const user_xxx = {
id: 'USR-000',
avatar: '/assets/avatar.png',
firstName: 'Sofia',
lastName: 'Rivers',
email: 'sofia@devias.io',
} satisfies User;
export interface SignUpParams {
firstName: string;
lastName: string;

View File

@@ -1,3 +1,8 @@
export default function getImageUrlFromFile(collectionId: string, id: string, catImage: string): string {
return `http://127.0.0.1:8090/api/files/${collectionId}/${id}/${catImage}`;
//
// PURPOSE:
// get file url from pocketbase record
//
export default function getImageUrlFromFile(collectionId: string, id: string, imgFile: string): string {
return `http://127.0.0.1:8090/api/files/${collectionId}/${id}/${imgFile}`;
}

View File

@@ -1,3 +1,7 @@
// src/lib/helloworld.ts
// RULES:
// T.B.A.
//
export function helloworld(): string {
return 'Helloworld';
}

View File

@@ -140,7 +140,7 @@ export const paths = {
list: '/dashboard/teachers/list',
create: '/dashboard/teachers/create',
details: (id: string) => `/dashboard/teachers/view/${id}`,
view: (id: string) => `/dashboard/students/view/${id}`,
view: (id: string) => `/dashboard/teachers/view/${id}`,
edit: (id: string) => `/dashboard/teachers/edit/${id}`,
mail: {
list: (id: string) => `/dashboard/teachers/mail/${id}/list`,

View File

@@ -3,11 +3,13 @@ const Paths = {
AuthLogin: `/auth/login`,
AuthSignUp: `/auth/signup`,
SignUpSuccess: `/auth/sign_up_success`,
AuthorizedTest: `/auth/authorized_test`,
//
StudentMenu: `/auth/student_menu`,
StudentInfo: `/auth/student_info/:id`,
GetStudentInfoLink: (id: string) => `/auth/student_info/${id}`,
//
AuthorizedTest: `/auth/authorized_test`,
//
Setting: `/setting`,
};

View File

@@ -54,6 +54,7 @@ import SignUpSuccess from './pages/auth/SignUpSuccess';
import AuthorizedTest from './pages/auth/AuthorizedTest';
import { AuthGuard } from './components/auth/auth-guard';
import StudentInfo from './pages/auth/StudentInfo';
import StudentMenu from './pages/auth/StudentMenu';
// import { AuthGuard } from './pages/auth/AuthorizedTest/auth-guard';
// import WordPageWithLayout from './pages/Lesson/WordPageWithLayout.del';
@@ -184,12 +185,15 @@ function RouteConfig() {
{/* protected page */}
<AuthGuard>
<Route exact path={Paths.AuthorizedTest}>
<AuthorizedTest />
</Route>
<Route exact path={Paths.StudentInfo}>
<StudentInfo />
</Route>
<Route exact path={Paths.StudentMenu}>
<StudentMenu />
</Route>
<Route exact path={Paths.AuthorizedTest}>
<AuthorizedTest />
</Route>
</AuthGuard>
{/* TODO: remove below */}

View File

@@ -3,6 +3,7 @@ import { arrowBack } from 'ionicons/icons';
import { LESSON_LINK, VERSIONS } from '../../constants';
import SettingSvg from './image.svg';
import { Paths } from '../../Paths';
import { pb } from '../../lib/pb';
interface ContainerProps {
name: string;
@@ -15,6 +16,14 @@ const SettingContainer: React.FC<ContainerProps> = ({ name }) => {
router.push(Paths.AuthHome);
}
function handleUserProfileClick() {
if (pb.authStore.record?.id) {
router.push(Paths.GetStudentInfoLink(pb.authStore.record.id));
} else {
router.push(Paths.AuthLogin);
}
}
return (
<div
style={{
@@ -40,7 +49,7 @@ const SettingContainer: React.FC<ContainerProps> = ({ name }) => {
<p>T.B.A.</p>
</div>
<div>{VERSIONS}</div>
<IonButton onClick={handleAuthHomeClick}>AuthHome</IonButton>
<IonButton onClick={handleUserProfileClick}>User Profile</IonButton>
<IonButton
onClick={() => {
router.push(LESSON_LINK, undefined, 'replace');

View File

@@ -63,8 +63,8 @@ function AuthLogin(): React.JSX.Element {
type Values = zod.infer<typeof schema>;
const defaultValues = {
email: 'user5@123.com',
password: 'user5@123.com',
email: '',
password: '',
//
} satisfies Values;
@@ -81,10 +81,13 @@ function AuthLogin(): React.JSX.Element {
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
//
console.log({ values });
try {
// const authData = await pb.collection(COL_USERS).authWithPassword(values.email, values.password);
await authClient.signInWithPassword({ email: values.email, password: values.password });
await authClient.signInWithPassword({
email: values.email,
password: values.password,
//
});
// Refresh the auth state
await checkSession?.();
@@ -93,7 +96,7 @@ function AuthLogin(): React.JSX.Element {
// UserProvider, for this case, will not refresh the router
// After refresh, GuestGuard will handle the redirect
router.push(Paths.AuthorizedTest);
router.push(Paths.StudentMenu);
} catch (err: any) {
const res_err = err as unknown as ClientResponseError;
const {
@@ -133,7 +136,12 @@ function AuthLogin(): React.JSX.Element {
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'email'}</IonLabel>
<IonInput {...field} type="email" />
<IonInput
type="email"
placeholder="e.g. user5@123.com / user5@123.com"
onIonInput={(e) => field.onChange(e.detail.value)}
onIonBlur={() => field.onBlur()}
/>
{errors.email ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.email.message}</IonText>
@@ -151,10 +159,14 @@ function AuthLogin(): React.JSX.Element {
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'password'}</IonLabel>
<IonInput {...field} type="password" />
<IonInput
type="password"
onIonInput={(e) => field.onChange(e.detail.value)}
onIonBlur={() => field.onBlur()}
/>
{errors.password ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{'errors.password.message'}</IonText>
<IonText>{errors.password.message}</IonText>
</IonText>
) : null}
</>

View File

@@ -0,0 +1,71 @@
import {
IonBackButton,
IonButton,
IonButtons,
IonCardTitle,
IonCol,
IonContent,
IonFooter,
IonGrid,
IonHeader,
IonIcon,
IonInput,
IonLabel,
IonPage,
IonRow,
IonText,
IonToolbar,
useIonRouter,
} from '@ionic/react';
import styles from './style.module.scss';
import _ from 'lodash';
import { Router, useParams } from 'react-router';
import { Wave } from '../../../components/Wave';
import { Paths } from '../../../Paths';
import { useTransition } from 'react';
import { useTranslation } from 'react-i18next';
import { useUser } from '../../../hooks/use-user';
function StudentMenu(): React.JSX.Element {
const router = useIonRouter();
const { t } = useTranslation();
const { user } = useUser();
function handleBackToLogin() {
router.push(Paths.AuthLogin);
}
function handleViewStudentInfoOnClick() {
if (user?.id) {
router.push(Paths.GetStudentInfoLink(user.id));
}
}
return (
<IonPage className={styles.loginPage}>
<IonHeader>{/* */}</IonHeader>
{/* */}
<IonContent fullscreen>
<IonGrid className="ion-padding">
<IonRow>Student Menu</IonRow>
{/* */}
<IonRow>
<IonButton onClick={handleViewStudentInfoOnClick}>{t('view-student-info')}</IonButton>
</IonRow>
{/* */}
<IonRow>
<IonButton onClick={handleBackToLogin}>Back to login</IonButton>
</IonRow>
</IonGrid>
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Wave />
</IonGrid>
</IonFooter>
</IonPage>
);
}
export default StudentMenu;

View File

@@ -0,0 +1,17 @@
.loginPage {
ion-toolbar {
--border-style: none;
--border-color: transparent;
--padding-top: 1rem;
--padding-bottom: 1rem;
--padding-start: 1rem;
--padding-end: 1rem;
}
}
.headingText {
h5 {
margin-top: 0.2rem;
// color: #d3a6c7;
}
}