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