Compare commits

...

6 Commits

Author SHA1 Message Date
louiscklaw
b8e8968866 update build scripts and prompt,s 2025-04-24 13:19:51 +08:00
louiscklaw
92040c6efb update student pages, 2025-04-24 13:19:21 +08:00
louiscklaw
a3d2ee57f7 update init student, 2025-04-24 12:40:42 +08:00
louiscklaw
90835a7fe3 Merge branch 'develop/cms/frontend/dashboard/students/trunk' into develop/cms/frontend/dashboard/teachers/trunk 2025-04-24 12:10:09 +08:00
louiscklaw
ca2a9c235b update init teacher , 2025-04-24 12:09:47 +08:00
louiscklaw
41a35b487a init students, 2025-04-24 02:12:27 +08:00
96 changed files with 4599 additions and 338 deletions

View File

@@ -1,7 +1,6 @@
const { resolve } = require('node:path');
const project = resolve(__dirname, 'tsconfig.json');
module.exports = {
root: true,
extends: [
@@ -84,4 +83,13 @@ module.exports = {
'react/jsx-sort-props': 'off',
},
ignorePatterns: ['**/*del', '**/*bak', '**/*copy.*', '**/*copy*.*'],
overrides: [
{
// override to ignore no-def for `describe`, `it`, and `expect`
files: ['*.test.ts', '*.test.tsx'],
rules: {
'no-undef': 'off',
},
},
],
};

View File

@@ -7,16 +7,19 @@ Imagine there is a:
1. developer (provide the modification)
2. QA engineer (provide the feedback, and testing)
3. software engineer
4. technical writer
software engineer will:
they will:
- conclude and integrate the ideas from developer and QA engineer
- make decision to modify the code accordingly.
no need to reply me what you are going on and your digest in this phase.
just reply me "OK" when done
## project background and initial setup
base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project`
- No need to reply me what you are going on and your digest in this phase.
Just reply me "OK" when done
- base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project`
- `schema.dbml`
@@ -35,5 +38,7 @@ base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/letterso
- directory may contain `repomix-output.xml` file, that is a simple summary of all files inside the directory
- read, remember and link up the ideas in file stated above,
- if the directory user provided contins `_GUIDELINES.md`, please read the file
- read the files, remember and link up the ideas in file stated above,
i will tell them the task afterwards

View File

@@ -0,0 +1,52 @@
---
description: Next.js with TypeScript and Tailwind UI best practices
globs: **/*.tsx, **/*.ts, src/**/*.ts, src/**/*.tsx
---
# Next.js Best Practices
## Project Structure
- Use the App Router directory structure
- Place components in `app` directory for route-specific components
- Place shared components in `components` directory
- Place utilities and helpers in `lib` directory
- Use lowercase with dashes for directories (e.g., `components/auth-wizard`)
## Components
- Use Server Components by default
- Mark client components explicitly with 'use client'
- Wrap client components in Suspense with fallback
- Use dynamic loading for non-critical components
- Implement proper error boundaries
- Place static content and interfaces at file end
## Performance
- Optimize images: Use WebP format, size data, lazy loading
- Minimize use of 'useEffect' and 'setState'
- Favor Server Components (RSC) where possible
- Use dynamic loading for non-critical components
- Implement proper caching strategies
## Data Fetching
- Use Server Components for data fetching when possible
- Implement proper error handling for data fetching
- Use appropriate caching strategies
- Handle loading and error states appropriately
## Routing
- Use the App Router conventions
- Implement proper loading and error states for routes
- Use dynamic routes appropriately
- Handle parallel routes when needed
## Forms and Validation
- Use Zod for form validation
- Implement proper server-side validation
- Handle form errors appropriately
- Show loading states during form submission
## State Management
- Minimize client-side state
- Use React Context sparingly
- Prefer server state when possible
- Implement proper loading states

View File

@@ -0,0 +1,57 @@
---
description: TypeScript coding standards and best practices for modern web development
globs: **/*.ts, **/*.tsx, **/*.d.ts
---
# TypeScript Best Practices
## Type System
- Prefer interfaces over types for object definitions
- Use type for unions, intersections, and mapped types
- Avoid using `any`, prefer `unknown` for unknown types
- Use strict TypeScript configuration
- Leverage TypeScript's built-in utility types
- Use generics for reusable type patterns
## Naming Conventions
- Use PascalCase for type names and interfaces
- Use camelCase for variables and functions
- Use UPPER_CASE for constants
- Use descriptive names with auxiliary verbs (e.g., isLoading, hasError)
- Prefix interfaces for React props with 'Props' (e.g., ButtonProps)
## Code Organization
- Keep type definitions close to where they're used
- Export types and interfaces from dedicated type files when shared
- Use barrel exports (index.ts) for organizing exports
- Place shared types in a `types` directory
- Co-locate component props with their components
## Functions
- Use explicit return types for public functions
- Use arrow functions for callbacks and methods
- Implement proper error handling with custom error types
- Use function overloads for complex type scenarios
- Prefer async/await over Promises
## Best Practices
- Enable strict mode in tsconfig.json
- Use readonly for immutable properties
- Leverage discriminated unions for type safety
- Use type guards for runtime type checking
- Implement proper null checking
- Avoid type assertions unless necessary
## Error Handling
- Create custom error types for domain-specific errors
- Use Result types for operations that can fail
- Implement proper error boundaries
- Use try-catch blocks with typed catch clauses
- Handle Promise rejections properly
## Patterns
- Use the Builder pattern for complex object creation
- Implement the Repository pattern for data access
- Use the Factory pattern for object creation
- Leverage dependency injection
- Use the Module pattern for encapsulation

View File

@@ -1,22 +0,0 @@
Hi, i need your help.
i am working on a nextjs react typescript project
i've already copied the code(and it sub-directories) from
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/lp` (a.k.a. `lp`)
to
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/cr` (a.k.a. `cr`)
i want you to:
- understand the relations (e.g. `lp` -> `lp_categories`, `lp` -> `COL_QUIZ_LP_CATEGORIES`)
- review if any remaining `lp` or `mf` exist in `cr` directory and please help to do replace it to `cr`. (e.g. `COL_QUIZ_LP_CATEGORIES` -> `COL_QUIZ_CR_CATEGORIES` )
- please create if you find any missing files/codes/constants
thank you
---
- compare the difference between `lp` and `mf`,
- remember the differences and
- draft the new type `cr` (e.g. modify the `import` locations, variables, functions, classes, constants name etc.)

View File

@@ -1,66 +0,0 @@
The software engineer will provide solutions,
while QA engineer will feedback the opinion.
this is now not in debug phase,
so, no need to reply me what they are going on or their insight throught the prompt.
just reply me "OK" when done
---
clone `GetVisibleCount.tsx` and `GetHiddenCount.tsx` from `LessonTypes` to `LessonCategories` and update it
please draft `GetHiddenCount.tsx` for COL_LESSON_TYPES and `status = hidden`
well done !, please proceed to another request
working directory: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db`
according information from `schema.json`, get the collection of `Students`
pleaes clone the `tsx` files from `LessonTypes` and `LessonCategories` to `Students` and update the content
when you draft coding, review file and append with `.tsx.draft`
---
- this is part of react typescript project, with pocketbase
- `schema.dbml`, describe the collections(tables)
- folder `LessonCategories`, the correct references
- folder `LessonTypes`, the correct references
- you can find the `schema.dbml` and schema information from `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006`
- do not read root directory, assume it is a fresh copy of nextjs project is ok
## instruction
- break the questions into smaller parts
- review file append with `.draft`, see if the content aligned with the correct references
- read and understand `dbml` file
- lookup the every folder
## tasks
Thanks
---
---
please revise
please revise
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/types/LpCategory.tsx` `interface LpCategory`
to the collection `QuizLPCategories` align the dbml file in the previous prompt
please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/lp_categories/_constants.tsx`
to follow the type definition in `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/types/LpCategory.tsx`, the constant `defaultLpCategory`
---
the constants file (`@/constants`) was `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/constants.ts`
please help to fix the `tsx` files in folder `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/QuizMFCategories`,
the `COL` constants is wrongly used, it should refer to `COL_QUIZ_MF_CATEGORIES`. thanks
please update the `COL_XXXX` TO COL_MF_CATEGORIES

View File

@@ -2,19 +2,24 @@
## getting started
Imagine there is a
Imagine there is a:
1. software developer and a
2. QA engineer
1. developer (provide the modification)
2. QA engineer (provide the feedback, and testing)
3. software engineer
4. technical writer
to solve the problems together
they will:
They will:
- conclude and integrate the ideas from developer and QA engineer
- make decision to modify the code accordingly.
no need to reply me what you are going on and your digest in this phase.
just reply me "OK" when done
## project background and initial setup
base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project`
- No need to reply me what you are going on and your digest in this phase.
Just reply me "OK" when done
- base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project`
- `schema.dbml`
@@ -33,5 +38,7 @@ base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/letterso
- directory may contain `repomix-output.xml` file, that is a simple summary of all files inside the directory
- read, remember and link up the ideas in file stated above,
- if the directory user provided contins `_GUIDELINES.md`, please read the file
- read the files, remember and link up the ideas in file stated above,
i will tell them the task afterwards

View File

@@ -10,7 +10,7 @@ i want you to:
- understand the relations (e.g. `lp` -> `lp_Questions`, `lp` -> `COL_QUIZ_LP_QUESTIONS`)
- review if any remaining `lp` or `mf` exist in `cr` directory and please help to do replace it to `cr`. (e.g. `COL_QUIZ_LP_QUESTIONS` -> `COL_QUIZ_CR_Questions` )
- please create if you find any missing files/codes/constants
- using template, create the similar code for `cr` named `QuizCRQuestions`
- using template, create the similar code for `customer` named `Customers`
thank you

View File

@@ -0,0 +1,20 @@
Hi, i need your help.
## task
i am working on a dbml file
i got a schema.json from exported from pocketbase
and i want to update it to my current dbml file (for documentation)
## Rules
- the collection from json file started with `_` can be ignored. they are system collection and should not appear in dbml
- one collection from json file mapped with one table in dbml file
- the `presentable` field from json file should be ignored.
## information
json file: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/schema.json`
dbml file: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006/schema.dbml`
thanks

View File

@@ -0,0 +1,10 @@
---
tags: db, driver
---
# clone db driver
please understand the tsx files in folder
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/Teachers`
change all occurrence of `customer` to `teacher` thanks.

View File

@@ -0,0 +1,18 @@
# task
update `dbml` from `schema.json`
## things to note
1. please skip `presentable` properties from `schema.json`
## steps
1. read file `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/schema.json`. this is the export from `pocketbase`.
1. read file `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006/schema.dbml`. this is file written in dbml format.
1. currently the collection in `schama.json` is mapped to table in `schema.dbml`
1. compare the `schema.json` and `schema.dbml`, remember the differences
1. you may found some comment already exist in `schema.dbml`, please keep them
1. while keeping `schema.json` content unchanged. write file to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006/schema.dbml` content based on `schema.json`.
thanks.

View File

@@ -0,0 +1,10 @@
# task
extend function by clone and updating exist code
## steps
please read file
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/QuizListenings/GetAllCount.tsx`
duplicate it to cover `GetActiveCount`, `GetPendingCount` and `GetBlockedCount`. create them in `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/Customers` directory thanks.

Binary file not shown.

View File

@@ -0,0 +1 @@
please read, understand and remember `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/_AI_WORKSPACE/code`

View File

@@ -1,37 +0,0 @@
# AI GUIDELINE
## getting started
Imagine there is a
1. software developer and a
2. QA engineer
to solve the problems together
They will:
no need to reply me what you are going on and your digest in this phase.
just reply me "OK" when done
base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project`
- `schema.dbml`
- read `<base_dir>/001_documentation/Requirements/REQ0006/schema.dbml`
this is file in `dbml` format stating the main database structure
- `schema.json`
- read `<base_dir>/002_source/cms/src/db/schema.json`
this is the file of current pocketbase schema
- read `<base_dir>/002_source/cms/src/constants.ts`
this is the content of `@/constants`
- look into the md files in folder `<base_dir>/002_source/cms/_AI_WORKSPACE/001_guideline`
- directory may contain `repomix-output.xml` file, that is a simple summary of all files inside the directory
- read, remember and link up the ideas in file stated above,
i will tell them the task afterwards

View File

@@ -1,25 +0,0 @@
Hi, i need your help.
i am working on a nextjs react typescript project
i've already copied the code(and it sub-directories) from `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lp` (a.k.a. `lp`) to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/cr` (a.k.a. `cr`)
i want you to:
- list files from directory
- `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lp`
- `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/cr`
- understand the relations (e.g. `lp` -> `lp_categories`, `lp` -> `COL_QUIZ_LP_CATEGORIES`)
- review if any remaining `lp` or `mf` exist in `cr` directory and please help to do replace it to `cr`. (e.g. `COL_QUIZ_LP_CATEGORIES` -> `COL_QUIZ_CR_CATEGORIES` )
- please create if you find any missing files/codes/constants
thank you
---
- I proofed the code is working already, what you need to do is refactoring of the `variables`, `functions`, `classes`, `constants` (`lp` -> `cr`)
- compare the difference between `lp` and `mf`,
- remember the differences and
- draft the new type `cr` (e.g. modify the `import` locations, variables, functions, classes, constants name etc.)

View File

@@ -1,66 +0,0 @@
The software engineer will provide solutions,
while QA engineer will feedback the opinion.
this is now not in debug phase,
so, no need to reply me what they are going on or their insight throught the prompt.
just reply me "OK" when done
---
clone `GetVisibleCount.tsx` and `GetHiddenCount.tsx` from `LessonTypes` to `LessonCategories` and update it
please draft `GetHiddenCount.tsx` for COL_LESSON_TYPES and `status = hidden`
well done !, please proceed to another request
working directory: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db`
according information from `schema.json`, get the collection of `Students`
pleaes clone the `tsx` files from `LessonTypes` and `LessonCategories` to `Students` and update the content
when you draft coding, review file and append with `.tsx.draft`
---
- this is part of react typescript project, with pocketbase
- `schema.dbml`, describe the collections(tables)
- folder `LessonCategories`, the correct references
- folder `LessonTypes`, the correct references
- you can find the `schema.dbml` and schema information from `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006`
- do not read root directory, assume it is a fresh copy of nextjs project is ok
## instruction
- break the questions into smaller parts
- review file append with `.draft`, see if the content aligned with the correct references
- read and understand `dbml` file
- lookup the every folder
## tasks
Thanks
---
---
please revise
please revise
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/types/LpCategory.tsx` `interface LpCategory`
to the collection `QuizLPCategories` align the dbml file in the previous prompt
please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/lp_categories/_constants.tsx`
to follow the type definition in `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/types/LpCategory.tsx`, the constant `defaultLpCategory`
---
the constants file (`@/constants`) was `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/constants.ts`
please help to fix the `tsx` files in folder `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/QuizMFCategories`,
the `COL` constants is wrongly used, it should refer to `COL_QUIZ_MF_CATEGORIES`. thanks
please update the `COL_XXXX` TO COL_MF_CATEGORIES

View File

@@ -91,6 +91,7 @@
"zod": "3.22.4"
},
"devDependencies": {
"@faker-js/faker": "^9.7.0",
"@ianvs/prettier-plugin-sort-imports": "4.1.1",
"@testing-library/jest-dom": "6.4.2",
"@testing-library/react": "14.2.1",
@@ -116,4 +117,4 @@
"protobufjs"
]
}
}
}

View File

@@ -195,6 +195,9 @@ importers:
specifier: 3.22.4
version: 3.22.4
devDependencies:
'@faker-js/faker':
specifier: ^9.7.0
version: 9.7.0
'@ianvs/prettier-plugin-sort-imports':
specifier: 4.1.1
version: 4.1.1(prettier@3.2.5)
@@ -725,6 +728,10 @@ packages:
resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@faker-js/faker@9.7.0':
resolution: {integrity: sha512-aozo5vqjCmDoXLNUJarFZx2IN/GgGaogY4TMJ6so/WLZOWpSV7fvj2dmrV6sEAnUm1O7aCrhTibjpzeDFgNqbg==}
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
'@fastify/busboy@2.1.0':
resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==}
engines: {node: '>=14'}
@@ -6303,6 +6310,8 @@ snapshots:
'@eslint/js@8.57.0': {}
'@faker-js/faker@9.7.0': {}
'@fastify/busboy@2.1.0': {}
'@firebase/analytics-compat@0.2.7(@firebase/app-compat@0.2.27)(@firebase/app@0.9.27)':

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -ex
node ./scripts/update_repomix.js
echo "done"

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -ex
git add src
git commit -m'update,'
echo "done"

View File

@@ -2,16 +2,17 @@ const exec = require('child_process').exec;
let directories = [
//
'./src/db',
'src/app/dashboard/lp',
'src/app/dashboard/mf',
'src/app/dashboard/cr',
'src/components/dashboard/lp',
'src/components/dashboard/mf',
'src/components/dashboard/cr',
'src/app/dashboard/Sample',
// './src/db',
// './src/app/dashboard/lp',
// './src/app/dashboard/mf',
// './src/app/dashboard/cr',
// './src/components/dashboard/lp',
// './src/components/dashboard/mf',
// './src/components/dashboard/cr',
// './src/app/dashboard/Sample',
'./src/components/dashboard/customer',
].map((directory) => {
return `cd ${directory} && pnpx repomix`;
return `cd ${directory} && pnpx repomix -c /home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/repomix.config.json`;
});
Promise.all(
@@ -34,4 +35,5 @@ Promise.all(
})
.catch((error) => {
console.error(error);
// process.exit(1);
});

View File

@@ -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 `<base_dir>/002_source/002_source/cms/src`
## Assumption and Requirements
- let one file contains one component only.
- type information defined in `<base_dir>/002_source/cms/src/db/Customers/type.d.tsx`
- it mainly consume the db drivers `Customres` in `<base_dir>/002_source/cms/src/db/Customers`
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 };
}
```

View File

@@ -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/student/student-create-form';
export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
export default function Page(): React.JSX.Element {
return (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack spacing={3}>
<div>
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.customers.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Customers
</Link>
</div>
<div>
<Typography variant="h4">Create customer</Typography>
</div>
</Stack>
<CustomerCreateForm />
</Stack>
</Box>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,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/student/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';
export default function Page(): React.JSX.Element {
const { t } = useTranslation();
const router = useRouter();
//
const { customerId } = useParams<{ customerId: string }>();
//
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [showLessonCategory, setShowLessonCategory] = React.useState<Student>(defaultStudent);
function handleEditClick(): void {
router.push(paths.dashboard.students.edit(showLessonCategory.id));
}
React.useEffect(() => {
if (customerId) {
pb.collection(COL_STUDENTS)
.getOne(customerId)
.then((model: RecordModel) => {
setShowLessonCategory({ ...defaultStudent, ...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 <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack spacing={3}>
<div>
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.students.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Students
</Link>
</div>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<TitleCard lpModel={showLessonCategory} />
</Stack>
</Stack>
<Grid
container
spacing={4}
>
<Grid
lg={4}
xs={12}
>
<Stack spacing={4}>
<BasicDetailCard
lpModel={showLessonCategory}
handleEditClick={handleEditClick}
/>
<SampleSecurityCard />
</Stack>
</Grid>
<Grid
lg={8}
xs={12}
>
<Stack spacing={4}>
<SamplePaymentCard />
<SampleAddressCard />
<Notifications notifications={SampleNotifications} />
</Stack>
</Grid>
</Grid>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,11 @@
# task
## instruction
with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/_helloworld/page.tsx`
with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx`
please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_categories/edit/page.tsx`
please draft a tsx for showing error to user thanks,

View File

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

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import type { Metadata } from 'next';
import { config } from '@/config';
import { ThreadView } from '@/components/dashboard/mail/thread-view';
export const metadata = { title: `Thread | Mail | Dashboard | ${config.site.name}` } satisfies Metadata;
interface PageProps {
params: { threadId: string };
}
export default function Page({ params }: PageProps): React.JSX.Element {
const { threadId } = params;
return <ThreadView threadId={threadId} />;
}

View File

@@ -0,0 +1,142 @@
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';
function filterThreads(threads: Thread[], labelId: string): Thread[] {
return threads.filter((thread) => {
if (['inbox', 'sent', 'drafts', 'spam', 'trash'].includes(labelId)) {
return thread.folder === labelId;
}
if (labelId === 'important') {
return thread.isImportant;
}
if (labelId === 'starred') {
return thread.isStarred;
}
if (thread.labels.includes(labelId)) {
return true;
}
return false;
});
}
const labels = [
{ id: 'inbox', type: 'system', name: 'Inbox', unreadCount: 1, totalCount: 0 },
{ id: 'sent', type: 'system', name: 'Sent', unreadCount: 0, totalCount: 0 },
{ id: 'drafts', type: 'system', name: 'Drafts', unreadCount: 0, totalCount: 0 },
{ id: 'spam', type: 'system', name: 'Spam', unreadCount: 0, totalCount: 0 },
{ id: 'trash', type: 'system', name: 'Trash', unreadCount: 0, totalCount: 1 },
{ id: 'important', type: 'system', name: 'Important', unreadCount: 0, totalCount: 1 },
{ id: 'starred', type: 'system', name: 'Starred', unreadCount: 1, totalCount: 1 },
{ id: 'work', type: 'custom', name: 'Work', color: '#43A048', unreadCount: 0, totalCount: 1 },
{ id: 'business', type: 'custom', name: 'Business', color: '#1E88E5', unreadCount: 1, totalCount: 2 },
{ 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 };
}
export default function Layout({ children, params }: LayoutProps): React.JSX.Element {
const { labelId } = params;
const filteredThreads = filterThreads(threads, labelId);
return (
<MailProvider currentLabelId={labelId} labels={labels} threads={filteredThreads}>
<MailView>{children}</MailView>
</MailProvider>
);
}

View File

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

View File

@@ -0,0 +1,210 @@
// 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 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 { StudentsFilters } from '@/components/dashboard/student/students-filters';
import { StudentsPagination } from '@/components/dashboard/student/students-pagination';
import { StudentsSelectionProvider } from '@/components/dashboard/student/students-selection-context';
import { StudentsTable } from '@/components/dashboard/student/students-table';
import type { Student } from '@/components/dashboard/student/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 { defaultStudent } from '@/components/dashboard/student/_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<Student[]>([]);
//
const [isLoadingAddPage, setIsLoadingAddPage] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [rowsPerPage, setRowsPerPage] = React.useState<number>(5);
//
const [f, setF] = React.useState<Student[]>([]);
const [currentPage, setCurrentPage] = React.useState<number>(0);
//
const [recordCount, setRecordCount] = React.useState<number>(0);
const [listOption, setListOption] = React.useState({});
//
const reloadRows = async (): Promise<void> => {
try {
const models: ListResult<RecordModel> = await pb
.collection(COL_CUSTOMERS)
.getList(currentPage + 1, rowsPerPage, listOption);
const { items, totalItems } = models;
const tempLessonTypes: Student[] = items.map((lt) => {
return { ...defaultStudent, ...lt };
});
setLessonCategoriesData(tempLessonTypes);
setRecordCount(totalItems);
setF(tempLessonTypes);
} 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 = {};
if (tempFilter.length > 0) {
preFinalListOption = { filter: tempFilter.join(' && ') };
}
if (tempSortDir.length > 0) {
preFinalListOption = { ...preFinalListOption, sort: tempSortDir };
}
setListOption(preFinalListOption);
}, [sortDir, email, phone, status]);
if (showLoading) return <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code={-1}
details={showError.detail}
/>
);
return (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<Box sx={{ flex: '1 1 auto' }}>
<Typography variant="h4">{t('list.title')}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<LoadingButton
loading={isLoadingAddPage}
onClick={(): void => {
setIsLoadingAddPage(true);
router.push(paths.dashboard.customers.create);
}}
startIcon={<PlusIcon />}
variant="contained"
>
{t('list.add')}
</LoadingButton>
</Box>
</Stack>
<StudentsSelectionProvider customers={f}>
<Card>
<StudentsFilters
filters={{ email, phone, status }}
fullData={lessonCategoriesData}
sortDir={sortDir}
/>
<Divider />
<Box sx={{ overflowX: 'auto' }}>
<StudentsTable
reloadRows={reloadRows}
rows={f}
/>
</Box>
<Divider />
<StudentsPagination
count={recordCount}
page={currentPage}
rowsPerPage={rowsPerPage}
setPage={setCurrentPage}
setRowsPerPage={setRowsPerPage}
/>
</Card>
</StudentsSelectionProvider>
</Stack>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify(f, null, 2)}</pre>
</Box>
</Box>
);
}
interface PageProps {
searchParams: {
email?: string;
phone?: string;
sortDir?: 'asc' | 'desc';
status?: string;
//
};
}

View File

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

View File

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

View File

@@ -0,0 +1,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/student/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';
export default function Page(): React.JSX.Element {
const { t } = useTranslation();
const router = useRouter();
//
const { customerId } = useParams<{ customerId: string }>();
//
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [showLessonCategory, setShowLessonCategory] = React.useState<Student>(defaultStudent);
function handleEditClick(): void {
router.push(paths.dashboard.students.edit(showLessonCategory.id));
}
React.useEffect(() => {
if (customerId) {
pb.collection(COL_STUDENTS)
.getOne(customerId)
.then((model: RecordModel) => {
setShowLessonCategory({ ...defaultStudent, ...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 <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack spacing={3}>
<div>
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.students.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Students
</Link>
</div>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<TitleCard lpModel={showLessonCategory} />
</Stack>
</Stack>
<Grid
container
spacing={4}
>
<Grid
lg={4}
xs={12}
>
<Stack spacing={4}>
<BasicDetailCard
lpModel={showLessonCategory}
handleEditClick={handleEditClick}
/>
<SampleSecurityCard />
</Stack>
</Grid>
<Grid
lg={8}
xs={12}
>
<Stack spacing={4}>
<SamplePaymentCard />
<SampleAddressCard />
<Notifications notifications={SampleNotifications} />
</Stack>
</Grid>
</Grid>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,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 SampleTeachers = [
{
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[];

View File

@@ -24,13 +24,13 @@ import { toast } from '@/components/core/toaster';
import ErrorDisplay from '@/components/dashboard/error';
import { Notifications } from '@/components/dashboard/customer/notifications';
import { Notifications } from '@/components/dashboard/teacher/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';
import { defaultTeacher } from '@/components/dashboard/teacher/_constants';
import type { Teacher } from '@/components/dashboard/teacher/type.d';
import { COL_TEACHERS } from '@/constants';
export default function Page(): React.JSX.Element {
const { t } = useTranslation();
@@ -41,18 +41,18 @@ export default function Page(): React.JSX.Element {
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [showLessonCategory, setShowLessonCategory] = React.useState<Customer>(defaultCustomer);
const [showLessonCategory, setShowLessonCategory] = React.useState<Teacher>(defaultTeacher);
function handleEditClick(): void {
router.push(paths.dashboard.customers.edit(showLessonCategory.id));
router.push(paths.dashboard.teachers.edit(showLessonCategory.id));
}
React.useEffect(() => {
if (customerId) {
pb.collection(COL_CUSTOMERS)
pb.collection(COL_TEACHERS)
.getOne(customerId)
.then((model: RecordModel) => {
setShowLessonCategory({ ...defaultCustomer, ...model });
setShowLessonCategory({ ...defaultTeacher, ...model });
})
.catch((err) => {
logger.error(err);
@@ -93,12 +93,12 @@ export default function Page(): React.JSX.Element {
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.customers.list}
href={paths.dashboard.teachers.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Customers
Teachers
</Link>
</div>
<Stack

View File

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

View File

@@ -11,7 +11,7 @@ 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';
import { TeacherEditForm } from '@/components/dashboard/teacher/teacher-edit-form';
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['lp_categories']);
@@ -35,7 +35,7 @@ export default function Page(): React.JSX.Element {
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.cr_categories.list}
href={paths.dashboard.teachers.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
@@ -47,7 +47,7 @@ export default function Page(): React.JSX.Element {
<Typography variant="h4">{t('edit.title')}</Typography>
</div>
</Stack>
<CustomerEditForm />
<TeacherEditForm />
</Stack>
</Box>
);

View File

@@ -1,13 +1,13 @@
// src/app/dashboard/customers/page.tsx
// src/app/dashboard/teachers/page.tsx
'use client';
// RULES:
// contains list page for customers (Customers)
// contains list page for teachers (Teachers)
// contain definition to collection only
//
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { COL_CUSTOMERS } from '@/constants';
import { COL_TEACHERS } from '@/constants';
import { LoadingButton } from '@mui/lab';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -19,13 +19,13 @@ 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 { TeachersFilters } from '@/components/dashboard/teacher/teachers-filters';
// import type { Filters } from '@/components/dashboard/teacher/customers-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, Filters } from '@/components/dashboard/teacher/type.d';
import { SampleTeachers } from './SampleTeachers';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
@@ -34,16 +34,16 @@ 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 { 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<Customer[]>([]);
const [lessonCategoriesData, setLessonCategoriesData] = React.useState<Teacher[]>([]);
//
const [isLoadingAddPage, setIsLoadingAddPage] = React.useState<boolean>(false);
@@ -52,26 +52,22 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
//
const [rowsPerPage, setRowsPerPage] = React.useState<number>(5);
//
const [f, setF] = React.useState<Customer[]>([]);
const [f, setF] = React.useState<Teacher[]>([]);
const [currentPage, setCurrentPage] = React.useState<number>(0);
//
const [recordCount, setRecordCount] = React.useState<number>(0);
const [listOption, setListOption] = React.useState({});
const [listSort, setListSort] = React.useState({});
//
// const sortedCustomers = applySort(SampleCustomers, sortDir);
// const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status });
//
const reloadRows = async (): Promise<void> => {
try {
const models: ListResult<RecordModel> = await pb
.collection(COL_CUSTOMERS)
.collection(COL_TEACHERS)
.getList(currentPage + 1, rowsPerPage, listOption);
const { items, totalItems } = models;
const tempLessonTypes: Customer[] = items.map((lt) => {
return { ...defaultCustomer, ...lt };
const tempLessonTypes: Teacher[] = items.map((lt) => {
return { ...defaultTeacher, ...lt };
});
setLessonCategoriesData(tempLessonTypes);
@@ -174,7 +170,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
loading={isLoadingAddPage}
onClick={(): void => {
setIsLoadingAddPage(true);
router.push(paths.dashboard.customers.create);
router.push(paths.dashboard.teachers.create);
}}
startIcon={<PlusIcon />}
variant="contained"
@@ -183,22 +179,22 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
</LoadingButton>
</Box>
</Stack>
<CustomersSelectionProvider customers={f}>
<TeachersSelectionProvider teachers={f}>
<Card>
<CustomersFilters
<TeachersFilters
filters={{ email, phone, status }}
fullData={lessonCategoriesData}
sortDir={sortDir}
/>
<Divider />
<Box sx={{ overflowX: 'auto' }}>
<CustomersTable
<TeachersTable
reloadRows={reloadRows}
rows={f}
/>
</Box>
<Divider />
<CustomersPagination
<TeachersPagination
count={recordCount}
page={currentPage}
rowsPerPage={rowsPerPage}
@@ -206,7 +202,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
setRowsPerPage={setRowsPerPage}
/>
</Card>
</CustomersSelectionProvider>
</TeachersSelectionProvider>
</Stack>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify(f, null, 2)}</pre>
@@ -217,7 +213,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
// Sorting and filtering has to be done on the server.
function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Customer[] {
function applySort(row: Teacher[], sortDir: 'asc' | 'desc' | undefined): Teacher[] {
return row.sort((a, b) => {
if (sortDir === 'asc') {
return a.createdAt.getTime() - b.createdAt.getTime();
@@ -227,7 +223,7 @@ function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Custom
});
}
function applyFilters(row: Customer[], { email, phone, status }: Filters): Customer[] {
function applyFilters(row: Teacher[], { email, phone, status }: Filters): Teacher[] {
return row.filter((item) => {
if (email) {
if (!item.email?.toLowerCase().includes(email.toLowerCase())) {

View File

@@ -1,9 +1,6 @@
'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';
@@ -11,7 +8,6 @@ import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Note as NoteIcon } from '@phosphor-icons/react/dist/ssr/Note';
import PocketBase from 'pocketbase';
import { useTranslation } from 'react-i18next';
import { logger } from '@/lib/default-logger';

View File

@@ -136,7 +136,7 @@ export const layoutConfig = {
items: [
{ key: 'students', title: 'List students', href: paths.dashboard.students.list },
{ key: 'students:create', title: 'Create student', href: paths.dashboard.students.create },
{ key: 'students:details', title: 'Student details', href: paths.dashboard.students.details('1') },
{ key: 'students:details', title: 'Student details', href: paths.dashboard.students.view('1') },
],
},
// {

View File

@@ -30,7 +30,10 @@ export function HorizontalLayout({ children }: HorizontalLayoutProps): React.JSX
minHeight: '100%',
}}
>
<MainNav color={settings.navColor} items={layoutConfig.navItems} />
<MainNav
color={settings.navColor}
items={layoutConfig.navItems}
/>
<Box
component="main"
sx={{

View File

@@ -40,7 +40,10 @@ export function VerticalLayout({ children }: VerticalLayoutProps): React.JSX.Ele
minHeight: '100%',
}}
>
<SideNav color={settings.navColor} items={layoutConfig.navItems} />
<SideNav
color={settings.navColor}
items={layoutConfig.navItems}
/>
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', pl: { lg: 'var(--SideNav-width)' } }}>
<MainNav items={layoutConfig.navItems} />
<Box

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
'use client';
import * as React from 'react';
import { LoadingButton } from '@mui/lab';
import { Button, Container, Modal, Paper } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Note as NoteIcon } from '@phosphor-icons/react/dist/ssr/Note';
import { useTranslation } from 'react-i18next';
import { logger } from '@/lib/default-logger';
import { toast } from '@/components/core/toaster';
import { deleteStudent } from '@/db/Students/Delete';
export default function ConfirmDeleteModal({
open,
setOpen,
idToDelete,
reloadRows,
}: {
open: boolean;
setOpen: (b: boolean) => void;
idToDelete: string;
reloadRows: () => void;
}): React.JSX.Element {
const { t } = useTranslation();
// const handleClose = () => setOpen(false);
function handleClose(): void {
setOpen(false);
}
const [isDeleteing, setIsDeleteing] = React.useState(false);
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
};
function handleUserConfirmDelete(): void {
if (idToDelete) {
setIsDeleteing(true);
// RULES: delete<CollectionName>
deleteStudent(idToDelete)
.then(() => {
reloadRows();
handleClose();
toast(t('delete.success'));
})
.catch((err) => {
// console.error(err)
logger.error(err);
toast(t('delete.error'));
})
.finally(() => {
setIsDeleteing(false);
});
}
}
return (
<div>
<Modal
open={open}
onClose={handleClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={style}>
<Container maxWidth="sm">
<Paper sx={{ border: '1px solid var(--mui-palette-divider)', boxShadow: 'var(--mui-shadows-16)' }}>
<Stack
direction="row"
spacing={2}
sx={{ display: 'flex', p: 3 }}
>
<Avatar sx={{ bgcolor: 'var(--mui-palette-error-50)', color: 'var(--mui-palette-error-main)' }}>
<NoteIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography variant="h5">{t('Delete Lesson Type ?')}</Typography>
<Typography
color="text.secondary"
variant="body2"
>
{t('Are you sure you want to delete lesson type ?')}
</Typography>
</Stack>
<Stack
direction="row"
spacing={2}
sx={{ justifyContent: 'flex-end' }}
>
<Button
color="secondary"
onClick={handleClose}
>
{t('Cancel')}
</Button>
<LoadingButton
color="error"
variant="contained"
onClick={(e) => {
handleUserConfirmDelete();
}}
loading={isDeleteing}
>
{t('Delete')}
</LoadingButton>
</Stack>
</Stack>
</Stack>
</Paper>
</Container>
</Box>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import OutlinedInput from '@mui/material/OutlinedInput';
import { FilterPopover, useFilterContext } from '@/components/core/filter-button';
// EmailFilterPopover -> email-filter-popover.tsx
export default function EmailFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover
anchorEl={anchorEl}
onClose={onClose}
open={open}
title="Filter by email"
>
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}

View File

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

View File

@@ -0,0 +1,101 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import Chip from '@mui/material/Chip';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple';
import { dayjs } from '@/lib/dayjs';
import { DataTable } from '@/components/core/data-table';
import type { ColumnDef } from '@/components/core/data-table';
import { Option } from '@/components/core/option';
export interface Notification {
id: string;
type: string;
status: 'delivered' | 'pending' | 'failed';
createdAt: Date;
}
const columns = [
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{row.type}
</Typography>
),
name: 'Type',
width: '300px',
},
{
formatter: (row): React.JSX.Element => {
const mapping = {
delivered: { label: 'Delivered', color: 'success' },
pending: { label: 'Pending', color: 'warning' },
failed: { label: 'Failed', color: 'error' },
} as const;
const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' };
return <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Notification>[];
export interface NotificationsProps {
notifications: Notification[];
}
export function Notifications({ notifications }: NotificationsProps): React.JSX.Element {
return (
<Card>
<CardHeader
avatar={
<Avatar>
<EnvelopeSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Notifications"
/>
<CardContent>
<Stack spacing={3}>
<Stack spacing={2}>
<Select defaultValue="last_invoice" name="type" sx={{ maxWidth: '100%', width: '320px' }}>
<Option value="last_invoice">Resend last invoice</Option>
<Option value="password_reset">Send password reset</Option>
<Option value="verification">Send verification</Option>
</Select>
<div>
<Button startIcon={<EnvelopeSimpleIcon />} variant="contained">
Send email
</Button>
</div>
</Stack>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Notification> columns={columns} rows={notifications} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import { ShoppingCartSimple as ShoppingCartSimpleIcon } from '@phosphor-icons/react/dist/ssr/ShoppingCartSimple';
import { dayjs } from '@/lib/dayjs';
import type { ColumnDef } from '@/components/core/data-table';
import { DataTable } from '@/components/core/data-table';
export interface Payment {
currency: string;
amount: number;
invoiceId: string;
status: 'pending' | 'completed' | 'canceled' | 'refunded';
createdAt: Date;
}
const columns = [
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="subtitle2">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)}
</Typography>
),
name: 'Amount',
width: '200px',
},
{
formatter: (row): React.JSX.Element => {
const mapping = {
pending: { label: 'Pending', color: 'warning' },
completed: { label: 'Completed', color: 'success' },
canceled: { label: 'Canceled', color: 'error' },
refunded: { label: 'Refunded', color: 'error' },
} as const;
const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' };
return <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => {
return <Link variant="inherit">{row.invoiceId}</Link>;
},
name: 'Invoice ID',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Payment>[];
export interface PaymentsProps {
ordersValue: number;
payments: Payment[];
refundsValue: number;
totalOrders: number;
}
export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element {
return (
<Card>
<CardHeader
action={
<Button color="secondary" startIcon={<PlusIcon />}>
Create Payment
</Button>
}
avatar={
<Avatar>
<ShoppingCartSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Payments"
/>
<CardContent>
<Stack spacing={3}>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Stack
direction="row"
divider={<Divider flexItem orientation="vertical" />}
spacing={3}
sx={{ justifyContent: 'space-between', p: 2 }}
>
<div>
<Typography color="text.secondary" variant="overline">
Total orders
</Typography>
<Typography variant="h6">{new Intl.NumberFormat('en-US').format(totalOrders)}</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Orders value
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)}
</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Refunds
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)}
</Typography>
</div>
</Stack>
</Card>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Payment> columns={columns} rows={payments} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import OutlinedInput from '@mui/material/OutlinedInput';
import { FilterPopover, useFilterContext } from '@/components/core/filter-button';
// phone-filter-popover.tsx
export default function PhoneFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover
anchorEl={anchorEl}
onClose={onClose}
open={open}
title="Filter by phone number"
>
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
export interface Address {
id: string;
country: string;
state: string;
city: string;
zipCode: string;
street: string;
primary?: boolean;
}
export interface ShippingAddressProps {
address: Address;
}
export function ShippingAddress({ address }: ShippingAddressProps): React.ReactElement {
return (
<Card sx={{ borderRadius: 1, height: '100%' }} variant="outlined">
<CardContent>
<Stack spacing={2}>
<Typography>
{address.street},
<br />
{address.city}, {address.state}, {address.country},
<br />
{address.zipCode}
</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
{address.primary ? <Chip color="warning" label="Primary" variant="soft" /> : <span />}
<Button color="secondary" size="small" startIcon={<PencilSimpleIcon />}>
Edit
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -104,7 +104,7 @@ export function CustomerCreateForm(): React.JSX.Element {
// 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));
router.push(paths.dashboard.students.view(record.id));
} catch (err) {
logger.error(err);
toast.error('Failed to create customer');
@@ -509,7 +509,7 @@ export function CustomerCreateForm(): React.JSX.Element {
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.customers.list}
href={paths.dashboard.students.list}
>
Cancel
</Button>

View File

@@ -85,7 +85,7 @@ const defaultValues = {
avatar: '',
} satisfies Values;
export function CustomerEditForm(): React.JSX.Element {
export function StudentEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
@@ -125,7 +125,7 @@ export function CustomerEditForm(): React.JSX.Element {
try {
await pb.collection(COL_CUSTOMERS).update(customerId, updateData);
toast.success('Customer updated successfully');
router.push(paths.dashboard.customers.list);
router.push(paths.dashboard.students.list);
} catch (error) {
logger.error(error);
toast.error('Failed to update customer');
@@ -581,7 +581,7 @@ export function CustomerEditForm(): React.JSX.Element {
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.customers.list}
href={paths.dashboard.students.list}
>
{t('edit.cancelButton')}
</Button>

View File

@@ -21,7 +21,7 @@ import { paths } from '@/paths';
import { FilterButton } from '@/components/core/filter-button';
import { Option } from '@/components/core/option';
import { useCustomersSelection } from './customers-selection-context';
import { useStudentsSelection } from './students-selection-context';
import GetBlockedCount from '@/db/Customers/GetBlockedCount';
import GetPendingCount from '@/db/Customers/GetPendingCount';
import GetActiveCount from '@/db/Customers/GetActiveCount';
@@ -29,7 +29,7 @@ import PhoneFilterPopover from './phone-filter-popover';
import EmailFilterPopover from './email-filter-popover';
import type { CustomersFiltersProps, Filters, SortDir } from './type.d';
export function CustomersFilters({
export function StudentsFilters({
filters = {},
sortDir = 'desc',
fullData,
@@ -45,7 +45,7 @@ export function CustomersFilters({
const router = useRouter();
const selection = useCustomersSelection();
const selection = useStudentsSelection();
// function getVisible(): number {
// return fullData.reduce((count, item: CrQuestion) => {
@@ -87,7 +87,7 @@ export function CustomersFilters({
searchParams.set('phone', newFilters.phone);
}
router.push(`${paths.dashboard.customers.list}?${searchParams.toString()}`);
router.push(`${paths.dashboard.students.list}?${searchParams.toString()}`);
},
[router]
);

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import TablePagination from '@mui/material/TablePagination';
function noop(): void {
return undefined;
}
interface StudentsPaginationProps {
count: number;
page: number;
//
setPage: (page: number) => void;
setRowsPerPage: (page: number) => void;
rowsPerPage: number;
}
export function StudentsPagination({
count,
page,
//
setPage,
setRowsPerPage,
rowsPerPage,
}: StudentsPaginationProps): React.JSX.Element {
// You should implement the pagination using a similar logic as the filters.
// Note that when page change, you should keep the filter search params.
const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value));
// console.log(parseInt(event.target.value));
};
return (
<TablePagination
component="div"
count={count}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
page={page}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 25]}
//
/>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import * as React from 'react';
import { useSelection } from '@/hooks/use-selection';
import type { Selection } from '@/hooks/use-selection';
import type { Student } from './type.d';
function noop(): void {
return undefined;
}
export interface StudentsSelectionContextValue extends Selection {}
export const StudentsSelectionContext = React.createContext<StudentsSelectionContextValue>({
deselectAll: noop,
deselectOne: noop,
selectAll: noop,
selectOne: noop,
selected: new Set(),
selectedAny: false,
selectedAll: false,
});
interface StudentsSelectionProviderProps {
children: React.ReactNode;
customers: Student[];
}
export function StudentsSelectionProvider({
children,
customers = [],
}: StudentsSelectionProviderProps): React.JSX.Element {
const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]);
const selection = useSelection(customerIds);
return <StudentsSelectionContext.Provider value={{ ...selection }}>{children}</StudentsSelectionContext.Provider>;
}
export function useStudentsSelection(): StudentsSelectionContextValue {
return React.useContext(StudentsSelectionContext);
}

View File

@@ -27,10 +27,10 @@ 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';
import { useStudentsSelection } from './students-selection-context';
import type { Student } from './type.d';
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Customer>[] {
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Student>[] {
return [
{
formatter: (row): React.JSX.Element => (
@@ -44,7 +44,7 @@ function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Custome
<Link
color="inherit"
component={RouterLink}
href={paths.dashboard.customers.details(row.id)}
href={paths.dashboard.students.view(row.id)}
sx={{ whiteSpace: 'nowrap' }}
variant="subtitle2"
>
@@ -143,7 +143,7 @@ function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Custome
//
color="secondary"
component={RouterLink}
href={paths.dashboard.customers.details(row.id)}
href={paths.dashboard.students.view(row.id)}
>
<PencilSimpleIcon size={24} />
</LoadingButton>
@@ -166,14 +166,14 @@ function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Custome
];
}
export interface CustomersTableProps {
rows: Customer[];
export interface StudentsTableProps {
rows: Student[];
reloadRows: () => void;
}
export function CustomersTable({ rows, reloadRows }: CustomersTableProps): React.JSX.Element {
export function StudentsTable({ rows, reloadRows }: StudentsTableProps): React.JSX.Element {
const { t } = useTranslation(['customers']);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection();
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useStudentsSelection();
const [idToDelete, setIdToDelete] = React.useState('');
const [open, setOpen] = React.useState(false);
@@ -191,7 +191,7 @@ export function CustomersTable({ rows, reloadRows }: CustomersTableProps): React
reloadRows={reloadRows}
setOpen={setOpen}
/>
<DataTable<Customer>
<DataTable<Student>
columns={columns(handleDeleteClick)}
onDeselectAll={deselectAll}
onDeselectOne={(_, row) => {

View File

@@ -0,0 +1,69 @@
'use client';
export type SortDir = 'asc' | 'desc';
export interface Student {
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: Student[];
}
export interface Filters {
email?: string;
phone?: string;
status?: string;
}

View File

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

View File

@@ -1,9 +1,6 @@
'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';
@@ -11,12 +8,11 @@ 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';
import { deleteTeacher } from '@/db/Teachers/Delete';
export default function ConfirmDeleteModal({
open,
@@ -49,7 +45,7 @@ export default function ConfirmDeleteModal({
setIsDeleteing(true);
// RULES: delete<CollectionName>
deleteCustomer(idToDelete)
deleteTeacher(idToDelete)
.then(() => {
reloadRows();
handleClose();

View File

@@ -0,0 +1,529 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import Checkbox from '@mui/material/Checkbox';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2';
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
import { Controller, useForm } from 'react-hook-form';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { Option } from '@/components/core/option';
import { toast } from '@/components/core/toaster';
import { createTeacher } from '@/db/Teachers/Create';
import isDevelopment from '@/lib/check-is-development';
function fileToBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = () => {
reject(new Error('Error converting file to base64'));
};
});
}
const schema = zod.object({
avatar: zod.string().optional(),
name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
phone: zod.string().min(1, 'Phone is required').max(15),
company: zod.string().max(255),
billingAddress: zod.object({
country: zod.string().min(1, 'Country is required').max(255),
state: zod.string().min(1, 'State is required').max(255),
city: zod.string().min(1, 'City is required').max(255),
zipCode: zod.string().min(1, 'Zip code is required').max(255),
line1: zod.string().min(1, 'Street line 1 is required').max(255),
line2: zod.string().max(255).optional(),
}),
taxId: zod.string().max(255).optional(),
timezone: zod.string().min(1, 'Timezone is required').max(255),
language: zod.string().min(1, 'Language is required').max(255),
currency: zod.string().min(1, 'Currency is required').max(255),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
avatar: '',
name: 'new name',
email: '123@123.com',
phone: '91234567',
company: '',
billingAddress: {
country: 'US',
state: '00000',
city: 'NY',
zipCode: '00000',
line1: 'test line 1',
line2: 'test line 2',
},
taxId: '12345',
timezone: 'new_york',
language: 'en',
currency: 'USD',
} satisfies Values;
export function TeacherCreateForm(): React.JSX.Element {
const router = useRouter();
const {
control,
handleSubmit,
formState: { errors },
setValue,
watch,
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
try {
// Use standard create method from db/Customers/Create
const record = await createTeacher(values);
toast.success('Customer created');
router.push(paths.dashboard.teachers.details(record.id));
} catch (err) {
logger.error(err);
toast.error('Failed to create customer');
}
},
[router]
);
const avatarInputRef = React.useRef<HTMLInputElement>(null);
const avatar = watch('avatar');
const handleAvatarChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = await fileToBase64(file);
setValue('avatar', url);
}
},
[setValue]
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack
divider={<Divider />}
spacing={4}
>
<Stack spacing={3}>
<Typography variant="h6">Account information</Typography>
<Grid
container
spacing={3}
>
<Grid xs={12}>
<Stack
direction="row"
spacing={3}
sx={{ alignItems: 'center' }}
>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '50%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
src={avatar}
sx={{
'--Avatar-size': '100px',
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
alignItems: 'center',
bgcolor: 'var(--mui-palette-background-level1)',
color: 'var(--mui-palette-text-primary)',
display: 'flex',
justifyContent: 'center',
}}
>
<CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Box>
<Stack
spacing={1}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1">Avatar</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography>
<Button
color="secondary"
onClick={() => {
avatarInputRef.current?.click();
}}
variant="outlined"
>
Select
</Button>
<input
hidden
onChange={handleAvatarChange}
ref={avatarInputRef}
type="file"
/>
</Stack>
</Stack>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="name"
render={({ field }) => (
<FormControl
error={Boolean(errors.name)}
fullWidth
>
<InputLabel required>Name</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="email"
render={({ field }) => (
<FormControl
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email address</InputLabel>
<OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone number</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="company"
render={({ field }) => (
<FormControl
error={Boolean(errors.company)}
fullWidth
>
<InputLabel>Company</InputLabel>
<OutlinedInput {...field} />
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Billing information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.country"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.country)}
fullWidth
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<Option value="">Choose a country</Option>
<Option value="us">United States</Option>
<Option value="de">Germany</Option>
<Option value="es">Spain</Option>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress?.country?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.state"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.state)}
fullWidth
>
<InputLabel required>State</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.state ? (
<FormHelperText>{errors.billingAddress?.state?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.city"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.city)}
fullWidth
>
<InputLabel required>City</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.city ? (
<FormHelperText>{errors.billingAddress?.city?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.zipCode"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip code</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress?.zipCode?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.line1"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress?.line1?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl
error={Boolean(errors.taxId)}
fullWidth
>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput
{...field}
placeholder="e.g EU372054390"
/>
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Shipping information</Typography>
<FormControlLabel
control={<Checkbox defaultChecked />}
label="Same as billing address"
/>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="timezone"
render={({ field }) => (
<FormControl
error={Boolean(errors.timezone)}
fullWidth
>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<Option value="">Select a timezone</Option>
<Option value="new_york">US - New York</Option>
<Option value="california">US - California</Option>
<Option value="london">UK - London</Option>
</Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="language"
render={({ field }) => (
<FormControl
error={Boolean(errors.language)}
fullWidth
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<Option value="">Select a language</Option>
<Option value="en">English</Option>
<Option value="es">Spanish</Option>
<Option value="de">German</Option>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="currency"
render={({ field }) => (
<FormControl
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel>Currency</InputLabel>
<Select {...field}>
<Option value="">Select a currency</Option>
<Option value="USD">USD</Option>
<Option value="EUR">EUR</Option>
<Option value="RON">RON</Option>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.teachers.list}
>
Cancel
</Button>
<Button
type="submit"
variant="contained"
>
Create teacher
</Button>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form>
);
}

View File

@@ -0,0 +1,604 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_TEACHERS } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2';
//
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
//
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import isDevelopment from '@/lib/check-is-development';
// TODO: review this
const schema = zod.object({
name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
phone: zod.string().min(1, 'Phone is required').max(25),
company: zod.string().max(255).optional(),
billingAddress: zod.object({
country: zod.string().min(1, 'Country is required').max(255),
state: zod.string().min(1, 'State is required').max(255),
city: zod.string().min(1, 'City is required').max(255),
zipCode: zod.string().min(1, 'Zip code is required').max(255),
line1: zod.string().min(1, 'Street line 1 is required').max(255),
line2: zod.string().max(255).optional(),
}),
taxId: zod.string().max(255).optional(),
timezone: zod.string().min(1, 'Timezone is required').max(255),
language: zod.string().min(1, 'Language is required').max(255),
currency: zod.string().min(1, 'Currency is required').max(255),
avatar: zod.string().optional(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
name: '',
email: '',
phone: '',
company: '',
billingAddress: {
country: '',
state: '',
city: '',
zipCode: '',
line1: '',
line2: '',
},
taxId: '',
timezone: '',
language: '',
currency: '',
avatar: '',
} satisfies Values;
export function TeacherEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
const { customerId } = useParams<{ customerId: string }>();
//
const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const {
control,
handleSubmit,
formState: { errors },
setValue,
reset,
watch,
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
setIsUpdating(true);
const updateData = {
name: values.name,
email: values.email,
phone: values.phone,
company: values.company,
billingAddress: values.billingAddress,
taxId: values.taxId,
timezone: values.timezone,
language: values.language,
currency: values.currency,
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
};
try {
await pb.collection(COL_TEACHERS).update(customerId, updateData);
toast.success('Customer updated successfully');
router.push(paths.dashboard.teachers.list);
} catch (error) {
logger.error(error);
toast.error('Failed to update customer');
} finally {
setIsUpdating(false);
}
},
[customerId, router]
);
const avatarInputRef = React.useRef<HTMLInputElement>(null);
const avatar = watch('avatar');
const handleAvatarChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = await fileToBase64(file);
setValue('avatar', url);
}
},
[setValue]
);
// TODO: need to align with save form
// use trycatch
const [textDescription, setTextDescription] = React.useState<string>('');
const [textRemarks, setTextRemarks] = React.useState<string>('');
// load existing data when user arrive
const loadExistingData = React.useCallback(
async (id: string) => {
setShowLoading(true);
try {
const result = await pb.collection(COL_TEACHERS).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 <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack
divider={<Divider />}
spacing={4}
>
<Stack spacing={3}>
<Typography variant="h6">{t('edit.basic-info')}</Typography>
<Grid
container
spacing={3}
>
<Grid xs={12}>
<Stack
direction="row"
spacing={3}
sx={{ alignItems: 'center' }}
>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '5%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
variant="rounded"
src={avatar}
sx={{
'--Avatar-size': '100px',
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
alignItems: 'center',
bgcolor: 'var(--mui-palette-background-level1)',
color: 'var(--mui-palette-text-primary)',
display: 'flex',
justifyContent: 'center',
}}
>
<CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Box>
<Stack
spacing={1}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1">{t('edit.avatar')}</Typography>
<Typography variant="caption">{t('edit.avatarRequirements')}</Typography>
<Button
color="secondary"
onClick={() => {
avatarInputRef.current?.click();
}}
variant="outlined"
>
{t('edit.avatar_select')}
</Button>
<input
hidden
onChange={handleAvatarChange}
ref={avatarInputRef}
type="file"
/>
</Stack>
</Stack>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="name"
render={({ field }) => (
<FormControl
error={Boolean(errors.name)}
fullWidth
>
<InputLabel required>Name</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="email"
render={({ field }) => (
<FormControl
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email</InputLabel>
<OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="company"
render={({ field }) => (
<FormControl
error={Boolean(errors.company)}
fullWidth
>
<InputLabel>Company</InputLabel>
<OutlinedInput
{...field}
placeholder="no company name"
/>
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">Billing Information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.country"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.country)}
fullWidth
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress.country.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.state"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.state)}
fullWidth
>
<InputLabel required>State</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.state ? (
<FormHelperText>{errors.billingAddress.state.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.city"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.city)}
fullWidth
>
<InputLabel required>City</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.city ? (
<FormHelperText>{errors.billingAddress.city.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.zipCode"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip Code</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.line1"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address Line 1</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress.line1.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl
error={Boolean(errors.taxId)}
fullWidth
>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput
{...field}
placeholder="no tax id..."
/>
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional Information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="timezone"
render={({ field }) => (
<FormControl
error={Boolean(errors.timezone)}
fullWidth
>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<MenuItem value="America/New_York">New York</MenuItem>
<MenuItem value="Europe/London">London</MenuItem>
<MenuItem value="Asia/Tokyo">Tokyo</MenuItem>
<MenuItem value="America/Boa_Vista">Boa Vista</MenuItem>
<MenuItem value="America/Grand_Turk">Grand Turk</MenuItem>
<MenuItem value="Asia/Manila">Manila</MenuItem>
<MenuItem value="Asia/Urumqi">Urumqi</MenuItem>
<MenuItem value="Africa/Tunis">Tunis</MenuItem>
</Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="language"
render={({ field }) => (
<FormControl
error={Boolean(errors.language)}
fullWidth
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="currency"
render={({ field }) => (
<FormControl
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel required>Currency</InputLabel>
<Select {...field}>
<MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.teachers.list}
>
{t('edit.cancelButton')}
</Button>
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit"
variant="contained"
>
{t('edit.updateButton')}
</LoadingButton>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form>
);
}

View File

@@ -0,0 +1,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 { 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 { CustomersFiltersProps, Filters, SortDir } from './type.d';
export function TeachersFilters({
filters = {},
sortDir = 'desc',
fullData,
}: CustomersFiltersProps): React.JSX.Element {
const { t } = useTranslation();
const { email, phone, status } = filters;
const [totalCount, setTotalCount] = React.useState<number>(0);
const [activeCount, setActiveCount] = React.useState<number>(0);
const [pendingCount, setPendingCount] = React.useState<number>(0);
const [blockedCount, setBlockedCount] = React.useState<number>(0);
const router = useRouter();
const selection = useTeachersSelection();
// function getVisible(): number {
// return fullData.reduce((count, item: CrQuestion) => {
// return item.visible === 'visible' ? count + 1 : count;
// }, 0);
// }
// function getHidden(): number {
// return fullData.reduce((count, item: CrQuestion) => {
// return item.visible === 'hidden' ? count + 1 : count;
// }, 0);
// }
// The tabs should be generated using API data.
const tabs = [
{ label: 'All', value: '', count: totalCount },
{ label: 'Active', value: 'active', count: activeCount },
{ label: 'Pending', value: 'pending', count: pendingCount },
{ label: 'Blocked', value: 'blocked', count: blockedCount },
] as const;
const updateSearchParams = React.useCallback(
(newFilters: Filters, newSortDir: SortDir): void => {
const searchParams = new URLSearchParams();
if (newSortDir === 'asc') {
searchParams.set('sortDir', newSortDir);
}
if (newFilters.status) {
searchParams.set('status', newFilters.status);
}
if (newFilters.email) {
searchParams.set('email', newFilters.email);
}
if (newFilters.phone) {
searchParams.set('phone', newFilters.phone);
}
router.push(`${paths.dashboard.teachers.list}?${searchParams.toString()}`);
},
[router]
);
const handleClearFilters = React.useCallback(() => {
updateSearchParams({}, sortDir);
}, [updateSearchParams, sortDir]);
const handleStatusChange = React.useCallback(
(_: React.SyntheticEvent, value: string) => {
updateSearchParams({ ...filters, status: value }, sortDir);
},
[updateSearchParams, filters, sortDir]
);
const handleEmailChange = React.useCallback(
(value?: string) => {
updateSearchParams({ ...filters, email: value }, sortDir);
},
[updateSearchParams, filters, sortDir]
);
const handlePhoneChange = React.useCallback(
(value?: string) => {
updateSearchParams({ ...filters, phone: value }, sortDir);
},
[updateSearchParams, filters, sortDir]
);
const handleSortChange = React.useCallback(
(event: SelectChangeEvent) => {
updateSearchParams(filters, event.target.value as SortDir);
},
[updateSearchParams, filters]
);
const hasFilters = status || email || phone;
React.useEffect(() => {
const fetchCount = async (): Promise<void> => {
try {
const tc = await 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 (
<div>
<Tabs
onChange={handleStatusChange}
sx={{ px: 3 }}
value={status ?? ''}
variant="scrollable"
//
>
{tabs.map((tab) => (
<Tab
icon={
<Chip
label={tab.count}
size="small"
variant="soft"
/>
}
iconPosition="end"
key={tab.value}
label={tab.label}
sx={{ minHeight: 'auto' }}
tabIndex={0}
value={tab.value}
/>
))}
</Tabs>
<Divider />
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flexWrap: 'wrap', px: 3, py: 2 }}
>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto', flexWrap: 'wrap' }}
>
<FilterButton
displayValue={email}
label="Email"
onFilterApply={(value) => {
handleEmailChange(value as string);
}}
onFilterDelete={() => {
handleEmailChange();
}}
popover={<EmailFilterPopover />}
value={email}
/>
<FilterButton
displayValue={phone}
label="Phone number"
onFilterApply={(value) => {
handlePhoneChange(value as string);
}}
onFilterDelete={() => {
handlePhoneChange();
}}
popover={<PhoneFilterPopover />}
value={phone}
/>
{hasFilters ? <Button onClick={handleClearFilters}>Clear filters</Button> : null}
</Stack>
{selection.selectedAny ? (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center' }}
>
<Typography
color="text.secondary"
variant="body2"
>
{selection.selected.size} selected
</Typography>
<Button
color="error"
variant="contained"
>
Delete
</Button>
</Stack>
) : null}
<Select
name="sort"
onChange={handleSortChange}
sx={{ maxWidth: '100%', width: '120px' }}
value={sortDir}
>
<Option value="desc">Newest</Option>
<Option value="asc">Oldest</Option>
</Select>
</Stack>
</div>
);
}

View File

@@ -16,7 +16,7 @@ interface CustomersPaginationProps {
rowsPerPage: number;
}
export function CustomersPagination({
export function TeachersPagination({
count,
page,
//

View File

@@ -5,7 +5,7 @@ import * as React from 'react';
import { useSelection } from '@/hooks/use-selection';
import type { Selection } from '@/hooks/use-selection';
import type { Customer } from './type.d';
import type { Teacher } from './type.d';
function noop(): void {
return undefined;
@@ -25,19 +25,19 @@ export const CustomersSelectionContext = React.createContext<CustomersSelectionC
interface CustomersSelectionProviderProps {
children: React.ReactNode;
customers: Customer[];
teachers: Teacher[];
}
export function CustomersSelectionProvider({
export function TeachersSelectionProvider({
children,
customers = [],
teachers = [],
}: CustomersSelectionProviderProps): React.JSX.Element {
const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]);
const customerIds = React.useMemo(() => teachers.map((customer) => customer.id), [teachers]);
const selection = useSelection(customerIds);
return <CustomersSelectionContext.Provider value={{ ...selection }}>{children}</CustomersSelectionContext.Provider>;
}
export function useCustomersSelection(): CustomersSelectionContextValue {
export function useTeachersSelection(): CustomersSelectionContextValue {
return React.useContext(CustomersSelectionContext);
}

View File

@@ -0,0 +1,220 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { LoadingButton } from '@mui/lab';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock';
import { Images as ImagesIcon } from '@phosphor-icons/react/dist/ssr/Images';
import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus';
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs';
import { DataTable } from '@/components/core/data-table';
import type { ColumnDef } from '@/components/core/data-table';
import ConfirmDeleteModal from './confirm-delete-modal';
import { useTeachersSelection } from './teachers-selection-context';
import type { Teacher } from './type.d';
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Teacher>[] {
return [
{
formatter: (row): React.JSX.Element => (
<Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center' }}
>
<Avatar src={row.avatar} />{' '}
<div>
<Link
color="inherit"
component={RouterLink}
href={paths.dashboard.teachers.details(row.id)}
sx={{ whiteSpace: 'nowrap' }}
variant="subtitle2"
>
{row.name}
</Link>
<Typography
color="text.secondary"
variant="body2"
>
{row.email}
</Typography>
</div>
</Stack>
),
name: 'Name',
width: '250px',
},
{
formatter: (row): React.JSX.Element => (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center' }}
>
<LinearProgress
sx={{ flex: '1 1 auto' }}
value={row.quota}
variant="determinate"
/>
<Typography
color="text.secondary"
variant="body2"
>
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)}
</Typography>
</Stack>
),
name: 'Quota',
width: '150px',
},
{ field: 'phone', name: 'Phone number', width: '150px' },
{
formatter: (row): React.JSX.Element => {
const mapping = {
active: {
label: 'Active',
icon: (
<CheckCircleIcon
color="var(--mui-palette-success-main)"
weight="fill"
/>
),
},
blocked: { label: 'Blocked', icon: <MinusIcon color="var(--mui-palette-error-main)" /> },
pending: {
label: 'Pending',
icon: (
<ClockIcon
color="var(--mui-palette-warning-main)"
weight="fill"
/>
),
},
} as const;
const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null };
return (
<Chip
icon={icon}
label={label}
size="small"
variant="outlined"
/>
);
},
name: 'Status',
width: '150px',
},
{
formatter(row) {
return dayjs(row.createdAt).format('MMM D, YYYY');
},
name: 'Created at',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<Stack
direction="row"
spacing={1}
>
<LoadingButton
//
color="secondary"
component={RouterLink}
href={paths.dashboard.teachers.details(row.id)}
>
<PencilSimpleIcon size={24} />
</LoadingButton>
<LoadingButton
color="error"
// TODO: originally it is row.isEmpty
disabled={false}
onClick={() => {
handleDeleteClick(row.id);
}}
>
<TrashSimpleIcon size={24} />
</LoadingButton>
</Stack>
),
name: 'Actions',
hideName: true,
align: 'right',
},
];
}
export interface CustomersTableProps {
rows: Teacher[];
reloadRows: () => void;
}
export function TeachersTable({ rows, reloadRows }: CustomersTableProps): React.JSX.Element {
const { t } = useTranslation(['customers']);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useTeachersSelection();
const [idToDelete, setIdToDelete] = React.useState('');
const [open, setOpen] = React.useState(false);
function handleDeleteClick(testId: string): void {
setOpen(true);
setIdToDelete(testId);
}
return (
<React.Fragment>
<ConfirmDeleteModal
idToDelete={idToDelete}
open={open}
reloadRows={reloadRows}
setOpen={setOpen}
/>
<DataTable<Teacher>
columns={columns(handleDeleteClick)}
onDeselectAll={deselectAll}
onDeselectOne={(_, row) => {
deselectOne(row.id);
}}
onSelectAll={selectAll}
onSelectOne={(_, row) => {
selectOne(row.id);
}}
rows={rows}
selectable
selected={selected}
/>
{!rows.length ? (
<Box sx={{ p: 3 }}>
<Typography
color="text.secondary"
sx={{ textAlign: 'center' }}
variant="body2"
>
{/* TODO: update this */}
{t('no-teachers-found')}
</Typography>
</Box>
) : null}
</React.Fragment>
);
}

