change some files
This commit is contained in:
61
ui/forms/education/EducationForm.tsx
Normal file
61
ui/forms/education/EducationForm.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { withFormik, type FormikBag } from "formik";
|
||||
import { EDUCATION_EMPTY_VALUES } from "./constants";
|
||||
import InnerEducationForm from "./InnerEducationForm";
|
||||
|
||||
export interface EducationItem {
|
||||
degree: string;
|
||||
field: string;
|
||||
university: string;
|
||||
startYear: number | "";
|
||||
endYear: number | "";
|
||||
gpa: number | "";
|
||||
certificateImageId: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface EducationFormValues {
|
||||
education: EducationItem[];
|
||||
}
|
||||
|
||||
/** این بخش را با WizardFormData واقعی پروژهات هماهنگ کن */
|
||||
export interface WizardFormData {
|
||||
education: EducationItem[];
|
||||
// ... other steps
|
||||
}
|
||||
|
||||
export type EducationFormProps = {
|
||||
step: number;
|
||||
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||
data: WizardFormData;
|
||||
update: (patch: Partial<WizardFormData>) => void;
|
||||
};
|
||||
|
||||
const EducationForm = withFormik<EducationFormProps, EducationFormValues>({
|
||||
displayName: "EducationForm",
|
||||
enableReinitialize: true,
|
||||
|
||||
mapPropsToValues: (props) => {
|
||||
return {
|
||||
education: props.data?.education ?? EDUCATION_EMPTY_VALUES,
|
||||
};
|
||||
},
|
||||
|
||||
// validationSchema: EducationValidationSchema,
|
||||
|
||||
handleSubmit: async (
|
||||
values,
|
||||
bag: FormikBag<EducationFormProps, EducationFormValues>,
|
||||
) => {
|
||||
const { props, setSubmitting } = bag;
|
||||
|
||||
props.update({ education: values.education });
|
||||
props.setStep((prev) => prev + 1);
|
||||
|
||||
setSubmitting(false);
|
||||
},
|
||||
})(InnerEducationForm);
|
||||
|
||||
export default EducationForm;
|
||||
445
ui/forms/education/InnerEducationForm.tsx
Normal file
445
ui/forms/education/InnerEducationForm.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { UploadFile, Add, DeleteOutlineOutlined } from "@mui/icons-material";
|
||||
import { FieldArray, Form, type FormikProps, getIn } from "formik";
|
||||
import { DEGREE_OPTIONS } from "./constants";
|
||||
import { EducationFormProps } from "./EducationForm";
|
||||
|
||||
export interface EducationItem {
|
||||
degree: string;
|
||||
field: string;
|
||||
university: string;
|
||||
startYear: number | "";
|
||||
endYear: number | "";
|
||||
gpa: number | "";
|
||||
certificateImageId: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface EducationFormValues {
|
||||
education: EducationItem[];
|
||||
}
|
||||
|
||||
const emptyEducationItem: EducationItem = {
|
||||
degree: "",
|
||||
field: "",
|
||||
university: "",
|
||||
startYear: "",
|
||||
endYear: "",
|
||||
gpa: "",
|
||||
certificateImageId: "",
|
||||
description: "",
|
||||
};
|
||||
|
||||
type Props = FormikProps<EducationFormValues> & EducationFormProps;
|
||||
|
||||
export default function InnerEducationForm(props: Props) {
|
||||
const { values, errors, touched, handleChange, setFieldValue } = props;
|
||||
|
||||
const [uploadError, setUploadError] = useState<Record<number, string>>({});
|
||||
const [uploading, setUploading] = useState<Record<number, boolean>>({});
|
||||
const [uploadProgress, setUploadProgress] = useState<Record<number, number>>(
|
||||
{},
|
||||
);
|
||||
const [selectedFile, setSelectedFile] = useState<Record<number, File | null>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const abortControllerRef = useRef<Record<number, AbortController | null>>({});
|
||||
|
||||
const validateImageFile = (file: File) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return "فقط فایل تصویری مجاز است";
|
||||
}
|
||||
|
||||
const maxSize = 500 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
return "حجم عکس باید حداکثر ۵۰۰ کیلوبایت باشد";
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
index: number,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const error = validateImageFile(file);
|
||||
if (error) {
|
||||
setSelectedFile((prev) => ({ ...prev, [index]: null }));
|
||||
setUploadError((prev) => ({ ...prev, [index]: error }));
|
||||
setFieldValue(`education.${index}.certificateImageId`, "");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile((prev) => ({ ...prev, [index]: file }));
|
||||
setUploadError((prev) => ({ ...prev, [index]: "" }));
|
||||
setUploadProgress((prev) => ({ ...prev, [index]: 0 }));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current[index] = controller;
|
||||
|
||||
try {
|
||||
setUploading((prev) => ({ ...prev, [index]: true }));
|
||||
|
||||
const response = await axios.post("/api/cdn/upload", formData, {
|
||||
signal: controller.signal,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const total = progressEvent.total ?? 0;
|
||||
if (!total) return;
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[index]: Math.round((progressEvent.loaded * 100) / total),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const uploadedUrl = response?.data?.url ?? "";
|
||||
if (!uploadedUrl) {
|
||||
setUploadError((prev) => ({
|
||||
...prev,
|
||||
[index]: "آپلود انجام شد اما آدرس فایل دریافت نشد",
|
||||
}));
|
||||
setFieldValue(`education.${index}.certificateImageId`, "");
|
||||
return;
|
||||
}
|
||||
|
||||
setFieldValue(`education.${index}.certificateImageId`, uploadedUrl);
|
||||
} catch (error: any) {
|
||||
if (
|
||||
axios.isCancel?.(error) ||
|
||||
error?.name === "CanceledError" ||
|
||||
error?.code === "ERR_CANCELED"
|
||||
) {
|
||||
setUploadError((prev) => ({
|
||||
...prev,
|
||||
[index]: "آپلود توسط کاربر لغو شد",
|
||||
}));
|
||||
} else {
|
||||
setUploadError((prev) => ({
|
||||
...prev,
|
||||
[index]: "خطا در بارگذاری فایل",
|
||||
}));
|
||||
}
|
||||
setFieldValue(`education.${index}.certificateImageId`, "");
|
||||
} finally {
|
||||
setUploading((prev) => ({ ...prev, [index]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelUpload = (index: number) => {
|
||||
abortControllerRef.current[index]?.abort();
|
||||
setUploading((prev) => ({ ...prev, [index]: false }));
|
||||
setUploadProgress((prev) => ({ ...prev, [index]: 0 }));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
props?.update({ education: props.values.education });
|
||||
props.setStep(props?.step - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<FieldArray name="education">
|
||||
{({ push, remove }) => (
|
||||
<>
|
||||
{values?.education?.map((item, index) => {
|
||||
const itemErrors = getIn(errors, `education.${index}`) || {};
|
||||
const itemTouched = getIn(touched, `education.${index}`) || {};
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
mb: 4,
|
||||
p: 3,
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: "16px",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography >
|
||||
سابقه تحصیلی {index + 1}
|
||||
</Typography>
|
||||
|
||||
{values.education.length > 1 && (
|
||||
<IconButton color="error" onClick={() => remove(index)}>
|
||||
<DeleteOutlineOutlined />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
select
|
||||
label="مقطع تحصیلی"
|
||||
name={`education.${index}.degree`}
|
||||
value={item.degree}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
error={!!itemTouched.degree && !!itemErrors.degree}
|
||||
helperText={itemTouched.degree ? itemErrors.degree : ""}
|
||||
>
|
||||
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||
{DEGREE_OPTIONS.map((d) => (
|
||||
<MenuItem key={d} value={d}>
|
||||
{d}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="رشته تحصیلی"
|
||||
name={`education.${index}.field`}
|
||||
value={item.field}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
error={!!itemTouched.field && !!itemErrors.field}
|
||||
helperText={itemTouched.field ? itemErrors.field : ""}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="دانشگاه / موسسه"
|
||||
name={`education.${index}.university`}
|
||||
value={item.university}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
error={!!itemTouched.university && !!itemErrors.university}
|
||||
helperText={
|
||||
itemTouched.university ? itemErrors.university : ""
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="سال شروع"
|
||||
type="number"
|
||||
value={item.startYear}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
`education.${index}.startYear`,
|
||||
e.target.value === "" ? "" : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
fullWidth
|
||||
error={!!itemTouched.startYear && !!itemErrors.startYear}
|
||||
helperText={
|
||||
itemTouched.startYear ? itemErrors.startYear : " "
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="سال پایان"
|
||||
type="number"
|
||||
value={item.endYear}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
`education.${index}.endYear`,
|
||||
e.target.value === "" ? "" : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
fullWidth
|
||||
error={!!itemTouched.endYear && !!itemErrors.endYear}
|
||||
helperText={itemTouched.endYear ? itemErrors.endYear : " "}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="معدل (از ۲۰)"
|
||||
type="number"
|
||||
value={item.gpa}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
`education.${index}.gpa`,
|
||||
e.target.value === "" ? "" : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
fullWidth
|
||||
error={!!itemTouched.gpa && !!itemErrors.gpa}
|
||||
helperText={itemTouched.gpa ? itemErrors.gpa : " "}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
border:
|
||||
uploadError[index] ||
|
||||
(itemTouched.certificateImageId &&
|
||||
itemErrors.certificateImageId)
|
||||
? "1px solid #ef4444"
|
||||
: "1px dashed #cbd5e1",
|
||||
borderRadius: "18px",
|
||||
backgroundColor: "#f8fafc",
|
||||
p: 2,
|
||||
minHeight: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
تصویر مدرک تحصیلی
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
component="label"
|
||||
variant="outlined"
|
||||
startIcon={<UploadFile />}
|
||||
disabled={!!uploading[index]}
|
||||
>
|
||||
انتخاب عکس
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleFileChange(e, index)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{uploading[index] && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => handleCancelUpload(index)}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
لغو آپلود
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectedFile[index] && (
|
||||
<Typography sx={{ mt: 1 }}>
|
||||
فایل انتخابشده: {selectedFile[index]?.name}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{uploading[index] && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={uploadProgress[index] || 0}
|
||||
/>
|
||||
<Typography sx={{ mt: 1 }}>
|
||||
{uploadProgress[index] || 0}% در حال بارگذاری...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!uploading[index] && item.certificateImageId && (
|
||||
<Typography sx={{ mt: 1.5, color: "green" }}>
|
||||
فایل با موفقیت بارگذاری شد
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{uploadError[index] && (
|
||||
<Typography sx={{ mt: 1.5, color: "red" }}>
|
||||
{uploadError[index]}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||
<TextField
|
||||
label="توضیحات"
|
||||
name={`education.${index}.description`}
|
||||
value={item.description}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
error={
|
||||
!!itemTouched.description && !!itemErrors.description
|
||||
}
|
||||
helperText={
|
||||
itemTouched.description ? itemErrors.description : ""
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{index < values.education.length - 1 && (
|
||||
<Divider sx={{ mt: 3 }} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
startIcon={<Add />}
|
||||
onClick={() => push(emptyEducationItem)}
|
||||
sx={{ borderRadius: "12px", mb: 3 }}
|
||||
>
|
||||
افزودن سابقه تحصیلی
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
mt: 2,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
disabled={props.step === 1}
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
sx={{
|
||||
borderRadius: "12px",
|
||||
color: "#64748b",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
بازگشت
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{
|
||||
borderRadius: "12px",
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
bgcolor: `${props.step === 12 ? "green" : "#2563eb"}`,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{props.step === 12 ? "اتمام و ثبت نهايي" : "گام بعدی"}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</FieldArray>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
31
ui/forms/education/constants/index.ts
Normal file
31
ui/forms/education/constants/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// education.constants.ts
|
||||
|
||||
import { EducationFormValues } from "../InnerEducationForm";
|
||||
import { DegreeValue} from "../types";
|
||||
|
||||
|
||||
export const EDUCATION_EMPTY_VALUES: EducationFormValues = {
|
||||
education: [
|
||||
{
|
||||
degree: "",
|
||||
field: "",
|
||||
university: "",
|
||||
startYear: "",
|
||||
endYear: "",
|
||||
gpa: "",
|
||||
certificateImageId: "",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const DEGREE_OPTIONS: Exclude<DegreeValue, "">[] = [
|
||||
"زیر دیپلم",
|
||||
"دیپلم",
|
||||
"کاردانی",
|
||||
"کارشناسی",
|
||||
"کارشناسی ارشد",
|
||||
"دکتری",
|
||||
"حوزوی",
|
||||
"سایر",
|
||||
];
|
||||
37
ui/forms/education/types/index.ts
Normal file
37
ui/forms/education/types/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// education.types.ts
|
||||
// export const EMPTY: EducationFormValues = {
|
||||
// degree: "",
|
||||
// field: "",
|
||||
// university: "",
|
||||
// startYear: "",
|
||||
// endYear: "",
|
||||
// gpa: "",
|
||||
// description: "",
|
||||
// certificateImageId: "",
|
||||
// };
|
||||
export type DegreeValue =
|
||||
| ""
|
||||
| "زیر دیپلم"
|
||||
| "دیپلم"
|
||||
| "کاردانی"
|
||||
| "کارشناسی"
|
||||
| "کارشناسی ارشد"
|
||||
| "دکتری"
|
||||
| "حوزوی"
|
||||
| "سایر";
|
||||
|
||||
// export interface EducationFormValues {
|
||||
// // applicantId: string;
|
||||
|
||||
// degree: DegreeValue;
|
||||
// field: string;
|
||||
// university: string;
|
||||
|
||||
// startYear: number | "";
|
||||
// endYear: number | "";
|
||||
|
||||
// gpa: number | "";
|
||||
|
||||
// description: string;
|
||||
// certificateImageId: string; // id returned from upload api
|
||||
// }
|
||||
19
ui/forms/education/validation/index.ts
Normal file
19
ui/forms/education/validation/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// EducationForm.validation.ts
|
||||
import * as yup from "yup";
|
||||
|
||||
export const educationItemSchema = yup.object({
|
||||
degree: yup.string().required("مقطع تحصیلی الزامی است"),
|
||||
field: yup.string().required("رشته تحصیلی الزامی است"),
|
||||
university: yup.string().required("دانشگاه / موسسه الزامی است"),
|
||||
startYear: yup.number().required("سال شروع الزامی است"),
|
||||
endYear: yup.number().required("سال پایان الزامی است"),
|
||||
gpa: yup.number().min(0).max(20).required("معدل الزامی است"),
|
||||
certificateImageId: yup.string().required("تصویر مدرک الزامی است"),
|
||||
description: yup.string(),
|
||||
});
|
||||
|
||||
export const educationValidationSchema = yup.object({
|
||||
education: yup.array()
|
||||
.of(educationItemSchema)
|
||||
.min(1, "حداقل یک سابقه تحصیلی باید وارد شود"),
|
||||
});
|
||||
Reference in New Issue
Block a user