diff --git a/002_source/cms/src/app/dashboard/users/SampleCustomers.tsx b/002_source/cms/src/app/dashboard/users/SampleTeachers.tsx similarity index 99% rename from 002_source/cms/src/app/dashboard/users/SampleCustomers.tsx rename to 002_source/cms/src/app/dashboard/users/SampleTeachers.tsx index b991279..ba471dc 100644 --- a/002_source/cms/src/app/dashboard/users/SampleCustomers.tsx +++ b/002_source/cms/src/app/dashboard/users/SampleTeachers.tsx @@ -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', diff --git a/002_source/cms/src/app/dashboard/users/_GUIDELINES.md b/002_source/cms/src/app/dashboard/users/_GUIDELINES.md index 1353901..039c9eb 100644 --- a/002_source/cms/src/app/dashboard/users/_GUIDELINES.md +++ b/002_source/cms/src/app/dashboard/users/_GUIDELINES.md @@ -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 `/002_source/002_source/cms/src` @@ -13,17 +13,17 @@ the `@` sign refer to `/002_source/002_source/cms/src` ## Assumption and Requirements - let one file contains one component only. -- type information defined in `/002_source/cms/src/db/Customers/type.d.tsx` -- it mainly consume the db drivers `Customres` in `/002_source/cms/src/db/Customers` +- type information defined in `/002_source/cms/src/db/UserMetas/type.d.tsx` +- it mainly consume the db drivers `UserMetas` in `/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 }; } ``` diff --git a/002_source/cms/src/app/dashboard/users/create/page.tsx b/002_source/cms/src/app/dashboard/users/create/page.tsx index d9a595d..294fe48 100644 --- a/002_source/cms/src/app/dashboard/users/create/page.tsx +++ b/002_source/cms/src/app/dashboard/users/create/page.tsx @@ -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 { - Users + Teachers
- Create user + Create teacher
- + ); diff --git a/002_source/cms/src/app/dashboard/users/edit/[id]/_PROMPT.md b/002_source/cms/src/app/dashboard/users/edit/[id]/_PROMPT.md deleted file mode 100644 index abf4465..0000000 --- a/002_source/cms/src/app/dashboard/users/edit/[id]/_PROMPT.md +++ /dev/null @@ -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, diff --git a/002_source/cms/src/app/dashboard/users/edit/[id]/page.tsx b/002_source/cms/src/app/dashboard/users/edit/[id]/page.tsx index da46923..f7bef10 100644 --- a/002_source/cms/src/app/dashboard/users/edit/[id]/page.tsx +++ b/002_source/cms/src/app/dashboard/users/edit/[id]/page.tsx @@ -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 { @@ -47,7 +47,7 @@ export default function Page(): React.JSX.Element { {t('edit.title')} - + ); diff --git a/002_source/cms/src/app/dashboard/users/list/_PROMPT.md b/002_source/cms/src/app/dashboard/users/list/_PROMPT.md new file mode 100644 index 0000000..3e3b1e2 --- /dev/null +++ b/002_source/cms/src/app/dashboard/users/list/_PROMPT.md @@ -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 ? diff --git a/002_source/cms/src/app/dashboard/users/list/page.tsx b/002_source/cms/src/app/dashboard/users/list/page.tsx index c507416..c16289d 100644 --- a/002_source/cms/src/app/dashboard/users/list/page.tsx +++ b/002_source/cms/src/app/dashboard/users/list/page.tsx @@ -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([]); - // + const [teacherData, setTeacherData] = React.useState([]); const [isLoadingAddPage, setIsLoadingAddPage] = React.useState(false); const [showLoading, setShowLoading] = React.useState(true); @@ -47,27 +45,37 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element { // const [rowsPerPage, setRowsPerPage] = React.useState(5); // - const [f, setF] = React.useState([]); + const [f, setF] = React.useState([]); const [currentPage, setCurrentPage] = React.useState(0); // const [recordCount, setRecordCount] = React.useState(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 => { try { - const models: ListResult = 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 = 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={} variant="contained" @@ -166,22 +175,22 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element { - + - - - - +
{JSON.stringify(f, null, 2)}
diff --git a/002_source/cms/src/app/dashboard/users/list/page.tsx.notworking b/002_source/cms/src/app/dashboard/users/list/page.tsx.notworking new file mode 100644 index 0000000..33d7590 --- /dev/null +++ b/002_source/cms/src/app/dashboard/users/list/page.tsx.notworking @@ -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([]); + + const [isLoadingAddPage, setIsLoadingAddPage] = React.useState(false); + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + // + const [rowsPerPage, setRowsPerPage] = React.useState(5); + // + const [f, setF] = React.useState([]); + const [currentPage, setCurrentPage] = React.useState(0); + // + const [recordCount, setRecordCount] = React.useState(0); + const [listOption, setListOption] = React.useState({ filter: '' }); + const [listSort, setListSort] = React.useState({}); + + function isListOptionChanged() { + return JSON.stringify(listOption) === '{}'; + } + // + const reloadRows = async (): Promise => { + try { + const listOptionTeacherOnly = isListOptionChanged() + ? { filter: `role = "teacher"` } + : { filter: [listOption.filter, `role = "teacher"`].join(' && ') }; + + const models: ListResult = 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 ; + + if (showError.show) + return ( + + ); + + return ( + + + + + {t('list.title')} + + + { + setIsLoadingAddPage(true); + router.push(paths.dashboard.teachers.create); + }} + startIcon={} + variant="contained" + > + {t('list.add')} + + + + + + + + + + + + + + + + +
{JSON.stringify(f, null, 2)}
+
+
+ ); +} + +interface PageProps { + searchParams: { + email?: string; + phone?: string; + sortDir?: 'asc' | 'desc'; + status?: string; + // + }; +} diff --git a/002_source/cms/src/app/dashboard/users/mail/[labelId]/SampleThreads.tsx b/002_source/cms/src/app/dashboard/users/mail/[labelId]/SampleThreads.tsx new file mode 100644 index 0000000..06e870f --- /dev/null +++ b/002_source/cms/src/app/dashboard/users/mail/[labelId]/SampleThreads.tsx @@ -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[]; diff --git a/002_source/cms/src/app/dashboard/users/mail/[labelId]/[threadId]/page.tsx b/002_source/cms/src/app/dashboard/users/mail/[labelId]/[threadId]/page.tsx index cf93176..231c33f 100644 --- a/002_source/cms/src/app/dashboard/users/mail/[labelId]/[threadId]/page.tsx +++ b/002_source/cms/src/app/dashboard/users/mail/[labelId]/[threadId]/page.tsx @@ -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; diff --git a/002_source/cms/src/app/dashboard/users/mail/[labelId]/layout.tsx b/002_source/cms/src/app/dashboard/users/mail/[labelId]/layout.tsx index cdde2e8..78ac428 100644 --- a/002_source/cms/src/app/dashboard/users/mail/[labelId]/layout.tsx +++ b/002_source/cms/src/app/dashboard/users/mail/[labelId]/layout.tsx @@ -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 ( - + {children} ); diff --git a/002_source/cms/src/app/dashboard/users/mail/[labelId]/list/page.tsx b/002_source/cms/src/app/dashboard/users/mail/[labelId]/list/page.tsx new file mode 100644 index 0000000..9c7884b --- /dev/null +++ b/002_source/cms/src/app/dashboard/users/mail/[labelId]/list/page.tsx @@ -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 ; +} diff --git a/002_source/cms/src/app/dashboard/users/mail/[labelId]/page.tsx b/002_source/cms/src/app/dashboard/users/mail/[labelId]/page.tsx index 9989b26..9c7884b 100644 --- a/002_source/cms/src/app/dashboard/users/mail/[labelId]/page.tsx +++ b/002_source/cms/src/app/dashboard/users/mail/[labelId]/page.tsx @@ -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; diff --git a/002_source/cms/src/app/dashboard/users/view/[id]/BasicDetailCard.tsx b/002_source/cms/src/app/dashboard/users/view/[id]/BasicDetailCard.tsx index 1d3ed59..fdb0dac 100644 --- a/002_source/cms/src/app/dashboard/users/view/[id]/BasicDetailCard.tsx +++ b/002_source/cms/src/app/dashboard/users/view/[id]/BasicDetailCard.tsx @@ -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(); diff --git a/002_source/cms/src/app/dashboard/users/view/[id]/TitleCard.tsx b/002_source/cms/src/app/dashboard/users/view/[id]/TitleCard.tsx index 5f59495..066caab 100644 --- a/002_source/cms/src/app/dashboard/users/view/[id]/TitleCard.tsx +++ b/002_source/cms/src/app/dashboard/users/view/[id]/TitleCard.tsx @@ -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 > {t('empty')} @@ -42,7 +40,7 @@ export default function SampleTitleCard({ user: lpModel }: { user: Student }): R spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }} > - {lpModel.email} + {teacherRecord.name || teacherRecord.email} } - label={lpModel.quota} + label={teacherRecord.status} size="small" variant="outlined" /> - - {lpModel.status} -
diff --git a/002_source/cms/src/app/dashboard/users/view/[id]/_PROMPT.md b/002_source/cms/src/app/dashboard/users/view/[id]/_PROMPT.md new file mode 100644 index 0000000..d148073 --- /dev/null +++ b/002_source/cms/src/app/dashboard/users/view/[id]/_PROMPT.md @@ -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 ? diff --git a/002_source/cms/src/app/dashboard/users/view/[id]/page.tsx b/002_source/cms/src/app/dashboard/users/view/[id]/page.tsx index 669ca23..9f4b729 100644 --- a/002_source/cms/src/app/dashboard/users/view/[id]/page.tsx +++ b/002_source/cms/src/app/dashboard/users/view/[id]/page.tsx @@ -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(true); const [showError, setShowError] = React.useState({ show: false, detail: '' }); // - const [showUser, setShowUser] = React.useState(defaultUser); + const [showLessonCategory, setShowLessonCategory] = React.useState(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 { - Users + Teachers
- + diff --git a/002_source/cms/src/app/dashboard/users/xxx/page.tsx b/002_source/cms/src/app/dashboard/users/view/[id]/page.tsx.notworking similarity index 80% rename from 002_source/cms/src/app/dashboard/users/xxx/page.tsx rename to 002_source/cms/src/app/dashboard/users/view/[id]/page.tsx.notworking index 5362910..448a8d3 100644 --- a/002_source/cms/src/app/dashboard/users/xxx/page.tsx +++ b/002_source/cms/src/app/dashboard/users/view/[id]/page.tsx.notworking @@ -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(true); const [showError, setShowError] = React.useState({ show: false, detail: '' }); // - const [showLessonCategory, setShowLessonCategory] = React.useState(defaultStudent); + const [teacherData, setTeacherData] = React.useState(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 { - Students + {t('back-to-list')} - + diff --git a/002_source/cms/src/app/dashboard/users/xxx/BasicDetailCard.tsx b/002_source/cms/src/app/dashboard/users/xxx/BasicDetailCard.tsx deleted file mode 100644 index e64427e..0000000 --- a/002_source/cms/src/app/dashboard/users/xxx/BasicDetailCard.tsx +++ /dev/null @@ -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 ( - - { - handleEditClick(); - }} - > - - - } - avatar={ - - - - } - title={t('list.basic-details')} - /> - } - orientation="vertical" - sx={{ '--PropertyItem-padding': '12px 24px' }} - > - {( - [ - { - key: 'Customer ID', - value: ( - - ), - }, - { 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 => ( - - ) - )} - - - ); -} diff --git a/002_source/cms/src/app/dashboard/users/xxx/TitleCard.tsx b/002_source/cms/src/app/dashboard/users/xxx/TitleCard.tsx deleted file mode 100644 index 9be2807..0000000 --- a/002_source/cms/src/app/dashboard/users/xxx/TitleCard.tsx +++ /dev/null @@ -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 ( - <> - - - {t('empty')} - -
- - {lpModel.email} - - } - label={lpModel.quota} - size="small" - variant="outlined" - /> - - - {lpModel.status} - -
-
-
- -
- - ); -} diff --git a/002_source/cms/src/components/dashboard/user.de/_GUIDELINES.md b/002_source/cms/src/components/dashboard/user.de/_GUIDELINES.md new file mode 100644 index 0000000..9e5a150 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/_GUIDELINES.md @@ -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 diff --git a/002_source/cms/src/components/dashboard/user.de/_constants.ts b/002_source/cms/src/components/dashboard/user.de/_constants.ts new file mode 100644 index 0000000..62097e2 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/_constants.ts @@ -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, +}; diff --git a/002_source/cms/src/components/dashboard/user.de/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/user.de/confirm-delete-modal.tsx new file mode 100644 index 0000000..3a4f946 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/confirm-delete-modal.tsx @@ -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 + 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 ( +
+ + + + + + + + + + + {t('Delete User ?')} + + {t('Are you sure you want to delete this user ?')} + + + + + { + handleUserConfirmDelete(); + }} + loading={isDeleteing} + > + {t('Delete')} + + + + + + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user.de/email-filter-popover.tsx b/002_source/cms/src/components/dashboard/user.de/email-filter-popover.tsx new file mode 100644 index 0000000..2636af0 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/email-filter-popover.tsx @@ -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(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/user.de/helloworld.tsx b/002_source/cms/src/components/dashboard/user.de/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/user.de/notifications.tsx b/002_source/cms/src/components/dashboard/user.de/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/notifications.tsx @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple'; + +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; +import { Option } from '@/components/core/option'; + +export interface Notification { + id: string; + type: string; + status: 'delivered' | 'pending' | 'failed'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {row.type} + + ), + name: 'Type', + width: '300px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + delivered: { label: 'Delivered', color: 'success' }, + pending: { label: 'Pending', color: 'warning' }, + failed: { label: 'Failed', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface NotificationsProps { + notifications: Notification[]; +} + +export function Notifications({ notifications }: NotificationsProps): React.JSX.Element { + return ( + + + +
+ } + title="Notifications" + /> + + + + +
+ +
+
+ + + columns={columns} rows={notifications} /> + + +
+
+ + ); +} diff --git a/002_source/cms/src/components/dashboard/user.de/payments.tsx b/002_source/cms/src/components/dashboard/user.de/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/payments.tsx @@ -0,0 +1,138 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import { ShoppingCartSimple as ShoppingCartSimpleIcon } from '@phosphor-icons/react/dist/ssr/ShoppingCartSimple'; + +import { dayjs } from '@/lib/dayjs'; +import type { ColumnDef } from '@/components/core/data-table'; +import { DataTable } from '@/components/core/data-table'; + +export interface Payment { + currency: string; + amount: number; + invoiceId: string; + status: 'pending' | 'completed' | 'canceled' | 'refunded'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)} + + ), + name: 'Amount', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + pending: { label: 'Pending', color: 'warning' }, + completed: { label: 'Completed', color: 'success' }, + canceled: { label: 'Canceled', color: 'error' }, + refunded: { label: 'Refunded', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + return {row.invoiceId}; + }, + name: 'Invoice ID', + width: '150px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface PaymentsProps { + ordersValue: number; + payments: Payment[]; + refundsValue: number; + totalOrders: number; +} + +export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element { + return ( + + }> + Create Payment + + } + avatar={ + + + + } + title="Payments" + /> + + + + } + spacing={3} + sx={{ justifyContent: 'space-between', p: 2 }} + > +
+ + Total orders + + {new Intl.NumberFormat('en-US').format(totalOrders)} +
+
+ + Orders value + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)} + +
+
+ + Refunds + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)} + +
+
+
+ + + columns={columns} rows={payments} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user.de/phone-filter-popover.tsx b/002_source/cms/src/components/dashboard/user.de/phone-filter-popover.tsx new file mode 100644 index 0000000..08cbad7 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/phone-filter-popover.tsx @@ -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(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/user.de/shipping-address.tsx b/002_source/cms/src/components/dashboard/user.de/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/shipping-address.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; + +export interface Address { + id: string; + country: string; + state: string; + city: string; + zipCode: string; + street: string; + primary?: boolean; +} + +export interface ShippingAddressProps { + address: Address; +} + +export function ShippingAddress({ address }: ShippingAddressProps): React.ReactElement { + return ( + + + + + {address.street}, +
+ {address.city}, {address.state}, {address.country}, +
+ {address.zipCode} +
+ + {address.primary ? : } + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user.de/type.d.tsx b/002_source/cms/src/components/dashboard/user.de/type.d.tsx new file mode 100644 index 0000000..847764a --- /dev/null +++ b/002_source/cms/src/components/dashboard/user.de/type.d.tsx @@ -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; +} diff --git a/002_source/cms/src/components/dashboard/user/user-create-form.tsx b/002_source/cms/src/components/dashboard/user.de/user-create-form.tsx similarity index 100% rename from 002_source/cms/src/components/dashboard/user/user-create-form.tsx rename to 002_source/cms/src/components/dashboard/user.de/user-create-form.tsx diff --git a/002_source/cms/src/components/dashboard/user/user-edit-form.tsx b/002_source/cms/src/components/dashboard/user.de/user-edit-form.tsx similarity index 100% rename from 002_source/cms/src/components/dashboard/user/user-edit-form.tsx rename to 002_source/cms/src/components/dashboard/user.de/user-edit-form.tsx diff --git a/002_source/cms/src/components/dashboard/user/users-filters.tsx b/002_source/cms/src/components/dashboard/user.de/users-filters.tsx similarity index 100% rename from 002_source/cms/src/components/dashboard/user/users-filters.tsx rename to 002_source/cms/src/components/dashboard/user.de/users-filters.tsx diff --git a/002_source/cms/src/components/dashboard/user/users-pagination.tsx b/002_source/cms/src/components/dashboard/user.de/users-pagination.tsx similarity index 100% rename from 002_source/cms/src/components/dashboard/user/users-pagination.tsx rename to 002_source/cms/src/components/dashboard/user.de/users-pagination.tsx diff --git a/002_source/cms/src/components/dashboard/user/users-selection-context.tsx b/002_source/cms/src/components/dashboard/user.de/users-selection-context.tsx similarity index 100% rename from 002_source/cms/src/components/dashboard/user/users-selection-context.tsx rename to 002_source/cms/src/components/dashboard/user.de/users-selection-context.tsx diff --git a/002_source/cms/src/components/dashboard/user/users-table.tsx b/002_source/cms/src/components/dashboard/user.de/users-table.tsx similarity index 100% rename from 002_source/cms/src/components/dashboard/user/users-table.tsx rename to 002_source/cms/src/components/dashboard/user.de/users-table.tsx diff --git a/002_source/cms/src/components/dashboard/user/_GUIDELINES.md b/002_source/cms/src/components/dashboard/user/_GUIDELINES.md index 9e5a150..6302b64 100644 --- a/002_source/cms/src/components/dashboard/user/_GUIDELINES.md +++ b/002_source/cms/src/components/dashboard/user/_GUIDELINES.md @@ -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 diff --git a/002_source/cms/src/components/dashboard/user/_constants.ts b/002_source/cms/src/components/dashboard/user/_constants.ts index 62097e2..0aa0baf 100644 --- a/002_source/cms/src/components/dashboard/user/_constants.ts +++ b/002_source/cms/src/components/dashboard/user/_constants.ts @@ -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, }; diff --git a/002_source/cms/src/components/dashboard/user/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/user/confirm-delete-modal.tsx index 3a4f946..afbb10e 100644 --- a/002_source/cms/src/components/dashboard/user/confirm-delete-modal.tsx +++ b/002_source/cms/src/components/dashboard/user/confirm-delete-modal.tsx @@ -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 - deleteUser(idToDelete) + deleteTeacher(idToDelete) .then(() => { reloadRows(); handleClose(); @@ -83,12 +83,12 @@ export default function ConfirmDeleteModal({ - {t('Delete User ?')} + {t('Delete Teacher ?')} - {t('Are you sure you want to delete this user ?')} + {t('Are you sure you want to delete this teacher ?')} + {count} attachments + + {attachments.map((attachment) => ( + + {attachment.type === 'image' ? : null} + {attachment.type === 'file' ? ( + + + + ) : null} +
+ + {attachment.name} + + + {attachment.size} + +
+
+ ))} +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/composer.tsx b/002_source/cms/src/components/dashboard/user/mail/composer.tsx new file mode 100644 index 0000000..c910475 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/composer.tsx @@ -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(false); + const [message, setMessage] = React.useState(''); + const [subject, setSubject] = React.useState(''); + const [to, setTo] = React.useState(''); + + const handleSubjectChange = React.useCallback((event: React.ChangeEvent) => { + setSubject(event.target.value); + }, []); + + const handleMessageChange = React.useCallback(({ editor }: EditorEvents['update']) => { + setMessage(editor.getText()); + }, []); + + const handleToChange = React.useCallback((event: React.ChangeEvent) => { + setTo(event.target.value); + }, []); + + if (!open) { + return null; + } + + return ( + + + + New message + + {isMaximized ? ( + { + setIsMaximized(false); + }} + > + + + ) : ( + { + setIsMaximized(true); + }} + > + + + )} + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/label-item.tsx b/002_source/cms/src/components/dashboard/user/mail/label-item.tsx new file mode 100644 index 0000000..eaec5a8 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/label-item.tsx @@ -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 = { + 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 ( + + { + 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} + > + + + + + + {label.name} + + + {showUnreadCount ? ( + + {label.unreadCount} + + ) : null} + + + ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/mail-context.tsx b/002_source/cms/src/components/dashboard/user/mail/mail-context.tsx new file mode 100644 index 0000000..d5c6e0b --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/mail-context.tsx @@ -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>; + openMobileSidebar: boolean; + setOpenMobileSidebar: React.Dispatch>; + openCompose: boolean; + setOpenCompose: React.Dispatch>; +} + +export const MailContext = React.createContext({ + 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([]); + const [threads, setThreads] = React.useState([]); + const [openDesktopSidebar, setOpenDesktopSidebar] = React.useState(true); + const [openMobileSidebar, setOpenMobileSidebar] = React.useState(false); + const [openCompose, setOpenCompose] = React.useState(false); + + React.useEffect((): void => { + setLabels(initialLabels); + }, [initialLabels]); + + React.useEffect((): void => { + setThreads(initialThreads); + }, [initialThreads]); + + return ( + + {children} + + ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/mail-view.tsx b/002_source/cms/src/components/dashboard/user/mail/mail-view.tsx new file mode 100644 index 0000000..02e9a78 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/mail-view.tsx @@ -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 ( + + + { + setOpenMobileSidebar(false); + }} + onCompose={() => { + setOpenCompose(true); + }} + openDesktop={openDesktopSidebar} + openMobile={openMobileSidebar} + /> + {children} + + { + setOpenCompose(false); + }} + open={openCompose} + /> + + ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/message.tsx b/002_source/cms/src/components/dashboard/user/mail/message.tsx new file mode 100644 index 0000000..f0edd50 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/message.tsx @@ -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 ( + + {content} + + ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/reply.tsx b/002_source/cms/src/components/dashboard/user/mail/reply.tsx new file mode 100644 index 0000000..b4d41d6 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/reply.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/sidebar.tsx b/002_source/cms/src/components/dashboard/user/mail/sidebar.tsx new file mode 100644 index 0000000..4483854 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/sidebar.tsx @@ -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 = ( + + ); + + if (mdUp) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} + +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 ( + + + + Mailbox + + + + + + + + + {Object.keys(groupedLabels).map((type) => ( + + {type === 'custom' ? ( + + + Custom Labels + + + ) : null} + + {groupedLabels[type as LabelType].map((label) => ( + { + handleLabelSelect(label.id); + }} + /> + ))} + + + ))} + + + ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/thread-item.tsx b/002_source/cms/src/components/dashboard/user/mail/thread-item.tsx new file mode 100644 index 0000000..e049667 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/thread-item.tsx @@ -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) => { + if (event.target.checked) { + onSelect?.(); + } else { + onDeselect?.(); + } + }, + [onSelect, onDeselect] + ); + + return ( + + + + + + + + + + + + + + + + + + + {thread.from.name} + + + + + + {thread.subject} + + + —{thread.message} + + + {hasAnyAttachments ? ( + + } label={thread.attachments![0].name} size="small" variant="soft" /> + {hasManyAttachments ? : null} + + ) : null} + + + {dayjs(thread.createdAt).format('DD MMM')} + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/thread-view.tsx b/002_source/cms/src/components/dashboard/user/mail/thread-view.tsx new file mode 100644 index 0000000..d3e8524 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/thread-view.tsx @@ -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 ( + + + Thread not found + + + ); + } + + const backHref = paths.dashboard.mail.list(currentLabelId); + + const hasMessage = Boolean(thread.message); + const hasAttachments = (thread.attachments ?? []).length > 0; + + return ( + + +
+ + + +
+ + + + + } + sx={{ width: '200px' }} + /> + + + + + + + + + + + +
+ + + + + +
+ + {thread.from.name} + {' '} + + {thread.from.email} + + + To:{' '} + {thread.to.map((person) => ( + + {person.email} + + ))} + + {thread.createdAt ? ( + + {dayjs(thread.createdAt).format('MMM D, YYYY hh:mm:ss A')} + + ) : null} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + {thread.subject} + + + {hasMessage ? : null} + {hasAttachments ? : null} + +
+ +
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/threads-view.tsx b/002_source/cms/src/components/dashboard/user/mail/threads-view.tsx new file mode 100644 index 0000000..2b90699 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/threads-view.tsx @@ -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 ( + + +
+ { + if (mdDown) { + setOpenMobileSidebar((prev) => !prev); + } else { + setOpenDesktopSidebar((prev) => !prev); + } + }} + > + + +
+ + + + + } + sx={{ width: '200px' }} + /> + + + + + + + + + + + + + + + + +
+ {threads.length ? ( + + + + 0 && selected.size < threads.length} + onChange={(event: React.ChangeEvent) => { + if (event.target.checked) { + selectAll(); + } else { + deselectAll(); + } + }} + /> + Select all + + + + + + + + + {threads.map((thread) => ( + { + deselectOne(thread.id); + }} + onSelect={() => { + selectOne(thread.id); + }} + selected={selected.has(thread.id)} + thread={thread} + /> + ))} + + + ) : ( + + + + + + + There are no threads + + + + )} +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user/mail/types.d.ts b/002_source/cms/src/components/dashboard/user/mail/types.d.ts new file mode 100644 index 0000000..e5eee95 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/mail/types.d.ts @@ -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; +} diff --git a/002_source/cms/src/components/dashboard/user/teacher-create-form.tsx b/002_source/cms/src/components/dashboard/user/teacher-create-form.tsx new file mode 100644 index 0000000..c8b4242 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/teacher-create-form.tsx @@ -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 { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.onerror = () => { + reject(new Error('Error converting file to base64')); + }; + }); +} + +const schema = zod.object({ + avatar: zod.string().optional(), + name: zod.string().min(1, 'Name is required').max(255), + email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255), + phone: zod.string().min(1, 'Phone is required').max(15), + company: zod.string().max(255), + billingAddress: zod.object({ + country: zod.string().min(1, 'Country is required').max(255), + state: zod.string().min(1, 'State is required').max(255), + city: zod.string().min(1, 'City is required').max(255), + zipCode: zod.string().min(1, 'Zip code is required').max(255), + line1: zod.string().min(1, 'Street line 1 is required').max(255), + line2: zod.string().max(255).optional(), + }), + taxId: zod.string().max(255).optional(), + timezone: zod.string().min(1, 'Timezone is required').max(255), + language: zod.string().min(1, 'Language is required').max(255), + currency: zod.string().min(1, 'Currency is required').max(255), +}); + +type Values = zod.infer; + +const defaultValues = { + avatar: '', + name: '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({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + 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(null); + const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + const url = await fileToBase64(file); + setValue('avatar', url); + } + }, + [setValue] + ); + + return ( +
+ + + } + spacing={4} + > + + Account information + + + + + + + + + + Avatar + Min 400x400px, PNG or JPEG + + + + + + + ( + + Name + + {errors.name ? {errors.name.message} : null} + + )} + /> + + + ( + + Email address + + {errors.email ? {errors.email.message} : null} + + )} + /> + + + ( + + Phone number + + {errors.phone ? {errors.phone.message} : null} + + )} + /> + + + ( + + Company + + {errors.company ? {errors.company.message} : null} + + )} + /> + + + + + Billing information + + + ( + + Country + + {errors.billingAddress?.country ? ( + {errors.billingAddress?.country?.message} + ) : null} + + )} + /> + + + ( + + State + + {errors.billingAddress?.state ? ( + {errors.billingAddress?.state?.message} + ) : null} + + )} + /> + + + ( + + City + + {errors.billingAddress?.city ? ( + {errors.billingAddress?.city?.message} + ) : null} + + )} + /> + + + ( + + Zip code + + {errors.billingAddress?.zipCode ? ( + {errors.billingAddress?.zipCode?.message} + ) : null} + + )} + /> + + + ( + + Address + + {errors.billingAddress?.line1 ? ( + {errors.billingAddress?.line1?.message} + ) : null} + + )} + /> + + + ( + + Tax ID + + {errors.taxId ? {errors.taxId.message} : null} + + )} + /> + + + + + Shipping information + } + label="Same as billing address" + /> + + + Additional information + + + ( + + Timezone + + {errors.timezone ? {errors.timezone.message} : null} + + )} + /> + + + ( + + Language + + {errors.language ? {errors.language.message} : null} + + )} + /> + + + ( + + Currency + + {errors.currency ? {errors.currency.message} : null} + + )} + /> + + + + + + + + + + + +
{JSON.stringify({ errors }, null, 2)}
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user/teacher-edit-form.tsx b/002_source/cms/src/components/dashboard/user/teacher-edit-form.tsx new file mode 100644 index 0000000..6885456 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/teacher-edit-form.tsx @@ -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; + +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(false); + const [showLoading, setShowLoading] = React.useState(false); + // + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + + const { + control, + handleSubmit, + formState: { errors }, + setValue, + reset, + watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsUpdating(true); + + const 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(null); + const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + const url = await fileToBase64(file); + setValue('avatar', url); + } + }, + [setValue] + ); + + // TODO: need to align with save form + // use trycatch + const [textDescription, setTextDescription] = React.useState(''); + const [textRemarks, setTextRemarks] = React.useState(''); + + // load existing data when user arrive + const loadExistingData = React.useCallback( + async (id: string) => { + setShowLoading(true); + + try { + const result = await pb.collection(COL_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 ; + if (showError.show) + return ( + + ); + + return ( +
+ + + } + spacing={4} + > + + {t('edit.basic-info')} + + + + + + + + + + {t('edit.avatar')} + {t('edit.avatarRequirements')} + + + + + + + ( + + Name + + {errors.name ? {errors.name.message} : null} + + )} + /> + + + ( + + Email + + {errors.email ? {errors.email.message} : null} + + )} + /> + + + ( + + Phone + + {errors.phone ? {errors.phone.message} : null} + + )} + /> + + + ( + + Company + + {errors.company ? {errors.company.message} : null} + + )} + /> + + + + {/* */} + + Billing Information + + + ( + + Country + + {errors.billingAddress?.country ? ( + {errors.billingAddress.country.message} + ) : null} + + )} + /> + + + ( + + State + + {errors.billingAddress?.state ? ( + {errors.billingAddress.state.message} + ) : null} + + )} + /> + + + ( + + City + + {errors.billingAddress?.city ? ( + {errors.billingAddress.city.message} + ) : null} + + )} + /> + + + ( + + Zip Code + + {errors.billingAddress?.zipCode ? ( + {errors.billingAddress.zipCode.message} + ) : null} + + )} + /> + + + ( + + Address Line 1 + + {errors.billingAddress?.line1 ? ( + {errors.billingAddress.line1.message} + ) : null} + + )} + /> + + + ( + + Tax ID + + {errors.taxId ? {errors.taxId.message} : null} + + )} + /> + + + + + + Additional Information + + + ( + + Timezone + + {errors.timezone ? {errors.timezone.message} : null} + + )} + /> + + + ( + + Language + + {errors.language ? {errors.language.message} : null} + + )} + /> + + + ( + + Currency + + {errors.currency ? {errors.currency.message} : null} + + )} + /> + + + + + + + + + + {t('edit.updateButton')} + + + + +
{JSON.stringify({ errors }, null, 2)}
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user/teachers-filters.tsx b/002_source/cms/src/components/dashboard/user/teachers-filters.tsx new file mode 100644 index 0000000..28bdce4 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/teachers-filters.tsx @@ -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(0); + const [activeCount, setActiveCount] = React.useState(0); + const [pendingCount, setPendingCount] = React.useState(0); + const [blockedCount, setBlockedCount] = React.useState(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 => { + 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 ( +
+ + {tabs.map((tab) => ( + + } + iconPosition="end" + key={tab.value} + label={tab.label} + sx={{ minHeight: 'auto' }} + tabIndex={0} + value={tab.value} + /> + ))} + + + + + { + handleEmailChange(value as string); + }} + onFilterDelete={() => { + handleEmailChange(); + }} + popover={} + value={email} + /> + + { + handlePhoneChange(value as string); + }} + onFilterDelete={() => { + handlePhoneChange(); + }} + popover={} + value={phone} + /> + + {hasFilters ? : null} + + {selection.selectedAny ? ( + + + {selection.selected.size} selected + + + + ) : null} + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/user/teachers-pagination.tsx b/002_source/cms/src/components/dashboard/user/teachers-pagination.tsx new file mode 100644 index 0000000..5aea10d --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/teachers-pagination.tsx @@ -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) => { + setRowsPerPage(parseInt(event.target.value)); + // console.log(parseInt(event.target.value)); + }; + + return ( + + ); +} diff --git a/002_source/cms/src/components/dashboard/user/teachers-selection-context.tsx b/002_source/cms/src/components/dashboard/user/teachers-selection-context.tsx new file mode 100644 index 0000000..1e92ad2 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/teachers-selection-context.tsx @@ -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({ + 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 {children}; +} + +export function useTeachersSelection(): CustomersSelectionContextValue { + return React.useContext(CustomersSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/user/teachers-table.tsx b/002_source/cms/src/components/dashboard/user/teachers-table.tsx new file mode 100644 index 0000000..f1c4bb2 --- /dev/null +++ b/002_source/cms/src/components/dashboard/user/teachers-table.tsx @@ -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[] { + return [ + { + formatter: (row): React.JSX.Element => ( + + +
+ + {row.name} + + + {row.email} + +
+
+ ), + name: 'Name', + width: '250px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)} + + + ), + name: 'Quota', + width: '150px', + }, + { field: 'phone', name: 'Phone number', width: '150px' }, + + { + formatter: (row): React.JSX.Element => { + const mapping = { + active: { + label: 'Active', + icon: ( + + ), + }, + blocked: { label: 'Blocked', icon: }, + pending: { + label: 'Pending', + icon: ( + + ), + }, + } as const; + const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null }; + + return ( + + ); + }, + name: 'Status', + width: '150px', + }, + { + formatter(row) { + return dayjs(row.createdAt).format('MMM D, YYYY'); + }, + name: 'Created at', + width: '150px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + + { + handleDeleteClick(row.id); + }} + > + + + + ), + 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 ( + + + + columns={columns(handleDeleteClick)} + onDeselectAll={deselectAll} + onDeselectOne={(_, row) => { + deselectOne(row.id); + }} + onSelectAll={selectAll} + onSelectOne={(_, row) => { + selectOne(row.id); + }} + rows={rows} + selectable + selected={selected} + /> + {!rows.length ? ( + + + {/* TODO: update this */} + {t('no-teachers-found')} + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/user/type.d.tsx b/002_source/cms/src/components/dashboard/user/type.d.tsx index 847764a..6033a60 100644 --- a/002_source/cms/src/components/dashboard/user/type.d.tsx +++ b/002_source/cms/src/components/dashboard/user/type.d.tsx @@ -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;