View File

@@ -2,7 +2,7 @@
export type SortDir = 'asc' | 'desc';
export interface Customer {
export interface Teacher {
id: string;
name: string;
avatar?: string;
@@ -60,7 +60,7 @@ export interface EditFormProps {
export interface CustomersFiltersProps {
filters?: Filters;
sortDir?: SortDir;
fullData: Customer[];
fullData: Teacher[];
}
export interface Filters {
email?: string;

View File

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

View File

@@ -0,0 +1,6 @@
import { pb } from '@/lib/pb';
import { COL_STUDENTS } from '@/constants';
export async function deleteStudent(id: string): Promise<boolean> {
return pb.collection(COL_STUDENTS).delete(id);
}

View File

@@ -0,0 +1,9 @@
import { COL_STUDENTS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetActiveCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_STUDENTS).getList(1, 1, {
filter: 'status = "active"',
});
return count;
}

View File

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

View File

@@ -0,0 +1,7 @@
import { pb } from '@/lib/pb';
import { COL_STUDENTS } from '@/constants';
export async function getAllStudentsCount(): Promise<number> {
const result = await pb.collection(COL_STUDENTS).getList(1, 1);
return result.totalItems;
}

View File

@@ -0,0 +1,9 @@
import { COL_STUDENTS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetBlockedCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_STUDENTS).getList(1, 1, {
filter: 'status = "blocked"',
});
return count;
}

View File

@@ -0,0 +1,7 @@
import { pb } from '@/lib/pb';
import { COL_STUDENTS } from '@/constants';
import { RecordModel } from 'pocketbase';
export async function getStudentById(id: string): Promise<RecordModel> {
return pb.collection(COL_STUDENTS).getOne(id);
}

View File

@@ -0,0 +1,9 @@
import { COL_STUDENTS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetPendingCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_STUDENTS).getList(1, 1, {
filter: 'status = "pending"',
});
return count;
}

View File

@@ -0,0 +1,3 @@
export function helloCustomer() {
return 'Hello from Customers module!';
}

View File

@@ -0,0 +1,8 @@
import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import type { EditFormProps } from '@/components/dashboard/customer/type.d';
export async function updateCustomer(id: string, data: Partial<EditFormProps>): Promise<RecordModel> {
return pb.collection(COL_CUSTOMERS).update(id, data);
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { pb } from '@/lib/pb';
import { COL_TEACHERS } from '@/constants';
export async function deleteTeacher(id: string): Promise<boolean> {
return pb.collection(COL_TEACHERS).delete(id);
}

View File

@@ -0,0 +1,9 @@
import { COL_TEACHERS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetActiveCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_TEACHERS).getList(1, 1, {
filter: 'status = "active"',
});
return count;
}

View File

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

View File

@@ -0,0 +1,7 @@
import { pb } from '@/lib/pb';
import { COL_TEACHERS } from '@/constants';
export async function getAllTeachersCount(): Promise<number> {
const result = await pb.collection(COL_TEACHERS).getList(1, 1);
return result.totalItems;
}

View File

@@ -0,0 +1,9 @@
import { COL_TEACHERS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetBlockedCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_TEACHERS).getList(1, 1, {
filter: 'status = "blocked"',
});
return count;
}

