change some files

This commit is contained in:
2026-06-02 17:08:52 +03:30
parent b8dc1d0e1b
commit cfb48c5bb0
76 changed files with 5204 additions and 2555 deletions

View 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;

View 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>
);
}

View 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, "">[] = [
"زیر دیپلم",
"دیپلم",
"کاردانی",
"کارشناسی",
"کارشناسی ارشد",
"دکتری",
"حوزوی",
"سایر",
];

View 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
// }

View 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, "حداقل یک سابقه تحصیلی باید وارد شود"),
});