446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|