View File

@@ -0,0 +1,7 @@
import { pb } from '@/lib/pb';
import { COL_TEACHERS } from '@/constants';
import { RecordModel } from 'pocketbase';
export async function getTeacherById(id: string): Promise<RecordModel> {
return pb.collection(COL_TEACHERS).getOne(id);
}

View File

@@ -0,0 +1,9 @@
import { COL_CUSTOMERS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetPendingCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_CUSTOMERS).getList(1, 1, {
filter: 'status = "pending"',
});
return count;
}

View File

@@ -0,0 +1,3 @@
export function helloCustomer() {
return 'Hello from Customers module!';
}

View File

@@ -0,0 +1,8 @@
import { pb } from '@/lib/pb';
import { COL_TEACHERS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import type { EditFormProps } from '@/components/dashboard/teacher/type.d';
export async function updateTeacher(id: string, data: Partial<EditFormProps>): Promise<RecordModel> {
return pb.collection(COL_TEACHERS).update(id, data);
}

View File

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

View File

@@ -639,6 +639,58 @@
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "json2115670734",
"maxSize": 0,
"name": "billingAddress",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text922858135",
"max": 0,
"min": 0,
"name": "timezone",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3571151285",
"max": 0,
"min": 0,
"name": "language",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1767278655",
"max": 0,
"min": 0,
"name": "currency",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
@@ -2170,6 +2222,396 @@
"indexes": [],
"system": false
},
{
"id": "pbc_491894781",
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"name": "Students",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3885137012",
"max": 0,
"min": 0,
"name": "email",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1146066909",
"max": 0,
"min": 0,
"name": "phone",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number1813778413",
"max": null,
"min": null,
"name": "quota",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2063623452",
"max": 0,
"min": 0,
"name": "status",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "file507207115",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "avatar_file",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "relation2809058197",
"maxSelect": 1,
"minSelect": 0,
"name": "user_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "json2115670734",
"maxSize": 0,
"name": "billingAddress",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text922858135",
"max": 0,
"min": 0,
"name": "timezone",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3571151285",
"max": 0,
"min": 0,
"name": "language",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1767278655",
"max": 0,
"min": 0,
"name": "currency",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [],
"system": false
},
{
"id": "pbc_1413424569",
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"name": "Teachers",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3885137012",
"max": 0,
"min": 0,
"name": "email",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1146066909",
"max": 0,
"min": 0,
"name": "phone",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number1813778413",
"max": null,
"min": null,
"name": "quota",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2063623452",
"max": 0,
"min": 0,
"name": "status",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "file507207115",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "avatar_file",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "relation2809058197",
"maxSelect": 1,
"minSelect": 0,
"name": "user_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "json2115670734",
"maxSize": 0,
"name": "billingAddress",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text922858135",
"max": 0,
"min": 0,
"name": "timezone",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3571151285",
"max": 0,
"min": 0,
"name": "language",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1767278655",
"max": 0,
"min": 0,
"name": "currency",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [],
"system": false
},
{
"id": "pbc_1305841361",
"listRule": "",

View File

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