init draft from teacher collections,

This commit is contained in:
louiscklaw
2025-05-08 17:30:49 +08:00
parent 5640d4d4f6
commit f840f02daf
58 changed files with 3954 additions and 381 deletions

View File

@@ -3,7 +3,7 @@
import type { Customer } from '@/components/dashboard/customer/type.d';
import { dayjs } from '@/lib/dayjs';
export const SampleCustomers = [
export const SampleTeachers = [
{
id: 'USR-005',
name: 'Fran Perez',

View File

@@ -1,11 +1,11 @@
# GUIDELINES
this folder is part of nextjs typescript project and containing page definition for `Customer` / `Customers` record:
this folder is part of nextjs typescript project and containing page definition for `UserMeta` / `UserMetas` record:
- list (./page.tsx)
- view (./[customerId]/page.tsx)
- view (./[userMetaId]/page.tsx)
- create (./create/page.tsx)
- edit (./[customerId]/page.tsx)
- edit (./[userMetaId]/page.tsx)
- translation provided by react-i18next
the `@` sign refer to `<base_dir>/002_source/002_source/cms/src`
@@ -13,17 +13,17 @@ the `@` sign refer to `<base_dir>/002_source/002_source/cms/src`
## Assumption and Requirements
- let one file contains one component only.
- type information defined in `<base_dir>/002_source/cms/src/db/Customers/type.d.tsx`
- it mainly consume the db drivers `Customres` in `<base_dir>/002_source/cms/src/db/Customers`
- type information defined in `<base_dir>/002_source/cms/src/db/UserMetas/type.d.tsx`
- it mainly consume the db drivers `UserMetas` in `<base_dir>/002_source/cms/src/db/UserMetas`
simple template:
```typescript
// src/app/dashboard/customers/page.tsx
// src/app/dashboard/users/page.tsx
'use client';
// RULES:
// contains list page for customers (Customers)
// contains list page for user metas (UserMetas)
// contain definition to collection only
//
import statements here ...
@@ -44,6 +44,6 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
interface PageProps {
searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string };
searchParams: { userId?: string; key?: string; sortDir?: 'asc' | 'desc'; value?: string };
}
```

View File

@@ -1,5 +1,5 @@
'use client';
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';
@@ -9,9 +9,7 @@ import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/Arrow
import { config } from '@/config';
import { paths } from '@/paths';
import { UserCreateForm } from '@/components/dashboard/user/user-create-form';
export const metadata = { title: `Create | Users | Dashboard | ${config.site.name}` } satisfies Metadata;
import { TeacherCreateForm } from '@/components/dashboard/teacher/teacher-create-form';
export default function Page(): React.JSX.Element {
return (
@@ -29,19 +27,19 @@ export default function Page(): React.JSX.Element {
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.users.list}
href={paths.dashboard.teachers.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Users
Teachers
</Link>
</div>
<div>
<Typography variant="h4">Create user</Typography>
<Typography variant="h4">Create teacher</Typography>
</div>
</Stack>
<UserCreateForm />
<TeacherCreateForm />
</Stack>
</Box>
);

View File

@@ -1,11 +0,0 @@
# 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

@@ -11,10 +11,10 @@ import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form';
import { UserEditForm } from '@/components/dashboard/user/user-edit-form';
import { TeacherEditForm } from '@/components/dashboard/teacher/teacher-edit-form';
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['users']);
const { t } = useTranslation(['lp_categories']);
React.useEffect(() => {
// console.log('helloworld');
@@ -35,7 +35,7 @@ export default function Page(): React.JSX.Element {
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.users.list}
href={paths.dashboard.teachers.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
@@ -47,7 +47,7 @@ export default function Page(): React.JSX.Element {
<Typography variant="h4">{t('edit.title')}</Typography>
</div>
</Stack>
<UserEditForm />
<TeacherEditForm />
</Stack>
</Box>
);

View File

@@ -0,0 +1,3 @@
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/teachers/list/page.tsx.draft` to handle `Teacher` record thanks, modify comments/variables/paths/functions name please
e.g. why `lessonCategories` still exist ?

View File

@@ -1,13 +1,12 @@
// src/app/dashboard/customers/page.tsx
// src/app/dashboard/teachers/list/page.tsx
'use client';
// RULES:
// contains list page for customers (Customers)
// contain definition to collection only
// contains list page for teachers (Teachers)
//
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { COL_USERS } from '@/constants';
import { COL_USER_METAS } from '@/constants';
import { LoadingButton } from '@mui/lab';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
@@ -17,11 +16,11 @@ import Typography from '@mui/material/Typography';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import type { ListResult, RecordModel } from 'pocketbase';
import { UsersFilters } from '@/components/dashboard/user/users-filters';
import { UsersPagination } from '@/components/dashboard/user/users-pagination';
import { UsersSelectionProvider } from '@/components/dashboard/user/users-selection-context';
import { UsersTable } from '@/components/dashboard/user/users-table';
import type { User } from '@/components/dashboard/user/type.d';
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';
@@ -29,17 +28,16 @@ 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 { defaultUser } from '@/components/dashboard/user/_constants';
import { defaultTeacher } from '@/components/dashboard/teacher/_constants';
import FormLoading from '@/components/loading';
export default function Page({ searchParams }: PageProps): React.JSX.Element {
const { t } = useTranslation(['customers']);
const { t } = useTranslation(['teachers']);
const router = useRouter();
const { email, phone, sortDir, status } = searchParams;
const [lessonCategoriesData, setLessonCategoriesData] = React.useState<User[]>([]);
//
const [teacherData, setTeacherData] = React.useState<Teacher[]>([]);
const [isLoadingAddPage, setIsLoadingAddPage] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(true);
@@ -47,27 +45,37 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
//
const [rowsPerPage, setRowsPerPage] = React.useState<number>(5);
//
const [f, setF] = React.useState<User[]>([]);
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({});
const [listOption, setListOption] = React.useState({ filter: '' });
const [listSort, setListSort] = React.useState({});
function isListOptionChanged() {
return JSON.stringify(listOption) === JSON.stringify({ filter: '' });
}
//
const reloadRows = async (): Promise<void> => {
try {
const models: ListResult<RecordModel> = await pb.collection(COL_USERS).getList(currentPage + 1, rowsPerPage, {});
const { items, totalItems } = models;
const listOptionTeacherOnly = isListOptionChanged()
? { filter: `role = "teacher"` }
: { filter: [listOption.filter, `role = "teacher"`].join(' && ') };
const tempLessonTypes: User[] = items.map((lt) => {
return { ...defaultUser, ...lt };
console.log({ listOptionTeacherOnly });
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 };
});
setLessonCategoriesData(tempLessonTypes);
setTeacherData(tempTeacher);
setRecordCount(totalItems);
setF(tempLessonTypes);
setF(tempTeacher);
} catch (error) {
//
logger.error(error);
setShowError({
//
@@ -95,7 +103,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
}, [currentPage, rowsPerPage, listOption]);
React.useEffect(() => {
const tempFilter = [];
let tempFilter = [];
let tempSortDir = '';
if (status) {
@@ -109,11 +117,12 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
if (email) {
tempFilter.push(`email ~ "%${email}%"`);
}
if (phone) {
tempFilter.push(`phone ~ "%${phone}%"`);
}
let preFinalListOption = {};
let preFinalListOption = { filter: '' };
if (tempFilter.length > 0) {
preFinalListOption = { filter: tempFilter.join(' && ') };
}
@@ -157,7 +166,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
loading={isLoadingAddPage}
onClick={(): void => {
setIsLoadingAddPage(true);
router.push(paths.dashboard.users.create);
router.push(paths.dashboard.teachers.create);
}}
startIcon={<PlusIcon />}
variant="contained"
@@ -166,22 +175,22 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
</LoadingButton>
</Box>
</Stack>
<UsersSelectionProvider users={f}>
<TeachersSelectionProvider teachers={f}>
<Card>
<UsersFilters
<TeachersFilters
filters={{ email, phone, status }}
fullData={lessonCategoriesData}
fullData={teacherData}
sortDir={sortDir}
/>
<Divider />
<Box sx={{ overflowX: 'auto' }}>
<UsersTable
<TeachersTable
reloadRows={reloadRows}
rows={f}
/>
</Box>
<Divider />
<UsersPagination
<TeachersPagination
count={recordCount}
page={currentPage}
rowsPerPage={rowsPerPage}
@@ -189,7 +198,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
setRowsPerPage={setRowsPerPage}
/>
</Card>
</UsersSelectionProvider>
</TeachersSelectionProvider>
</Stack>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify(f, null, 2)}</pre>

View File

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

@@ -0,0 +1,86 @@
import type { Thread } from '@/components/dashboard/teacher/mail/types';
import { dayjs } from '@/lib/dayjs';
export const SampleThreads = [
{
id: 'TRD-004',
from: { avatar: '/assets/avatar-9.png', email: 'marcus.finn@domain.com', name: 'Marcus Finn' },
to: [{ avatar: '/assets/avatar.png', email: 'sofia@devias.io', name: 'Sofia Rivers' }],
subject: 'Website redesign. Interested in collaboration',
message: `Hey there,
I hope this email finds you well. I'm glad you liked my projects, and I would be happy to provide you with a quote for a similar project.
Please let me know your requirements and any specific details you have in mind, so I can give you an accurate quote.
Looking forward to hearing from you soon.
Best regards,
Marcus Finn`,
attachments: [
{
id: 'ATT-001',
name: 'working-sketch.png',
size: '128.5 KB',
type: 'image',
url: '/assets/image-abstract-1.png',
},
{ id: 'ATT-002', name: 'summer-customers.pdf', size: '782.3 KB', type: 'file', url: '#' },
{
id: 'ATT-003',
name: 'desktop-coffee.png',
size: '568.2 KB',
type: 'image',
url: '/assets/image-minimal-1.png',
},
],
folder: 'inbox',
labels: ['work', 'business'],
isImportant: true,
isStarred: false,
isUnread: true,
createdAt: dayjs().subtract(3, 'hour').toDate(),
},
{
id: 'TRD-003',
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
from: { name: 'Miron Vitold', avatar: '/assets/avatar-1.png', email: 'miron.vitold@domain.com' },
subject: 'Amazing work',
message: `Hey, nice projects! I really liked the one in react. What's your quote on kinda similar project?`,
folder: 'spam',
labels: [],
isImportant: false,
isStarred: true,
isUnread: false,
createdAt: dayjs().subtract(1, 'day').toDate(),
},
{
id: 'TRD-002',
from: { name: 'Penjani Inyene', avatar: '/assets/avatar-4.png', email: 'penjani.inyene@domain.com' },
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
subject: 'Flight reminder',
message: `Dear Sofia,
Your flight is coming up soon. Please don't forget to check in for your scheduled flight.`,
folder: 'inbox',
labels: ['business'],
isImportant: false,
isStarred: false,
isUnread: false,
createdAt: dayjs().subtract(2, 'day').toDate(),
},
{
id: 'TRD-001',
from: { name: 'Carson Darrin', avatar: '/assets/avatar-3.png', email: 'carson.darrin@domain.com' },
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
subject: 'Possible candidates for the position',
message: `My market leading client has another fantastic opportunity for an experienced Software Developer to join them on a heavily remote basis`,
folder: 'trash',
labels: ['personal'],
isImportant: false,
isStarred: false,
isUnread: true,
createdAt: dayjs().subtract(2, 'day').toDate(),
},
] satisfies Thread[];

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import type { Metadata } from 'next';
import { config } from '@/config';
import { ThreadView } from '@/components/dashboard/mail/thread-view';
import { ThreadView } from '@/components/dashboard/teacher/mail/thread-view';
export const metadata = { title: `Thread | Mail | Dashboard | ${config.site.name}` } satisfies Metadata;

View File

@@ -1,9 +1,9 @@
import * as React from 'react';
import { dayjs } from '@/lib/dayjs';
import { MailProvider } from '@/components/dashboard/mail/mail-context';
import { MailView } from '@/components/dashboard/mail/mail-view';
import type { Label, Thread } from '@/components/dashboard/mail/types';
import { MailProvider } from '@/components/dashboard/teacher/mail/mail-context';
import { MailView } from '@/components/dashboard/teacher/mail/mail-view';
import type { Label, Thread } from '@/components/dashboard/teacher/mail/types';
import { SampleThreads } from './SampleThreads';
function filterThreads(threads: Thread[], labelId: string): Thread[] {
return threads.filter((thread) => {
@@ -40,90 +40,6 @@ const labels = [
{ id: 'personal', type: 'custom', name: 'Personal', color: '#FB8A00', unreadCount: 0, totalCount: 1 },
] satisfies Label[];
const threads = [
{
id: 'TRD-004',
from: { avatar: '/assets/avatar-9.png', email: 'marcus.finn@domain.com', name: 'Marcus Finn' },
to: [{ avatar: '/assets/avatar.png', email: 'sofia@devias.io', name: 'Sofia Rivers' }],
subject: 'Website redesign. Interested in collaboration',
message: `Hey there,
I hope this email finds you well. I'm glad you liked my projects, and I would be happy to provide you with a quote for a similar project.
Please let me know your requirements and any specific details you have in mind, so I can give you an accurate quote.
Looking forward to hearing from you soon.
Best regards,
Marcus Finn`,
attachments: [
{
id: 'ATT-001',
name: 'working-sketch.png',
size: '128.5 KB',
type: 'image',
url: '/assets/image-abstract-1.png',
},
{ id: 'ATT-002', name: 'summer-customers.pdf', size: '782.3 KB', type: 'file', url: '#' },
{
id: 'ATT-003',
name: 'desktop-coffee.png',
size: '568.2 KB',
type: 'image',
url: '/assets/image-minimal-1.png',
},
],
folder: 'inbox',
labels: ['work', 'business'],
isImportant: true,
isStarred: false,
isUnread: true,
createdAt: dayjs().subtract(3, 'hour').toDate(),
},
{
id: 'TRD-003',
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
from: { name: 'Miron Vitold', avatar: '/assets/avatar-1.png', email: 'miron.vitold@domain.com' },
subject: 'Amazing work',
message: `Hey, nice projects! I really liked the one in react. What's your quote on kinda similar project?`,
folder: 'spam',
labels: [],
isImportant: false,
isStarred: true,
isUnread: false,
createdAt: dayjs().subtract(1, 'day').toDate(),
},
{
id: 'TRD-002',
from: { name: 'Penjani Inyene', avatar: '/assets/avatar-4.png', email: 'penjani.inyene@domain.com' },
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
subject: 'Flight reminder',
message: `Dear Sofia,
Your flight is coming up soon. Please don't forget to check in for your scheduled flight.`,
folder: 'inbox',
labels: ['business'],
isImportant: false,
isStarred: false,
isUnread: false,
createdAt: dayjs().subtract(2, 'day').toDate(),
},
{
id: 'TRD-001',
from: { name: 'Carson Darrin', avatar: '/assets/avatar-3.png', email: 'carson.darrin@domain.com' },
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
subject: 'Possible candidates for the position',
message: `My market leading client has another fantastic opportunity for an experienced Software Developer to join them on a heavily remote basis`,
folder: 'trash',
labels: ['personal'],
isImportant: false,
isStarred: false,
isUnread: true,
createdAt: dayjs().subtract(2, 'day').toDate(),
},
] satisfies Thread[];
interface LayoutProps {
children: React.ReactNode;
params: { labelId: string };
@@ -132,10 +48,14 @@ interface LayoutProps {
export default function Layout({ children, params }: LayoutProps): React.JSX.Element {
const { labelId } = params;
const filteredThreads = filterThreads(threads, labelId);
const filteredThreads = filterThreads(SampleThreads, labelId);
return (
<MailProvider currentLabelId={labelId} labels={labels} threads={filteredThreads}>
<MailProvider
currentLabelId={labelId}
labels={labels}
threads={filteredThreads}
>
<MailView>{children}</MailView>
</MailProvider>
);

View File

@@ -0,0 +1,11 @@
import * as React from 'react';
import type { Metadata } from 'next';
import { config } from '@/config';
import { ThreadsView } from '@/components/dashboard/teacher/mail/threads-view';
export const metadata = { title: `Mail | Dashboard | ${config.site.name}` } satisfies Metadata;
export default function Page(): React.JSX.Element {
return <ThreadsView />;
}

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import type { Metadata } from 'next';
import { config } from '@/config';
import { ThreadsView } from '@/components/dashboard/mail/threads-view';
import { ThreadsView } from '@/components/dashboard/teacher/mail/threads-view';
export const metadata = { title: `Mail | Dashboard | ${config.site.name}` } satisfies Metadata;

View File

@@ -14,13 +14,13 @@ 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 { User } from '@/components/dashboard/user/type.d';
import type { Customer } from '@/components/dashboard/customer/type.d';
export default function BasicDetailCard({
user: model,
lpModel: model,
handleEditClick,
}: {
user: User;
lpModel: Customer;
handleEditClick: () => void;
}): React.JSX.Element {
const { t } = useTranslation();

View File

@@ -9,17 +9,15 @@ 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 type { Student } from '@/components/dashboard/student/type.d';
import { Teacher } from '@/components/dashboard/teacher/type.d';
// import type { CrCategory } from '@/components/dashboard/cr/categories/type';
function getImageUrlFrRecord(record: Student): string {
// TODO: fix this
// `http://127.0.0.1:8090/api/files/${'record.collectionId'}/${'record.id'}/${'record.cat_image'}`;
return 'getImageUrlFrRecord(helloworld)';
function getImageUrlFrRecord(teacher: Teacher): string {
return `http://127.0.0.1:8090/api/files/${teacher.collectionId}/${teacher.id}/${teacher.avatar}`;
}
export default function SampleTitleCard({ user: lpModel }: { user: Student }): React.JSX.Element {
export default function SampleTitleCard({ teacherRecord }: { teacherRecord: Teacher }): React.JSX.Element {
const { t } = useTranslation();
return (
@@ -31,7 +29,7 @@ export default function SampleTitleCard({ user: lpModel }: { user: Student }): R
>
<Avatar
variant="rounded"
src={getImageUrlFrRecord(lpModel)}
src={getImageUrlFrRecord(teacherRecord)}
sx={{ '--Avatar-size': '64px' }}
>
{t('empty')}
@@ -42,7 +40,7 @@ export default function SampleTitleCard({ user: lpModel }: { user: Student }): R
spacing={2}
sx={{ alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="h4">{lpModel.email}</Typography>
<Typography variant="h4">{teacherRecord.name || teacherRecord.email}</Typography>
<Chip
icon={
<CheckCircleIcon
@@ -50,17 +48,11 @@ export default function SampleTitleCard({ user: lpModel }: { user: Student }): R
weight="fill"
/>
}
label={lpModel.quota}
label={teacherRecord.status}
size="small"
variant="outlined"
/>
</Stack>
<Typography
color="text.secondary"
variant="body1"
>
{lpModel.status}
</Typography>
</div>
</Stack>
<div>

View File

@@ -0,0 +1,3 @@
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/teachers/view/[id]/TitleCard.tsx.draft` to handle `Teacher` record thanks, modify comments/variables/paths/functions name please
e.g. why `lessonCategories` still exist ?

View File

@@ -24,35 +24,35 @@ import { toast } from '@/components/core/toaster';
import ErrorDisplay from '@/components/dashboard/error';
import { Notifications } from '@/components/dashboard/user/notifications';
import { Notifications } from '@/components/dashboard/teacher/notifications';
import FormLoading from '@/components/loading';
import BasicDetailCard from './BasicDetailCard';
import TitleCard from './TitleCard';
import { defaultUser } from '@/components/dashboard/user/_constants';
import type { User } from '@/components/dashboard/user/type.d';
import { COL_USERS } from '@/constants';
import { defaultTeacher } from '@/components/dashboard/teacher/_constants';
import type { Teacher } from '@/components/dashboard/teacher/type.d';
import { COL_USER_METAS } from '@/constants';
export default function Page(): React.JSX.Element {
const { t } = useTranslation();
const router = useRouter();
//
const { id: userId } = useParams<{ id: string }>();
const { id } = useParams<{ id: string }>();
//
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [showUser, setShowUser] = React.useState<User>(defaultUser);
const [showLessonCategory, setShowLessonCategory] = React.useState<Teacher>(defaultTeacher);
function handleEditClick(): void {
router.push(paths.dashboard.users.edit(showUser.id));
router.push(paths.dashboard.teachers.edit(showLessonCategory.id));
}
React.useEffect(() => {
if (userId) {
pb.collection(COL_USERS)
.getOne(userId)
if (id) {
pb.collection(COL_USER_METAS)
.getOne(id)
.then((model: RecordModel) => {
setShowUser({ ...defaultUser, ...model });
setShowLessonCategory({ ...defaultTeacher, ...model });
})
.catch((err) => {
logger.error(err);
@@ -64,9 +64,8 @@ export default function Page(): React.JSX.Element {
setShowLoading(false);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
}, [id]);
// return <>{JSON.stringify({ showError, showLessonCategory }, null, 2)}</>;
@@ -95,12 +94,12 @@ export default function Page(): React.JSX.Element {
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.users.list}
href={paths.dashboard.teachers.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Users
Teachers
</Link>
</div>
<Stack
@@ -108,7 +107,7 @@ export default function Page(): React.JSX.Element {
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<TitleCard user={showUser} />
<TitleCard teacherRecord={showLessonCategory} />
</Stack>
</Stack>
<Grid
@@ -121,7 +120,7 @@ export default function Page(): React.JSX.Element {
>
<Stack spacing={4}>
<BasicDetailCard
user={showUser}
lpModel={showLessonCategory}
handleEditClick={handleEditClick}
/>
<SampleSecurityCard />

View File

@@ -24,35 +24,35 @@ import { toast } from '@/components/core/toaster';
import ErrorDisplay from '@/components/dashboard/error';
import { Notifications } from '@/components/dashboard/student/notifications';
import { Notifications } from '@/components/dashboard/teacher/notifications';
import FormLoading from '@/components/loading';
import BasicDetailCard from './BasicDetailCard';
import TitleCard from './TitleCard';
import { defaultStudent } from '@/components/dashboard/student/_constants';
import type { Student } from '@/components/dashboard/student/type.d';
import { COL_STUDENTS } from '@/constants';
import { defaultTeacher } from '@/components/dashboard/teacher/_constants';
import type { Teacher } from '@/components/dashboard/teacher/type.d';
import { COL_USER_METAS } from '@/constants';
export default function Page(): React.JSX.Element {
const { t } = useTranslation();
const { t } = useTranslation(['teachers']);
const router = useRouter();
//
const { customerId } = useParams<{ customerId: string }>();
const { id } = useParams<{ id: string }>();
//
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [showLessonCategory, setShowLessonCategory] = React.useState<Student>(defaultStudent);
const [teacherData, setTeacherData] = React.useState<Teacher>(defaultTeacher);
function handleEditClick(): void {
router.push(paths.dashboard.students.edit(showLessonCategory.id));
router.push(paths.dashboard.teachers.edit(teacherData.id));
}
React.useEffect(() => {
if (customerId) {
pb.collection(COL_STUDENTS)
.getOne(customerId)
if (id) {
pb.collection(COL_USER_METAS)
.getOne(id)
.then((model: RecordModel) => {
setShowLessonCategory({ ...defaultStudent, ...model });
setTeacherData({ ...defaultTeacher, ...model });
})
.catch((err) => {
logger.error(err);
@@ -64,7 +64,8 @@ export default function Page(): React.JSX.Element {
setShowLoading(false);
});
}
}, [customerId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
// return <>{JSON.stringify({ showError, showLessonCategory }, null, 2)}</>;
@@ -93,12 +94,12 @@ export default function Page(): React.JSX.Element {
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.students.list}
href={paths.dashboard.teachers.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Students
{t('back-to-list')}
</Link>
</div>
<Stack
@@ -106,7 +107,7 @@ export default function Page(): React.JSX.Element {
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<TitleCard lpModel={showLessonCategory} />
<TitleCard lpModel={teacherData} />
</Stack>
</Stack>
<Grid
@@ -119,7 +120,7 @@ export default function Page(): React.JSX.Element {
>
<Stack spacing={4}>
<BasicDetailCard
lpModel={showLessonCategory}
lpModel={teacherData}
handleEditClick={handleEditClick}
/>
<SampleSecurityCard />

View File

@@ -1,80 +0,0 @@
'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 { CrCategory } from '@/components/dashboard/cr/categories/type';
import type { Student } from '@/components/dashboard/student/type.d';
export default function BasicDetailCard({
lpModel: model,
handleEditClick,
}: {
lpModel: Student;
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: 'Email', value: model.email },
{ key: 'Quota', value: model.quota },
{ key: 'Status', value: model.status },
] 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

@@ -1,76 +0,0 @@
'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 type { Student } from '@/components/dashboard/student/type.d';
// import type { CrCategory } from '@/components/dashboard/cr/categories/type';
function getImageUrlFrRecord(record: Student): string {
// TODO: fix this
// `http://127.0.0.1:8090/api/files/${'record.collectionId'}/${'record.id'}/${'record.cat_image'}`;
return 'getImageUrlFrRecord(helloworld)';
}
export default function SampleTitleCard({ lpModel }: { lpModel: Student }): 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.email}</Typography>
<Chip
icon={
<CheckCircleIcon
color="var(--mui-palette-success-main)"
weight="fill"
/>
}
label={lpModel.quota}
size="small"
variant="outlined"
/>
</Stack>
<Typography
color="text.secondary"
variant="body1"
>
{lpModel.status}
</Typography>
</div>
</Stack>
<div>
<Button
endIcon={<CaretDownIcon />}
variant="contained"
>
{t('list.action')}
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,25 @@
# GUIDELINES & KEY COMPONENTS
- `_constants.ts` contains the constant for
- default value (defaultValue)
- empty value (emptyValue)
- `users-table.tsx`
- `confirm-delete-modal.tsx` - delete modal component when click delete button on list
- `users-filters.tsx`
- `users-pagination.tsx`
- `email-filter-popover.tsx`
- `phone-filter-popover.tsx`
- `users-selection-context.tsx`
- `user-create-form.tsx` - form to create a new user
- `user-edit-form.tsx` - form to edit a existing user
- `type.d.tsx` - contains type definition
- `notifications.tsx` - constants used for demonstration
- `payments.tsx` - constants used for demonstration
- `shipping-address.tsx` - constants used for demonstration

View File

@@ -0,0 +1,21 @@
// RULES:
// default variable value for customer
// empty valur for customer
import { dayjs } from '@/lib/dayjs';
import type { User } from './type.d';
export const defaultUser: User = {
id: '',
name: '',
avatar: undefined,
email: '',
phone: undefined,
quota: 0,
status: 'pending',
createdAt: dayjs().toDate(),
};
export const emptyLpCategory: User = {
...defaultUser,
};

View File

@@ -0,0 +1,124 @@
'use client';
import * as React from 'react';
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';
import { deleteUser } from '@/db/Users/Delete';
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);
// RULES: delete<CollectionName>
deleteUser(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 User ?')}</Typography>
<Typography
color="text.secondary"
variant="body2"
>
{t('Are you sure you want to delete this user ?')}
</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,50 @@
'use client';
import * as React from 'react';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import OutlinedInput from '@mui/material/OutlinedInput';
import { FilterPopover, useFilterContext } from '@/components/core/filter-button';
// EmailFilterPopover -> email-filter-popover.tsx
export default function EmailFilterPopover(): React.JSX.Element {
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="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"
>
Apply
</Button>
</FilterPopover>
);
}

View File

@@ -0,0 +1,3 @@
const helloworld = 'helloworld';
export { helloworld };

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,50 @@
'use client';
import * as React from 'react';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import OutlinedInput from '@mui/material/OutlinedInput';
import { FilterPopover, useFilterContext } from '@/components/core/filter-button';
// phone-filter-popover.tsx
export default function PhoneFilterPopover(): React.JSX.Element {
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="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"
>
Apply
</Button>
</FilterPopover>
);
}

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,69 @@
'use client';
export type SortDir = 'asc' | 'desc';
export interface User {
id: string;
name: string;
avatar?: string;
email: string;
phone?: string;
quota: number;
status: 'pending' | 'active' | 'blocked';
createdAt: Date;
updatedAt?: Date;
}
export interface CreateFormProps {
name: string;
email: string;
phone?: string;
company?: string;
billingAddress?: {
country: string;
state: string;
city: string;
zipCode: string;
line1: string;
line2?: string;
};
taxId?: string;
timezone: string;
language: string;
currency: string;
avatar?: string;
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
}
export interface EditFormProps {
name: string;
email: string;
phone?: string;
company?: string;
billingAddress?: {
country: string;
state: string;
city: string;
zipCode: string;
line1: string;
line2?: string;
};
taxId?: string;
timezone: string;
language: string;
currency: string;
avatar?: string;
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
}
export interface CustomersFiltersProps {
filters?: Filters;
sortDir?: SortDir;
fullData: User[];
}
export interface Filters {
email?: string;
phone?: string;
status?: string;
}

View File

@@ -5,18 +5,18 @@
- default value (defaultValue)
- empty value (emptyValue)
- `users-table.tsx`
- `teachers-table.tsx`
- `confirm-delete-modal.tsx` - delete modal component when click delete button on list
- `users-filters.tsx`
- `users-pagination.tsx`
- `teachers-filters.tsx`
- `teachers-pagination.tsx`
- `email-filter-popover.tsx`
- `phone-filter-popover.tsx`
- `users-selection-context.tsx`
- `teachers-selection-context.tsx`
- `user-create-form.tsx` - form to create a new user
- `user-edit-form.tsx` - form to edit a existing user
- `teacher-create-form.tsx` - form to create a new teacher
- `teacher-edit-form.tsx` - form to edit an existing teacher
- `type.d.tsx` - contains type definition

View File

@@ -3,9 +3,9 @@
// empty valur for customer
import { dayjs } from '@/lib/dayjs';
import type { User } from './type.d';
import type { Teacher } from './type.d';
export const defaultUser: User = {
export const defaultTeacher: Teacher = {
id: '',
name: '',
avatar: undefined,
@@ -16,6 +16,6 @@ export const defaultUser: User = {
createdAt: dayjs().toDate(),
};
export const emptyLpCategory: User = {
...defaultUser,
export const emptyLpCategory: Teacher = {
...defaultTeacher,
};

View File

@@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next';
import { logger } from '@/lib/default-logger';
import { toast } from '@/components/core/toaster';
import { deleteUser } from '@/db/Users/Delete';
import { deleteTeacher } from '@/db/Teachers/Delete';
export default function ConfirmDeleteModal({
open,
@@ -45,7 +45,7 @@ export default function ConfirmDeleteModal({
setIsDeleteing(true);
// RULES: delete<CollectionName>
deleteUser(idToDelete)
deleteTeacher(idToDelete)
.then(() => {
reloadRows();
handleClose();
@@ -83,12 +83,12 @@ export default function ConfirmDeleteModal({
</Avatar>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography variant="h5">{t('Delete User ?')}</Typography>
<Typography variant="h5">{t('Delete Teacher ?')}</Typography>
<Typography
color="text.secondary"
variant="body2"
>
{t('Are you sure you want to delete this user ?')}
{t('Are you sure you want to delete this teacher ?')}
</Typography>
</Stack>
<Stack

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { DownloadSimple as DownloadSimpleIcon } from '@phosphor-icons/react/dist/ssr/DownloadSimple';
import { File as FileIcon } from '@phosphor-icons/react/dist/ssr/File';
import type { Attachment } from './types';
export interface AttachmentsProps {
attachments?: Attachment[];
}
export function Attachments({ attachments = [] }: AttachmentsProps): React.JSX.Element {
const count = attachments.length;
return (
<Stack spacing={2}>
<Typography variant="h6">{count} attachments</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start', flexWrap: 'wrap' }}>
{attachments.map((attachment) => (
<Stack
direction="row"
key={attachment.id}
spacing={1}
sx={{ alignItems: 'center', cursor: 'pointer', display: 'flex' }}
>
{attachment.type === 'image' ? <Avatar src={attachment.url} variant="rounded" /> : null}
{attachment.type === 'file' ? (
<Avatar variant="rounded">
<FileIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
) : null}
<div>
<Typography noWrap variant="subtitle2">
{attachment.name}
</Typography>
<Typography color="text.secondary" variant="body2">
{attachment.size}
</Typography>
</div>
</Stack>
))}
</Stack>
<div>
<Button color="secondary" size="small" startIcon={<DownloadSimpleIcon />}>
Download all
</Button>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Input from '@mui/material/Input';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { ArrowsInSimple as ArrowsInSimpleIcon } from '@phosphor-icons/react/dist/ssr/ArrowsInSimple';
import { ArrowsOutSimple as ArrowsOutSimpleIcon } from '@phosphor-icons/react/dist/ssr/ArrowsOutSimple';
import { Image as ImageIcon } from '@phosphor-icons/react/dist/ssr/Image';
import { Paperclip as PaperclipIcon } from '@phosphor-icons/react/dist/ssr/Paperclip';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import type { EditorEvents } from '@tiptap/react';
import { TextEditor } from '@/components/core/text-editor/text-editor';
export interface ComposerProps {
onClose?: () => void;
open: boolean;
}
export function Composer({ onClose, open }: ComposerProps): React.JSX.Element | null {
const [isMaximized, setIsMaximized] = React.useState<boolean>(false);
const [message, setMessage] = React.useState<string>('');
const [subject, setSubject] = React.useState<string>('');
const [to, setTo] = React.useState<string>('');
const handleSubjectChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setSubject(event.target.value);
}, []);
const handleMessageChange = React.useCallback(({ editor }: EditorEvents['update']) => {
setMessage(editor.getText());
}, []);
const handleToChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setTo(event.target.value);
}, []);
if (!open) {
return null;
}
return (
<Paper
sx={{
border: '1px solid var(--mui-palette-divider)',
bottom: 0,
boxShadow: 'var(--mui-shadows-16)',
height: '600px',
m: 2,
maxWidth: '100%',
position: 'fixed',
right: 0,
width: '600px',
zIndex: 'var(--mui-zIndex-modal)',
...(isMaximized && { borderRadius: 0, height: '100%', left: 0, m: 0, top: 0, width: '100%' }),
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', display: 'flex', p: 2 }}>
<Box sx={{ flex: '1 1 auto' }}>
<Typography variant="h6">New message</Typography>
</Box>
{isMaximized ? (
<IconButton
onClick={() => {
setIsMaximized(false);
}}
>
<ArrowsInSimpleIcon />
</IconButton>
) : (
<IconButton
onClick={() => {
setIsMaximized(true);
}}
>
<ArrowsOutSimpleIcon />
</IconButton>
)}
<IconButton onClick={onClose}>
<XIcon />
</IconButton>
</Stack>
<div>
<Input onChange={handleToChange} placeholder="To" value={to} />
<Divider />
<Input onChange={handleSubjectChange} placeholder="Subject" value={subject} />
<Divider />
<Box sx={{ '& .tiptap-root': { border: 'none', borderRadius: 0 }, '& .tiptap-container': { height: '300px' } }}>
<TextEditor content={message} onUpdate={handleMessageChange} placeholder="Leave a message" />
</Box>
<Divider />
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between', p: 2 }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Tooltip title="Attach image">
<IconButton>
<ImageIcon />
</IconButton>
</Tooltip>
<Tooltip title="Attach file">
<IconButton>
<PaperclipIcon />
</IconButton>
</Tooltip>
</Stack>
<div>
<Button variant="contained">Send</Button>
</div>
</Stack>
</div>
</Paper>
);
}

View File

@@ -0,0 +1,100 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import type { Icon } from '@phosphor-icons/react/dist/lib/types';
import { Bookmark as BookmarkIcon } from '@phosphor-icons/react/dist/ssr/Bookmark';
import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple';
import { File as FileIcon } from '@phosphor-icons/react/dist/ssr/File';
import { PaperPlaneTilt as PaperPlaneTiltIcon } from '@phosphor-icons/react/dist/ssr/PaperPlaneTilt';
import { Star as StarIcon } from '@phosphor-icons/react/dist/ssr/Star';
import { Tag as TagIcon } from '@phosphor-icons/react/dist/ssr/Tag';
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
import { WarningCircle as WarningCircleIcon } from '@phosphor-icons/react/dist/ssr/WarningCircle';
import type { Label } from './types';
const systemLabelIcons: Record<string, Icon> = {
inbox: EnvelopeSimpleIcon,
sent: PaperPlaneTiltIcon,
trash: TrashIcon,
drafts: FileIcon,
spam: WarningCircleIcon,
starred: StarIcon,
important: BookmarkIcon,
};
function getIcon(label: Label): Icon {
if (label.type === 'system') {
return systemLabelIcons[label.id] ?? systemLabelIcons.inbox;
}
return TagIcon;
}
function getIconColor(label: Label, active?: boolean): string {
if (label.type === 'custom') {
return label.color ?? 'var(--mui-palette-text-secondary)';
}
return active ? 'var(--mui-palette-text-primary)' : 'var(--mui-palette-text-secondary)';
}
export interface LabelItemProps {
active?: boolean;
label: Label;
onSelect?: () => void;
}
export function LabelItem({ active, label, onSelect }: LabelItemProps): React.JSX.Element {
const Icon = getIcon(label);
const iconColor = getIconColor(label, active);
const showUnreadCount = (label.unreadCount ?? 0) > 0;
return (
<Box component="li" sx={{ userSelect: 'none' }}>
<Box
onClick={onSelect}
onKeyUp={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
onSelect?.();
}
}}
role="button"
sx={{
alignItems: 'center',
borderRadius: 1,
color: 'var(--mui-palette-text-secondary)',
cursor: 'pointer',
display: 'flex',
flex: '0 0 auto',
gap: 1,
p: '6px 16px',
textDecoration: 'none',
whiteSpace: 'nowrap',
...(active && { bgcolor: 'var(--mui-palette-action-selected)', color: 'var(--mui-palette-text-primary)' }),
'&:hover': {
...(!active && { bgcolor: 'var(--mui-palette-action-hover)', color: 'var(---mui-palette-text-primary)' }),
},
}}
tabIndex={0}
>
<Box sx={{ display: 'flex', flex: '0 0 auto' }}>
<Icon color={iconColor} fontSize="var(--icon-fontSize-md)" weight={active ? 'fill' : undefined} />
</Box>
<Box sx={{ flex: '1 1 auto' }}>
<Typography
component="span"
sx={{ color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px' }}
>
{label.name}
</Typography>
</Box>
{showUnreadCount ? (
<Typography color="inherit" variant="subtitle2">
{label.unreadCount}
</Typography>
) : null}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import * as React from 'react';
import type { Label, Thread } from './types';
function noop(): void {
return undefined;
}
export interface MailContextValue {
labels: Label[];
threads: Thread[];
currentLabelId: string;
openDesktopSidebar: boolean;
setOpenDesktopSidebar: React.Dispatch<React.SetStateAction<boolean>>;
openMobileSidebar: boolean;
setOpenMobileSidebar: React.Dispatch<React.SetStateAction<boolean>>;
openCompose: boolean;
setOpenCompose: React.Dispatch<React.SetStateAction<boolean>>;
}
export const MailContext = React.createContext<MailContextValue>({
labels: [],
threads: [],
currentLabelId: 'inbox',
openDesktopSidebar: true,
setOpenDesktopSidebar: noop,
openMobileSidebar: true,
setOpenMobileSidebar: noop,
openCompose: false,
setOpenCompose: noop,
});
export interface MailProviderProps {
children: React.ReactNode;
labels: Label[];
threads: Thread[];
currentLabelId: string;
}
export function MailProvider({
children,
labels: initialLabels = [],
threads: initialThreads = [],
currentLabelId,
}: MailProviderProps): React.JSX.Element {
const [labels, setLabels] = React.useState<Label[]>([]);
const [threads, setThreads] = React.useState<Thread[]>([]);
const [openDesktopSidebar, setOpenDesktopSidebar] = React.useState<boolean>(true);
const [openMobileSidebar, setOpenMobileSidebar] = React.useState<boolean>(false);
const [openCompose, setOpenCompose] = React.useState<boolean>(false);
React.useEffect((): void => {
setLabels(initialLabels);
}, [initialLabels]);
React.useEffect((): void => {
setThreads(initialThreads);
}, [initialThreads]);
return (
<MailContext.Provider
value={{
labels,
threads,
currentLabelId,
openDesktopSidebar,
setOpenDesktopSidebar,
openMobileSidebar,
setOpenMobileSidebar,
openCompose,
setOpenCompose,
}}
>
{children}
</MailContext.Provider>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import { Composer } from './composer';
import { MailContext } from './mail-context';
import { Sidebar } from './sidebar';
export interface MailViewProps {
children: React.ReactNode;
}
export function MailView({ children }: MailViewProps): React.JSX.Element {
const {
labels,
currentLabelId,
openDesktopSidebar,
openMobileSidebar,
setOpenMobileSidebar,
openCompose,
setOpenCompose,
} = React.useContext(MailContext);
return (
<React.Fragment>
<Box sx={{ display: 'flex', flex: '1 1 0', minHeight: 0 }}>
<Sidebar
currentLabelId={currentLabelId}
labels={labels}
onCloseMobile={() => {
setOpenMobileSidebar(false);
}}
onCompose={() => {
setOpenCompose(true);
}}
openDesktop={openDesktopSidebar}
openMobile={openMobileSidebar}
/>
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', overflow: 'hidden' }}>{children}</Box>
</Box>
<Composer
onClose={() => {
setOpenCompose(false);
}}
open={openCompose}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Markdown from 'react-markdown';
export interface MessageProps {
content: string;
}
export function Message({ content }: MessageProps): React.JSX.Element {
return (
<Box
sx={{
'& h2': { fontWeight: 500, fontSize: '1.5rem', lineHeight: 1.2, mb: 3 },
'& h3': { fontWeight: 500, fontSize: '1.25rem', lineHeight: 1.2, mb: 3 },
'& p': { fontWeight: 400, fontSize: '1rem', lineHeight: 1.5, mb: 2, mt: 0 },
}}
>
<Markdown>{content}</Markdown>
</Box>
);
}

View File

@@ -0,0 +1,54 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { Image as ImageIcon } from '@phosphor-icons/react/dist/ssr/Image';
import { Link as LinkIcon } from '@phosphor-icons/react/dist/ssr/Link';
import { Paperclip as PaperclipIcon } from '@phosphor-icons/react/dist/ssr/Paperclip';
import { Smiley as SmileyIcon } from '@phosphor-icons/react/dist/ssr/Smiley';
import type { User } from '@/types/user';
const user = {
id: 'USR-000',
name: 'Sofia Rivers',
avatar: '/assets/avatar.png',
email: 'sofia@devias.io',
} satisfies User;
export function Reply(): React.JSX.Element {
return (
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start', flex: '0 0 auto', p: 3 }}>
<Avatar src={user.avatar} />
<Stack spacing={2} sx={{ flex: '1 1 auto' }}>
<OutlinedInput maxRows={7} minRows={3} multiline placeholder="Leave a message" />
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Tooltip title="Attach image">
<IconButton>
<ImageIcon />
</IconButton>
</Tooltip>
<Tooltip title="Attach file">
<IconButton>
<PaperclipIcon />
</IconButton>
</Tooltip>
<IconButton>
<LinkIcon />
</IconButton>
<IconButton>
<SmileyIcon />
</IconButton>
</Stack>
<div>
<Button variant="contained">Reply</Button>
</div>
</Stack>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import ListSubheader from '@mui/material/ListSubheader';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import { paths } from '@/paths';
import { useMediaQuery } from '@/hooks/use-media-query';
import { LabelItem } from './label-item';
import type { Label, LabelType } from './types';
interface GroupedLabels {
system: Label[];
custom: Label[];
}
function groupLabels(labels: Label[]): GroupedLabels {
const groups: GroupedLabels = { system: [], custom: [] };
labels.forEach((label) => {
if (label.type === 'system') {
groups.system.push(label);
} else {
groups.custom.push(label);
}
});
return groups;
}
// A single sidebar component is used, because you might need to have internal state
// shared between both mobile and desktop
export interface SidebarProps {
currentLabelId?: string;
labels: Label[];
openDesktop?: boolean;
openMobile?: boolean;
onCloseMobile?: () => void;
onCompose?: () => void;
}
export function Sidebar({
currentLabelId,
labels,
onCloseMobile,
onCompose,
openDesktop = false,
openMobile = false,
}: SidebarProps): React.JSX.Element {
const mdUp = useMediaQuery('up', 'md');
const content = (
<SidebarContent
closeOnSelect={!mdUp}
currentLabelId={currentLabelId}
labels={labels}
onClose={onCloseMobile}
onCompose={onCompose}
/>
);
if (mdUp) {
return (
<Box
sx={{
borderRight: '1px solid var(--mui-palette-divider)',
flex: '0 0 auto',
ml: openDesktop ? 0 : '-320px',
position: 'relative',
transition: 'margin 225ms cubic-bezier(0.0, 0, 0.2, 1) 0ms',
width: '320px',
}}
>
{content}
</Box>
);
}
return (
<Drawer PaperProps={{ sx: { maxWidth: '100%', width: '320px' } }} onClose={onCloseMobile} open={openMobile}>
{content}
</Drawer>
);
}
export interface SidebarContentProps {
closeOnSelect?: boolean;
currentLabelId?: string;
labels: Label[];
open?: boolean;
onClose?: () => void;
onCompose?: () => void;
}
function SidebarContent({
closeOnSelect,
currentLabelId,
labels,
onClose,
onCompose,
}: SidebarContentProps): React.JSX.Element {
const router = useRouter();
const handleLabelSelect = React.useCallback(
(labelId: string) => {
if (closeOnSelect) {
onClose?.();
}
const href = paths.dashboard.mail.list(labelId);
router.push(href);
},
[router, closeOnSelect, onClose]
);
// Maybe use memo
const groupedLabels: GroupedLabels = groupLabels(labels);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Stack spacing={1} sx={{ flex: '0 0 auto', p: 2 }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h5">Mailbox</Typography>
<IconButton onClick={onClose} sx={{ display: { md: 'none' } }}>
<XIcon />
</IconButton>
</Stack>
<Button onClick={onCompose} startIcon={<PlusIcon />} variant="contained">
Compose
</Button>
</Stack>
<Divider />
<Box sx={{ flex: '1 1 auto', overflowY: 'auto', p: 2 }}>
{Object.keys(groupedLabels).map((type) => (
<React.Fragment key={type}>
{type === 'custom' ? (
<ListSubheader disableSticky>
<Typography color="text.secondary" variant="overline">
Custom Labels
</Typography>
</ListSubheader>
) : null}
<Stack component="ul" spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{groupedLabels[type as LabelType].map((label) => (
<LabelItem
active={currentLabelId === label.id}
key={label.id}
label={label}
onSelect={() => {
handleLabelSelect(label.id);
}}
/>
))}
</Stack>
</React.Fragment>
))}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Checkbox from '@mui/material/Checkbox';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { BookmarkSimple as BookmarkSimpleIcon } from '@phosphor-icons/react/dist/ssr/BookmarkSimple';
import { Paperclip as PaperclipIcon } from '@phosphor-icons/react/dist/ssr/Paperclip';
import { Star as StarIcon } from '@phosphor-icons/react/dist/ssr/Star';
import { dayjs } from '@/lib/dayjs';
import type { Thread } from './types';
export interface ThreadItemProps {
href: string;
onDeselect?: () => void;
onSelect?: () => void;
selected: boolean;
thread: Thread;
}
export function ThreadItem({ thread, onDeselect, onSelect, selected, href }: ThreadItemProps): React.JSX.Element {
const hasAnyAttachments = (thread.attachments ?? []).length > 0;
const hasManyAttachments = (thread.attachments ?? []).length > 1;
const handleSelectToggle = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
onSelect?.();
} else {
onDeselect?.();
}
},
[onSelect, onDeselect]
);
return (
<Box
sx={{
alignItems: 'center',
borderBottom: '1px solid var(--mui-palette-divider)',
display: 'flex',
gap: 1,
p: 2,
...(!thread.isUnread && {
position: 'relative',
'&::before': {
bgcolor: 'var(--mui-palette-primary-main)',
bottom: 0,
content: '" "',
left: 0,
position: 'absolute',
top: 0,
width: '4px',
},
}),
...(selected && { bgcolor: 'var(--mui-palette-primary-selected)' }),
'&:hover': { ...(!selected && { bgcolor: 'var(--mui-palette-action-hover)' }) },
}}
>
<Box sx={{ alignItems: 'center', display: { md: 'flex', xs: 'none' }, flex: '0 0 auto' }}>
<Checkbox checked={selected} onChange={handleSelectToggle} />
<Tooltip title="Starred">
<IconButton>
<StarIcon
color={thread.isStarred ? 'var(--mui-palette-warning-main)' : undefined}
weight={thread.isStarred ? 'fill' : undefined}
/>
</IconButton>
</Tooltip>
<Tooltip title="Important">
<IconButton>
<BookmarkSimpleIcon
color={thread.isImportant ? 'var(--mui-palette-warning-main)' : undefined}
weight={thread.isImportant ? 'fill' : undefined}
/>
</IconButton>
</Tooltip>
</Box>
<Box
component={RouterLink}
href={href}
sx={{
alignItems: 'center',
color: 'var(--mui-palette-text-primary)',
cursor: 'pointer',
display: 'flex',
flex: '1 1 auto',
flexWrap: { xs: 'wrap', md: 'nowrap' },
gap: 2,
minWidth: 0,
textDecoration: 'none',
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', display: 'flex' }}>
<Avatar src={thread.from.avatar} />
<Typography noWrap sx={{ width: '120px' }} variant={thread.isUnread ? 'body2' : 'subtitle2'}>
{thread.from.name}
</Typography>
</Stack>
<Stack spacing={1} sx={{ flex: '1 1 auto', overflow: 'hidden', width: { xs: '100%', md: 'auto' } }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', maxWidth: '800px', width: '100%' }}>
<Typography noWrap sx={{ minWidth: '100px', maxWidth: '400px' }} variant="subtitle2">
{thread.subject}
</Typography>
<Typography color="text.secondary" noWrap variant="body2">
{thread.message}
</Typography>
</Stack>
{hasAnyAttachments ? (
<Stack direction="row" spacing={1}>
<Chip icon={<PaperclipIcon />} label={thread.attachments![0].name} size="small" variant="soft" />
{hasManyAttachments ? <Chip label="+1" size="small" variant="soft" /> : null}
</Stack>
) : null}
</Stack>
<Typography
color="text.secondary"
sx={{ display: 'block', textAlign: { xs: 'left', md: 'right' }, whiteSpace: 'nowrap', width: '100px' }}
variant="caption"
>
{dayjs(thread.createdAt).format('DD MMM')}
</Typography>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import Link from '@mui/material/Link';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
import { CaretLeft as CaretLeftIcon } from '@phosphor-icons/react/dist/ssr/CaretLeft';
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
import { Chat as ChatIcon } from '@phosphor-icons/react/dist/ssr/Chat';
import { Chats as ChatsIcon } from '@phosphor-icons/react/dist/ssr/Chats';
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs';
import { Attachments } from './attachments';
import { MailContext } from './mail-context';
import { Message } from './message';
import { Reply } from './reply';
import type { Thread } from './types';
/**
* This method is used to get the thread from the context based on the thread ID.
* The thread should be loaded from the API in the page, but for the sake of simplicity we are just using the context.
*/
function useThread(threadId: string): Thread | undefined {
const { threads } = React.useContext(MailContext);
return threads.find((thread) => thread.id === threadId);
}
export interface ThreadViewProps {
threadId: string;
}
export function ThreadView({ threadId }: ThreadViewProps): React.JSX.Element {
const { currentLabelId } = React.useContext(MailContext);
const thread = useThread(threadId);
if (!thread) {
return (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '1 1 auto', justifyContent: 'center' }}>
<Typography color="textSecondary" variant="h6">
Thread not found
</Typography>
</Box>
);
}
const backHref = paths.dashboard.mail.list(currentLabelId);
const hasMessage = Boolean(thread.message);
const hasAttachments = (thread.attachments ?? []).length > 0;
return (
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0 }}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
borderBottom: '1px solid var(--mui-palette-divider)',
flex: '0 0 auto',
justifyContent: 'space-between',
p: 2,
}}
>
<div>
<IconButton component={RouterLink} href={backHref}>
<ArrowLeftIcon />
</IconButton>
</div>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<OutlinedInput
placeholder="Search message"
startAdornment={
<InputAdornment position="start">
<MagnifyingGlassIcon fontSize="var(--icon-fontSize-md)" />
</InputAdornment>
}
sx={{ width: '200px' }}
/>
<Tooltip title="Previous">
<IconButton>
<CaretLeftIcon />
</IconButton>
</Tooltip>
<Tooltip title="Next">
<IconButton>
<CaretRightIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', overflowY: 'auto', p: 3 }}>
<Box sx={{ flex: '1 1 auto' }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Avatar src={thread.from.avatar} sx={{ '--Avatar-size': '48px' }} />
<div>
<Typography component="span" variant="subtitle2">
{thread.from.name}
</Typography>{' '}
<Link color="text.secondary" component="span" variant="body2">
{thread.from.email}
</Link>
<Typography color="text.secondary" variant="subtitle2">
To:{' '}
{thread.to.map((person) => (
<Link color="inherit" key={person.email}>
{person.email}
</Link>
))}
</Typography>
{thread.createdAt ? (
<Typography color="text.secondary" noWrap variant="caption">
{dayjs(thread.createdAt).format('MMM D, YYYY hh:mm:ss A')}
</Typography>
) : null}
</div>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', display: { xs: 'none', md: 'flex' } }}>
<Tooltip title="Reply">
<IconButton>
<ChatIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reply all">
<IconButton>
<ChatsIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton>
<TrashIcon />
</IconButton>
</Tooltip>
</Stack>
<Box sx={{ display: { md: 'none' } }}>
<Tooltip title="More options">
<IconButton>
<DotsThreeIcon weight="bold" />
</IconButton>
</Tooltip>
</Box>
</Stack>
<Box sx={{ py: 3 }}>
<Typography variant="h4">{thread.subject}</Typography>
</Box>
<Stack spacing={3}>
{hasMessage ? <Message content={thread.message} /> : null}
{hasAttachments ? <Attachments attachments={thread.attachments} /> : null}
</Stack>
</Box>
<Reply />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Checkbox from '@mui/material/Checkbox';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { ArrowCounterClockwise as ArrowCounterClockwiseIcon } from '@phosphor-icons/react/dist/ssr/ArrowCounterClockwise';
import { CaretLeft as CaretLeftIcon } from '@phosphor-icons/react/dist/ssr/CaretLeft';
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { paths } from '@/paths';
import { useMediaQuery } from '@/hooks/use-media-query';
import { useSelection } from '@/hooks/use-selection';
import { MailContext } from './mail-context';
import { ThreadItem } from './thread-item';
export function ThreadsView(): React.JSX.Element {
const { currentLabelId, threads, setOpenDesktopSidebar, setOpenMobileSidebar } = React.useContext(MailContext);
const mdDown = useMediaQuery('down', 'md');
const threadIds = React.useMemo(() => threads.map((thread) => thread.id), [threads]);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useSelection(threadIds);
return (
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0 }}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
borderBottom: '1px solid var(--mui-palette-divider)',
flex: '0 0 auto',
justifyContent: 'space-between',
p: 2,
}}
>
<div>
<IconButton
onClick={() => {
if (mdDown) {
setOpenMobileSidebar((prev) => !prev);
} else {
setOpenDesktopSidebar((prev) => !prev);
}
}}
>
<ListIcon />
</IconButton>
</div>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flex: '0 0 auto' }}>
<OutlinedInput
placeholder="Search thread"
startAdornment={
<InputAdornment position="start">
<MagnifyingGlassIcon fontSize="var(--icon-fontSize-md)" />
</InputAdornment>
}
sx={{ width: '200px' }}
/>
<Tooltip title="Next page">
<IconButton>
<CaretLeftIcon />
</IconButton>
</Tooltip>
<Tooltip title="Previous page">
<IconButton>
<CaretRightIcon />
</IconButton>
</Tooltip>
<Tooltip title="Refresh">
<IconButton sx={{ display: { xs: 'none', md: 'inline-flex' } }}>
<ArrowCounterClockwiseIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
{threads.length ? (
<React.Fragment>
<Box
sx={{
alignItems: 'center',
borderBottom: '1px solid var(--mui-palette-divider)',
display: { xs: 'none', md: 'flex' },
p: 2,
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flex: '1 1 auto' }}>
<Checkbox
checked={selected.size === threads.length}
indeterminate={selected.size > 0 && selected.size < threads.length}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
selectAll();
} else {
deselectAll();
}
}}
/>
<Typography variant="subtitle2">Select all</Typography>
</Stack>
<Tooltip title="More options">
<IconButton>
<DotsThreeIcon weight="bold" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ overflowY: 'auto' }}>
{threads.map((thread) => (
<ThreadItem
href={paths.dashboard.mail.details(currentLabelId, thread.id)}
key={thread.id}
onDeselect={() => {
deselectOne(thread.id);
}}
onSelect={() => {
selectOne(thread.id);
}}
selected={selected.has(thread.id)}
thread={thread}
/>
))}
</Box>
</React.Fragment>
) : (
<Box
sx={{
alignItems: 'center',
display: 'flex',
flex: '1 1 auto',
flexDirection: 'column',
justifyContent: 'center',
overflowY: 'auto',
p: 3,
}}
>
<Stack spacing={2} sx={{ alignItems: 'center' }}>
<Box>
<Box
alt="No threads"
component="img"
src="/assets/not-found.svg"
sx={{ height: 'auto', maxWidth: '100%', width: '120px' }}
/>
</Box>
<Typography color="text.secondary" sx={{ textAlign: 'center' }} variant="h6">
There are no threads
</Typography>
</Stack>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,47 @@
export interface Attachment {
id: string;
type: 'file' | 'image';
name?: string;
size?: string;
url?: string;
}
export interface Sender {
name: string;
avatar?: string;
email: string;
}
export interface Receiver {
name: string;
avatar?: string;
email: string;
}
export interface Thread {
id: string;
from: Sender;
to: Receiver[];
subject: string;
message: string;
attachments?: Attachment[];
folder: Folder;
labels: string[];
isImportant: boolean;
isStarred: boolean;
isUnread: boolean;
createdAt: Date;
}
export type Folder = 'inbox' | 'sent' | 'drafts' | 'spam' | 'trash';
export type LabelType = 'system' | 'custom';
export interface Label {
id: string;
type: LabelType;
name: string;
color?: string;
unreadCount?: number;
totalCount?: number;
}

View File

@@ -0,0 +1,529 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import Checkbox from '@mui/material/Checkbox';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2';
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
import { Controller, useForm } from 'react-hook-form';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { Option } from '@/components/core/option';
import { toast } from '@/components/core/toaster';
import { createTeacher } from '@/db/Teachers/Create';
import isDevelopment from '@/lib/check-is-development';
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'));
};
});
}
const schema = zod.object({
avatar: zod.string().optional(),
name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
phone: zod.string().min(1, 'Phone is required').max(15),
company: zod.string().max(255),
billingAddress: zod.object({
country: zod.string().min(1, 'Country is required').max(255),
state: zod.string().min(1, 'State is required').max(255),
city: zod.string().min(1, 'City is required').max(255),
zipCode: zod.string().min(1, 'Zip code is required').max(255),
line1: zod.string().min(1, 'Street line 1 is required').max(255),
line2: zod.string().max(255).optional(),
}),
taxId: zod.string().max(255).optional(),
timezone: zod.string().min(1, 'Timezone is required').max(255),
language: zod.string().min(1, 'Language is required').max(255),
currency: zod.string().min(1, 'Currency is required').max(255),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
avatar: '',
name: 'new name',
email: '123@123.com',
phone: '91234567',
company: '',
billingAddress: {
country: 'US',
state: '00000',
city: 'NY',
zipCode: '00000',
line1: 'test line 1',
line2: 'test line 2',
},
taxId: '12345',
timezone: 'new_york',
language: 'en',
currency: 'USD',
} satisfies Values;
export function TeacherCreateForm(): React.JSX.Element {
const router = useRouter();
const {
control,
handleSubmit,
formState: { errors },
setValue,
watch,
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
try {
// Use standard create method from db/Customers/Create
const record = await createTeacher(values);
toast.success('Customer created');
router.push(paths.dashboard.teachers.details(record.id));
} catch (err) {
logger.error(err);
toast.error('Failed to create customer');
}
},
[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">Account information</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: '50%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
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">Avatar</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography>
<Button
color="secondary"
onClick={() => {
avatarInputRef.current?.click();
}}
variant="outlined"
>
Select
</Button>
<input
hidden
onChange={handleAvatarChange}
ref={avatarInputRef}
type="file"
/>
</Stack>
</Stack>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="name"
render={({ field }) => (
<FormControl
error={Boolean(errors.name)}
fullWidth
>
<InputLabel required>Name</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="email"
render={({ field }) => (
<FormControl
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email address</InputLabel>
<OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone number</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="company"
render={({ field }) => (
<FormControl
error={Boolean(errors.company)}
fullWidth
>
<InputLabel>Company</InputLabel>
<OutlinedInput {...field} />
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Billing information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.country"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.country)}
fullWidth
>
<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>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress?.country?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.state"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.state)}
fullWidth
>
<InputLabel required>State</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.state ? (
<FormHelperText>{errors.billingAddress?.state?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.city"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.city)}
fullWidth
>
<InputLabel required>City</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.city ? (
<FormHelperText>{errors.billingAddress?.city?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.zipCode"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip code</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress?.zipCode?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.line1"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress?.line1?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl
error={Boolean(errors.taxId)}
fullWidth
>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput
{...field}
placeholder="e.g EU372054390"
/>
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Shipping information</Typography>
<FormControlLabel
control={<Checkbox defaultChecked />}
label="Same as billing address"
/>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="timezone"
render={({ field }) => (
<FormControl
error={Boolean(errors.timezone)}
fullWidth
>
<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>
</Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="language"
render={({ field }) => (
<FormControl
error={Boolean(errors.language)}
fullWidth
>
<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>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="currency"
render={({ field }) => (
<FormControl
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel>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>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.teachers.list}
>
Cancel
</Button>
<Button
type="submit"
variant="contained"
>
Create teacher
</Button>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form>
);
}

View File

@@ -0,0 +1,604 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_TEACHERS, COL_USER_METAS } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2';
//
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
//
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import isDevelopment from '@/lib/check-is-development';
// TODO: review this
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),
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),
city: zod.string().min(1, 'City is required').max(255),
zipCode: zod.string().min(1, 'Zip code is required').max(255),
line1: zod.string().min(1, 'Street line 1 is required').max(255),
line2: zod.string().max(255).optional(),
}),
taxId: zod.string().max(255).optional(),
timezone: zod.string().min(1, 'Timezone is required').max(255),
language: zod.string().min(1, 'Language is required').max(255),
currency: zod.string().min(1, 'Currency is required').max(255),
avatar: zod.string().optional(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
name: '',
email: '',
phone: '',
company: '',
billingAddress: {
country: '',
state: '',
city: '',
zipCode: '',
line1: '',
line2: '',
},
taxId: '',
timezone: '',
language: '',
currency: '',
avatar: '',
} satisfies Values;
export function TeacherEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
const { id: teacherId } = 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 = {
name: values.name,
email: values.email,
phone: values.phone,
company: values.company,
billingAddress: values.billingAddress,
taxId: values.taxId,
timezone: values.timezone,
language: values.language,
currency: values.currency,
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
};
try {
await pb.collection(COL_USER_METAS).update(teacherId, updateData);
toast.success('Teacher updated successfully');
router.push(paths.dashboard.teachers.list);
} catch (error) {
logger.error(error);
toast.error('Failed to update teacher');
} finally {
setIsUpdating(false);
}
},
[teacherId, 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_USER_METAS).getOne(id);
reset({ ...defaultValues, ...result });
console.log({ result });
if (result.avatar) {
const fetchResult = await fetch(
`http://127.0.0.1:8090/api/files/${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');
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
}
},
[reset, setValue]
);
React.useEffect(() => {
void loadExistingData(teacherId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [teacherId]);
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
control={control}
name="name"
render={({ field }) => (
<FormControl
error={Boolean(errors.name)}
fullWidth
>
<InputLabel required>Name</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="email"
render={({ field }) => (
<FormControl
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email</InputLabel>
<OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="company"
render={({ field }) => (
<FormControl
error={Boolean(errors.company)}
fullWidth
>
<InputLabel>Company</InputLabel>
<OutlinedInput
{...field}
placeholder="no company name"
/>
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">Billing Information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.country"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.country)}
fullWidth
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress.country.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.state"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.state)}
fullWidth
>
<InputLabel required>State</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.state ? (
<FormHelperText>{errors.billingAddress.state.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.city"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.city)}
fullWidth
>
<InputLabel required>City</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.city ? (
<FormHelperText>{errors.billingAddress.city.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.zipCode"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip Code</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.line1"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address Line 1</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress.line1.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl
error={Boolean(errors.taxId)}
fullWidth
>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput
{...field}
placeholder="no tax id..."
/>
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional Information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="timezone"
render={({ field }) => (
<FormControl
error={Boolean(errors.timezone)}
fullWidth
>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<MenuItem value="America/New_York">New York</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>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="language"
render={({ field }) => (
<FormControl
error={Boolean(errors.language)}
fullWidth
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="currency"
render={({ field }) => (
<FormControl
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel required>Currency</InputLabel>
<Select {...field}>
<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>
)}
/>
</Grid>
</Grid>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.teachers.list}
>
{t('edit.cancelButton')}
</Button>
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit"
variant="contained"
>
{t('edit.updateButton')}
</LoadingButton>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form>
);
}

View File

@@ -0,0 +1,249 @@
'use client';
// RULES:
// T.B.A.
//
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { getAllTeachersCount } from '@/db/Teachers/GetAllCount';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
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 } from '@/components/core/filter-button';
import { Option } from '@/components/core/option';
import { useTeachersSelection } from './teachers-selection-context';
import GetBlockedCount from '@/db/Customers/GetBlockedCount';
import GetPendingCount from '@/db/Customers/GetPendingCount';
import GetActiveCount from '@/db/Customers/GetActiveCount';
import PhoneFilterPopover from './phone-filter-popover';
import EmailFilterPopover from './email-filter-popover';
import type { TeachersFiltersProps, Filters, SortDir } from './type.d';
import { logger } from '@/lib/default-logger';
export function TeachersFilters({
filters = {},
sortDir = 'desc',
fullData,
//
}: TeachersFiltersProps): React.JSX.Element {
const { t } = useTranslation();
const { email, phone, status } = filters;
const [totalCount, setTotalCount] = React.useState<number>(0);
const [activeCount, setActiveCount] = React.useState<number>(0);
const [pendingCount, setPendingCount] = React.useState<number>(0);
const [blockedCount, setBlockedCount] = React.useState<number>(0);
const router = useRouter();
const selection = useTeachersSelection();
// function getVisible(): number {
// return fullData.reduce((count, item: CrQuestion) => {
// return item.visible === 'visible' ? count + 1 : count;
// }, 0);
// }
// function getHidden(): number {
// return fullData.reduce((count, item: CrQuestion) => {
// return item.visible === 'hidden' ? count + 1 : count;
// }, 0);
// }
// The tabs should be generated using API data.
const tabs = [
{ label: 'All', value: '', count: totalCount },
{ label: 'Active', value: 'active', count: activeCount },
{ label: 'Pending', value: 'pending', count: pendingCount },
{ label: 'Blocked', value: 'blocked', count: blockedCount },
] 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);
}
router.push(`${paths.dashboard.teachers.list}?${searchParams.toString()}`);
},
[router]
);
const handleClearFilters = React.useCallback(() => {
updateSearchParams({}, sortDir);
}, [updateSearchParams, sortDir]);
const handleStatusChange = React.useCallback(
(_: React.SyntheticEvent, value: string) => {
updateSearchParams({ ...filters, status: value }, sortDir);
},
[updateSearchParams, filters, sortDir]
);
const handleEmailChange = React.useCallback(
(value?: string) => {
updateSearchParams({ ...filters, email: value }, sortDir);
},
[updateSearchParams, filters, sortDir]
);
const handlePhoneChange = React.useCallback(
(value?: string) => {
updateSearchParams({ ...filters, phone: value }, sortDir);
},
[updateSearchParams, filters, sortDir]
);
const handleSortChange = React.useCallback(
(event: SelectChangeEvent) => {
updateSearchParams(filters, event.target.value as SortDir);
},
[updateSearchParams, filters]
);
const hasFilters = status || email || phone;
React.useEffect(() => {
const fetchCount = async (): Promise<void> => {
try {
const tc = await getAllTeachersCount();
setTotalCount(tc);
const bc = await GetBlockedCount();
setBlockedCount(bc);
const pc = await GetPendingCount();
setPendingCount(pc);
const ac = await GetActiveCount();
setActiveCount(ac);
} catch (error) {
//
logger.error(error);
}
};
void fetchCount();
}, []);
return (
<div>
<Tabs
onChange={handleStatusChange}
sx={{ px: 3 }}
value={status ?? ''}
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={email}
label="Email"
onFilterApply={(value) => {
handleEmailChange(value as string);
}}
onFilterDelete={() => {
handleEmailChange();
}}
popover={<EmailFilterPopover />}
value={email}
/>
<FilterButton
displayValue={phone}
label="Phone number"
onFilterApply={(value) => {
handlePhoneChange(value as string);
}}
onFilterDelete={() => {
handlePhoneChange();
}}
popover={<PhoneFilterPopover />}
value={phone}
/>
{hasFilters ? <Button onClick={handleClearFilters}>Clear filters</Button> : null}
</Stack>
{selection.selectedAny ? (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center' }}
>
<Typography
color="text.secondary"
variant="body2"
>
{selection.selected.size} selected
</Typography>
<Button
color="error"
variant="contained"
>
Delete
</Button>
</Stack>
) : null}
<Select
name="sort"
onChange={handleSortChange}
sx={{ maxWidth: '100%', width: '120px' }}
value={sortDir}
>
<Option value="desc">Newest</Option>
<Option value="asc">Oldest</Option>
</Select>
</Stack>
</div>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import TablePagination from '@mui/material/TablePagination';
function noop(): void {
return undefined;
}
interface CustomersPaginationProps {
count: number;
page: number;
//
setPage: (page: number) => void;
setRowsPerPage: (page: number) => void;
rowsPerPage: number;
}
export function TeachersPagination({
count,
page,
//
setPage,
setRowsPerPage,
rowsPerPage,
}: CustomersPaginationProps): React.JSX.Element {
// You should implement the pagination using a similar logic as the filters.
// Note that when page change, you should keep the filter search params.
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,43 @@
'use client';
import * as React from 'react';
import { useSelection } from '@/hooks/use-selection';
import type { Selection } from '@/hooks/use-selection';
import type { Teacher } from './type.d';
function noop(): void {
return undefined;
}
export interface CustomersSelectionContextValue extends Selection {}
export const CustomersSelectionContext = React.createContext<CustomersSelectionContextValue>({
deselectAll: noop,
deselectOne: noop,
selectAll: noop,
selectOne: noop,
selected: new Set(),
selectedAny: false,
selectedAll: false,
});
interface CustomersSelectionProviderProps {
children: React.ReactNode;
teachers: Teacher[];
}
export function TeachersSelectionProvider({
children,
teachers = [],
}: CustomersSelectionProviderProps): React.JSX.Element {
const customerIds = React.useMemo(() => teachers.map((customer) => customer.id), [teachers]);
const selection = useSelection(customerIds);
return <CustomersSelectionContext.Provider value={{ ...selection }}>{children}</CustomersSelectionContext.Provider>;
}
export function useTeachersSelection(): CustomersSelectionContextValue {
return React.useContext(CustomersSelectionContext);
}

View File

@@ -0,0 +1,223 @@
'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 Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock';
import { 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 { useTeachersSelection } from './teachers-selection-context';
import type { Teacher } from './type.d';
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Teacher>[] {
return [
{
formatter: (row): React.JSX.Element => (
<Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center' }}
>
<Avatar
src={`http://127.0.0.1:8090/api/files/${row.collectionId}/${row.id}/${row.avatar}`}
variant="rounded"
/>
<div>
<Link
color="inherit"
component={RouterLink}
href={paths.dashboard.teachers.details(row.id)}
sx={{ whiteSpace: 'nowrap' }}
variant="subtitle2"
>
{row.name}
</Link>
<Typography
color="text.secondary"
variant="body2"
>
{row.email}
</Typography>
</div>
</Stack>
),
name: 'Name',
width: '250px',
},
{
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>
),
name: 'Quota',
width: '150px',
},
{ field: 'phone', name: 'Phone number', width: '150px' },
{
formatter: (row): React.JSX.Element => {
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"
/>
),
},
} as const;
const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null };
return (
<Chip
icon={icon}
label={label}
size="small"
variant="outlined"
/>
);
},
name: 'Status',
width: '150px',
},
{
formatter(row) {
return dayjs(row.createdAt).format('MMM D, YYYY');
},
name: 'Created at',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<Stack
direction="row"
spacing={1}
>
<LoadingButton
//
color="secondary"
component={RouterLink}
href={paths.dashboard.teachers.view(row.id)}
>
<PencilSimpleIcon size={24} />
</LoadingButton>
<LoadingButton
color="error"
// TODO: originally it is row.isEmpty
disabled={false}
onClick={() => {
handleDeleteClick(row.id);
}}
>
<TrashSimpleIcon size={24} />
</LoadingButton>
</Stack>
),
name: 'Actions',
hideName: true,
align: 'right',
},
];
}
export interface TeachersTableProps {
rows: Teacher[];
reloadRows: () => void;
}
export function TeachersTable({ rows, reloadRows }: TeachersTableProps): React.JSX.Element {
const { t } = useTranslation(['teachers']);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useTeachersSelection();
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<Teacher>
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"
>
{/* TODO: update this */}
{t('no-teachers-found')}
</Typography>
</Box>
) : null}
</React.Fragment>
);
}

View File

@@ -1,19 +1,31 @@
'use client';
// RULES: sorting direction for teacher lists
export type SortDir = 'asc' | 'desc';
export interface User {
id: string;
// RULES: core teacher data structure
export interface Teacher {
name: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone?: string;
quota: number;
// status is obsoleted, replace by state
status: 'pending' | 'active' | 'blocked';
state: 'pending' | 'active' | 'blocked';
//
id: string;
createdAt: Date;
updatedAt?: Date;
collectionId: string;
}
// RULES: form data structure for creating new teacher
export interface CreateFormProps {
name: string;
email: string;
@@ -36,6 +48,7 @@ export interface CreateFormProps {
// status?: 'pending' | 'active' | 'blocked';
}
// RULES: form data structure for editing existing teacher
export interface EditFormProps {
name: string;
email: string;
@@ -57,10 +70,12 @@ export interface EditFormProps {
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
}
export interface CustomersFiltersProps {
// RULES: filter props for teacher search and filtering
export interface TeachersFiltersProps {
filters?: Filters;
sortDir?: SortDir;
fullData: User[];
fullData: Teacher[];
}
export interface Filters {
email?: string;