diff --git a/002_source/cms/src/app/dashboard/students/SampleCustomers.tsx b/002_source/cms/src/app/dashboard/students/SampleCustomers.tsx new file mode 100644 index 0000000..b991279 --- /dev/null +++ b/002_source/cms/src/app/dashboard/students/SampleCustomers.tsx @@ -0,0 +1,157 @@ +// src/app/dashboard/customers/page.tsx +'use client'; +import type { Customer } from '@/components/dashboard/customer/type.d'; +import { dayjs } from '@/lib/dayjs'; + +export const SampleCustomers = [ + { + id: 'USR-005', + name: 'Fran Perez', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + }, + { + id: 'USR-003', + name: 'Carson Darrin', + avatar: '/assets/avatar-3.png', + email: 'carson.darrin@domain.com', + phone: '(715) 278-5041', + quota: 10, + status: 'blocked', + createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-002', + name: 'Siegbert Gottfried', + avatar: '/assets/avatar-2.png', + email: 'siegbert.gottfried@domain.com', + phone: '(603) 766-0431', + quota: 0, + status: 'pending', + createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-001', + name: 'Miron Vitold', + avatar: '/assets/avatar-1.png', + email: 'miron.vitold@domain.com', + phone: '(425) 434-5535', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), + }, + { + id: 'USR-005', + name: 'Fran Perez', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + }, + { + id: 'USR-003', + name: 'Carson Darrin', + avatar: '/assets/avatar-3.png', + email: 'carson.darrin@domain.com', + phone: '(715) 278-5041', + quota: 10, + status: 'blocked', + createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-002', + name: 'Siegbert Gottfried', + avatar: '/assets/avatar-2.png', + email: 'siegbert.gottfried@domain.com', + phone: '(603) 766-0431', + quota: 0, + status: 'pending', + createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-001', + name: 'Miron Vitold', + avatar: '/assets/avatar-1.png', + email: 'miron.vitold@domain.com', + phone: '(425) 434-5535', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), + }, + { + id: 'USR-005', + name: 'Fran Perez', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + }, + { + id: 'USR-003', + name: 'Carson Darrin', + avatar: '/assets/avatar-3.png', + email: 'carson.darrin@domain.com', + phone: '(715) 278-5041', + quota: 10, + status: 'blocked', + createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-002', + name: 'Siegbert Gottfried', + avatar: '/assets/avatar-2.png', + email: 'siegbert.gottfried@domain.com', + phone: '(603) 766-0431', + quota: 0, + status: 'pending', + createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-001', + name: 'Miron Vitold', + avatar: '/assets/avatar-1.png', + email: 'miron.vitold@domain.com', + phone: '(425) 434-5535', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), + }, +] satisfies Customer[]; diff --git a/002_source/cms/src/app/dashboard/students/[customerId]/BasicDetailCard.tsx b/002_source/cms/src/app/dashboard/students/[customerId]/BasicDetailCard.tsx new file mode 100644 index 0000000..fdb0dac --- /dev/null +++ b/002_source/cms/src/app/dashboard/students/[customerId]/BasicDetailCard.tsx @@ -0,0 +1,80 @@ +'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 { Customer } from '@/components/dashboard/customer/type.d'; + +export default function BasicDetailCard({ + lpModel: model, + handleEditClick, +}: { + lpModel: Customer; + 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/students/[customerId]/TitleCard.tsx b/002_source/cms/src/app/dashboard/students/[customerId]/TitleCard.tsx new file mode 100644 index 0000000..971bfa7 --- /dev/null +++ b/002_source/cms/src/app/dashboard/students/[customerId]/TitleCard.tsx @@ -0,0 +1,76 @@ +'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 { Customer } from '@/components/dashboard/customer/type.d'; + +// import type { CrCategory } from '@/components/dashboard/cr/categories/type'; + +function getImageUrlFrRecord(record: Customer): 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: Customer }): 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/app/dashboard/students/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/students/[customerId]/page.tsx new file mode 100644 index 0000000..6196a4a --- /dev/null +++ b/002_source/cms/src/app/dashboard/students/[customerId]/page.tsx @@ -0,0 +1,142 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import SampleAddressCard from '@/app/dashboard/Sample/AddressCard'; +import { SampleNotifications } from '@/app/dashboard/Sample/Notifications'; +import SamplePaymentCard from '@/app/dashboard/Sample/PaymentCard'; +import SampleSecurityCard from '@/app/dashboard/Sample/SecurityCard'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Grid from '@mui/material/Unstable_Grid2'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import type { RecordModel } from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { config } from '@/config'; +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; + +import ErrorDisplay from '@/components/dashboard/error'; + +import { Notifications } from '@/components/dashboard/customer/notifications'; +import FormLoading from '@/components/loading'; +import BasicDetailCard from './BasicDetailCard'; +import TitleCard from './TitleCard'; +import { defaultCustomer } from '@/components/dashboard/customer/_constants'; +import type { Customer } from '@/components/dashboard/customer/type.d'; +import { COL_CUSTOMERS } from '@/constants'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + const router = useRouter(); + // + const { customerId } = useParams<{ customerId: string }>(); + // + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + // + const [showLessonCategory, setShowLessonCategory] = React.useState(defaultCustomer); + + function handleEditClick(): void { + router.push(paths.dashboard.customers.edit(showLessonCategory.id)); + } + + React.useEffect(() => { + if (customerId) { + pb.collection(COL_CUSTOMERS) + .getOne(customerId) + .then((model: RecordModel) => { + setShowLessonCategory({ ...defaultCustomer, ...model }); + }) + .catch((err) => { + logger.error(err); + toast(t('list.error')); + + setShowError({ show: true, detail: JSON.stringify(err) }); + }) + .finally(() => { + setShowLoading(false); + }); + } + }, [customerId]); + + // return <>{JSON.stringify({ showError, showLessonCategory }, null, 2)}; + + if (showLoading) return ; + if (showError.show) + return ( + + ); + + return ( + + + +
+ + + Customers + +
+ + + +
+ + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/students/_GUIDELINES.md b/002_source/cms/src/app/dashboard/students/_GUIDELINES.md new file mode 100644 index 0000000..1353901 --- /dev/null +++ b/002_source/cms/src/app/dashboard/students/_GUIDELINES.md @@ -0,0 +1,49 @@ +# GUIDELINES + +this folder is part of nextjs typescript project and containing page definition for `Customer` / `Customers` record: + +- list (./page.tsx) +- view (./[customerId]/page.tsx) +- create (./create/page.tsx) +- edit (./[customerId]/page.tsx) +- translation provided by react-i18next + +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` + +simple template: + +```typescript +// src/app/dashboard/customers/page.tsx +'use client'; + +// RULES: +// contains list page for customers (Customers) +// contain definition to collection only +// +import statements here ... +... +... +... + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + // ...content + // use direct return of pb.collection (e.g. return pb.collection(xxx)) + + return ( + <> + {* page content *} + + ) +} + + +interface PageProps { + searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string }; +} +``` diff --git a/002_source/cms/src/app/dashboard/students/create/page.tsx b/002_source/cms/src/app/dashboard/students/create/page.tsx new file mode 100644 index 0000000..a0460ab --- /dev/null +++ b/002_source/cms/src/app/dashboard/students/create/page.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import type { Metadata } from 'next'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; + +import { config } from '@/config'; +import { paths } from '@/paths'; +import { CustomerCreateForm } from '@/components/dashboard/customer/customer-create-form'; + +export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+
+ Create customer +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/students/edit/[customerId]/_PROMPT.md b/002_source/cms/src/app/dashboard/students/edit/[customerId]/_PROMPT.md new file mode 100644 index 0000000..abf4465 --- /dev/null +++ b/002_source/cms/src/app/dashboard/students/edit/[customerId]/_PROMPT.md @@ -0,0 +1,11 @@ +# task + +## instruction + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/_helloworld/page.tsx` + +with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx` + +please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_categories/edit/page.tsx` + +please draft a tsx for showing error to user thanks, diff --git a/002_source/cms/src/app/dashboard/students/edit/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/students/edit/[customerId]/page.tsx new file mode 100644 index 0000000..e4e23bc --- /dev/null +++ b/002_source/cms/src/app/dashboard/students/edit/[customerId]/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form'; +import { CustomerEditForm } from '@/components/dashboard/customer/customer-edit-form'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(['lp_categories']); + + React.useEffect(() => { + // console.log('helloworld'); + }, []); + + return ( + + + +
+ + + {t('edit.title')} + +
+
+ {t('edit.title')} +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/students/page.tsx b/002_source/cms/src/app/dashboard/students/page.tsx new file mode 100644 index 0000000..4137d42 --- /dev/null +++ b/002_source/cms/src/app/dashboard/students/page.tsx @@ -0,0 +1,256 @@ +// src/app/dashboard/customers/page.tsx +'use client'; + +// RULES: +// contains list page for customers (Customers) +// contain definition to collection only +// +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { COL_CUSTOMERS } from '@/constants'; +import { LoadingButton } from '@mui/lab'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +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 { config } from '@/config'; +import { CustomersFilters } from '@/components/dashboard/customer/customers-filters'; +// import type { Filters } from '@/components/dashboard/customer/customers-filters'; +import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination'; +import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context'; +import { CustomersTable } from '@/components/dashboard/customer/customers-table'; +import type { Customer, Filters } from '@/components/dashboard/customer/type.d'; +import { SampleCustomers } from './SampleCustomers'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import isDevelopment from '@/lib/check-is-development'; +import { logger } from '@/lib/default-logger'; +import { pb } from '@/lib/pb'; +import { toast } from '@/components/core/toaster'; +import ErrorDisplay from '@/components/dashboard/error'; +import { defaultCustomer } from '@/components/dashboard/customer/_constants'; +import FormLoading from '@/components/loading'; + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + const { t } = useTranslation(['customers']); + const router = useRouter(); + + const { email, phone, sortDir, status } = searchParams; + + const [lessonCategoriesData, setLessonCategoriesData] = React.useState([]); + // + + const [isLoadingAddPage, setIsLoadingAddPage] = React.useState(false); + const [showLoading, setShowLoading] = React.useState(true); + const [showError, setShowError] = React.useState({ show: false, detail: '' }); + // + const [rowsPerPage, setRowsPerPage] = React.useState(5); + // + const [f, setF] = React.useState([]); + const [currentPage, setCurrentPage] = React.useState(0); + // + const [recordCount, setRecordCount] = React.useState(0); + const [listOption, setListOption] = React.useState({}); + const [listSort, setListSort] = React.useState({}); + + // + // const sortedCustomers = applySort(SampleCustomers, sortDir); + // const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status }); + + // + const reloadRows = async (): Promise => { + try { + const models: ListResult = await pb + .collection(COL_CUSTOMERS) + .getList(currentPage + 1, rowsPerPage, listOption); + const { items, totalItems } = models; + const tempLessonTypes: Customer[] = items.map((lt) => { + return { ...defaultCustomer, ...lt }; + }); + + setLessonCategoriesData(tempLessonTypes); + setRecordCount(totalItems); + setF(tempLessonTypes); + // console.log({ currentPage, f }); + } catch (error) { + // + logger.error(error); + setShowError({ + // + show: true, + detail: JSON.stringify(error, null, 2), + }); + } finally { + setShowLoading(false); + } + }; + + const [lastListOption, setLastListOption] = React.useState({}); + const isFirstRun = React.useRef(false); + React.useEffect(() => { + if (!isFirstRun.current) { + isFirstRun.current = true; + } else if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) { + // reset page number as tab changes + setLastListOption(listOption); + setCurrentPage(0); + void reloadRows(); + } else { + void reloadRows(); + } + }, [currentPage, rowsPerPage, listOption]); + + React.useEffect(() => { + let tempFilter = [], + tempSortDir = ''; + + if (status) { + tempFilter.push(`status = "${status}"`); + } + + if (sortDir) { + tempSortDir = `-created`; + } + + if (email) { + tempFilter.push(`email ~ "%${email}%"`); + } + if (phone) { + tempFilter.push(`phone ~ "%${phone}%"`); + } + + let preFinalListOption = {}; + if (tempFilter.length > 0) { + preFinalListOption = { filter: tempFilter.join(' && ') }; + } + if (tempSortDir.length > 0) { + preFinalListOption = { ...preFinalListOption, sort: tempSortDir }; + } + setListOption(preFinalListOption); + // setListOption({ + // filter: tempFilter.join(' && '), + // sort: tempSortDir, + // // + // }); + }, [sortDir, email, phone, status]); + + if (showLoading) return ; + + if (showError.show) + return ( + + ); + + return ( + + + + + {t('list.title')} + + + { + setIsLoadingAddPage(true); + router.push(paths.dashboard.customers.create); + }} + startIcon={} + variant="contained" + > + {t('list.add')} + + + + + + + + + + + + + + + + +
{JSON.stringify(f, null, 2)}
+
+
+ ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Customer[] { + return row.sort((a, b) => { + if (sortDir === 'asc') { + return a.createdAt.getTime() - b.createdAt.getTime(); + } + + return b.createdAt.getTime() - a.createdAt.getTime(); + }); +} + +function applyFilters(row: Customer[], { email, phone, status }: Filters): Customer[] { + return row.filter((item) => { + if (email) { + if (!item.email?.toLowerCase().includes(email.toLowerCase())) { + return false; + } + } + + if (phone) { + if (!item.phone?.toLowerCase().includes(phone.toLowerCase())) { + return false; + } + } + + if (status) { + if (item.status !== status) { + return false; + } + } + + return true; + }); +} + +interface PageProps { + searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string }; +} diff --git a/002_source/cms/src/components/dashboard/student/_GUIDELINES.md b/002_source/cms/src/components/dashboard/student/_GUIDELINES.md new file mode 100644 index 0000000..709449f --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/_GUIDELINES.md @@ -0,0 +1,25 @@ +# GUIDELINES & KEY COMPONENTS + +- `_constants.ts` contains the constant for + + - default value (defaultValue) + - empty value (emptyValue) + +- `customers-table.tsx` + +- `confirm-delete-modal.tsx` - delete modal component when click delete button on list + + - `customers-filters.tsx` + - `customers-pagination.tsx` + - `email-filter-popover.tsx` + - `phone-filter-popover.tsx` + - `customers-selection-context.tsx` + +- `customer-create-form.tsx` - form to create a new customer +- `customer-edit-form.tsx` - form to edit a existing customer + +- `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/student/_constants.ts b/002_source/cms/src/components/dashboard/student/_constants.ts new file mode 100644 index 0000000..503e5e3 --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/_constants.ts @@ -0,0 +1,21 @@ +// RULES: +// default variable value for customer +// empty valur for customer + +import { dayjs } from '@/lib/dayjs'; +import type { Customer } from './type.d'; + +export const defaultCustomer: Customer = { + id: '', + name: '', + avatar: undefined, + email: '', + phone: undefined, + quota: 0, + status: 'pending', + createdAt: dayjs().toDate(), +}; + +export const emptyLpCategory: Customer = { + ...defaultCustomer, +}; diff --git a/002_source/cms/src/components/dashboard/student/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/student/confirm-delete-modal.tsx new file mode 100644 index 0000000..ba9aad5 --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/confirm-delete-modal.tsx @@ -0,0 +1,128 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { COL_LESSON_TYPES } from '@/constants'; +import deleteQuizLPCategories from '@/db/QuizListenings/Delete'; +import { LoadingButton } from '@mui/lab'; +import { Button, Container, Modal, Paper } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Note as NoteIcon } from '@phosphor-icons/react/dist/ssr/Note'; +import PocketBase from 'pocketbase'; +import { useTranslation } from 'react-i18next'; + +import { logger } from '@/lib/default-logger'; +import { toast } from '@/components/core/toaster'; +import { deleteCustomer } from '@/db/Customers/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 + deleteCustomer(idToDelete) + .then(() => { + reloadRows(); + handleClose(); + toast(t('delete.success')); + }) + .catch((err) => { + // console.error(err) + logger.error(err); + toast(t('delete.error')); + }) + .finally(() => { + setIsDeleteing(false); + }); + } + } + + return ( +
+ + + + + + + + + + + {t('Delete Lesson Type ?')} + + {t('Are you sure you want to delete lesson type ?')} + + + + + { + handleUserConfirmDelete(); + }} + loading={isDeleteing} + > + {t('Delete')} + + + + + + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/student/customer-create-form.tsx b/002_source/cms/src/components/dashboard/student/customer-create-form.tsx new file mode 100644 index 0000000..046f16e --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/customer-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 { createCustomer } from '@/db/Customers/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 CustomerCreateForm(): 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 createCustomer(values); + toast.success('Customer created'); + router.push(paths.dashboard.customers.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/student/customer-edit-form.tsx b/002_source/cms/src/components/dashboard/student/customer-edit-form.tsx new file mode 100644 index 0000000..8e9d211 --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/customer-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_CUSTOMERS } 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 CustomerEditForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(['lp_categories']); + + const { customerId } = useParams<{ customerId: 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_CUSTOMERS).update(customerId, updateData); + toast.success('Customer updated successfully'); + router.push(paths.dashboard.customers.list); + } catch (error) { + logger.error(error); + toast.error('Failed to update customer'); + } finally { + setIsUpdating(false); + } + }, + [customerId, 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_CUSTOMERS).getOne(id); + reset({ ...defaultValues, ...result }); + console.log({ result }); + + if (result.avatar_file) { + const fetchResult = await fetch( + `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar_file}` + ); + const blob = await fetchResult.blob(); + const url = await fileToBase64(blob); + setValue('avatar', url); + } + } catch (error) { + logger.error(error); + toast.error('Failed to load customer data'); + setShowError({ show: true, detail: JSON.stringify(error, null, 2) }); + } finally { + setShowLoading(false); + } + }, + [reset, setValue] + ); + + React.useEffect(() => { + void loadExistingData(customerId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [customerId]); + + 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/student/customers-filters.tsx b/002_source/cms/src/components/dashboard/student/customers-filters.tsx new file mode 100644 index 0000000..56a7e4f --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/customers-filters.tsx @@ -0,0 +1,246 @@ +'use client'; +// RULES: +// T.B.A. +// +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { getAllCustomersCount } from '@/db/Customers/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 { useCustomersSelection } from './customers-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 { CustomersFiltersProps, Filters, SortDir } from './type.d'; + +export function CustomersFilters({ + filters = {}, + sortDir = 'desc', + fullData, +}: CustomersFiltersProps): 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 = useCustomersSelection(); + + // 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.customers.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 getAllCustomersCount(); + setTotalCount(tc); + + const bc = await GetBlockedCount(); + setBlockedCount(bc); + const pc = await GetPendingCount(); + setPendingCount(pc); + const ac = await GetActiveCount(); + setActiveCount(ac); + } catch (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/student/customers-pagination.tsx b/002_source/cms/src/components/dashboard/student/customers-pagination.tsx new file mode 100644 index 0000000..a7a89b6 --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/customers-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 CustomersPagination({ + 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/student/customers-selection-context.tsx b/002_source/cms/src/components/dashboard/student/customers-selection-context.tsx new file mode 100644 index 0000000..72cd8ba --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/customers-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 { Customer } 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; + customers: Customer[]; +} + +export function CustomersSelectionProvider({ + children, + customers = [], +}: CustomersSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]); + const selection = useSelection(customerIds); + + return {children}; +} + +export function useCustomersSelection(): CustomersSelectionContextValue { + return React.useContext(CustomersSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/student/customers-table.tsx b/002_source/cms/src/components/dashboard/student/customers-table.tsx new file mode 100644 index 0000000..f064433 --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/customers-table.tsx @@ -0,0 +1,222 @@ +'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 { useCustomersSelection } from './customers-selection-context'; +import type { Customer } 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 => { + // eslint-disable-next-line react-hooks/rules-of-hooks + + 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 CustomersTableProps { + rows: Customer[]; + reloadRows: () => void; +} + +export function CustomersTable({ rows, reloadRows }: CustomersTableProps): React.JSX.Element { + const { t } = useTranslation(['customers']); + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection(); + + 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-record-found')} + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/student/email-filter-popover.tsx b/002_source/cms/src/components/dashboard/student/email-filter-popover.tsx new file mode 100644 index 0000000..2636af0 --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/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/student/helloworld.tsx b/002_source/cms/src/components/dashboard/student/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/student/notifications.tsx b/002_source/cms/src/components/dashboard/student/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/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/student/payments.tsx b/002_source/cms/src/components/dashboard/student/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/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/student/phone-filter-popover.tsx b/002_source/cms/src/components/dashboard/student/phone-filter-popover.tsx new file mode 100644 index 0000000..08cbad7 --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/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/student/shipping-address.tsx b/002_source/cms/src/components/dashboard/student/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/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/student/type.d.tsx b/002_source/cms/src/components/dashboard/student/type.d.tsx new file mode 100644 index 0000000..a9c7cde --- /dev/null +++ b/002_source/cms/src/components/dashboard/student/type.d.tsx @@ -0,0 +1,69 @@ +'use client'; + +export type SortDir = 'asc' | 'desc'; + +export interface Customer { + 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: Customer[]; +} +export interface Filters { + email?: string; + phone?: string; + status?: string; +}