This commit is contained in:
2026-05-31 18:00:43 +03:30
parent 98af7d639b
commit b241d12ff5
20 changed files with 939 additions and 797 deletions

View File

@@ -10,13 +10,10 @@ import {
useMediaQuery,
} from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CenterRegistrationForm from "./forms/register-center/RegistrationCenterForm";
import IdentityForm from "./forms/IdentityForm";
import IdentityForm from "./forms/identity/IdentityForm";
import PersonalInfoForm from "./forms/PersonalInfoForm";
import PhysicalInfoForm from "./forms/PhysicalInfoForm";
import EducationForm from "./forms/EducationForm";
import EducationSection from "./forms/EducationSection";
import JobRequestForm from "./forms/JobRequestForm";
import JobRequestSection from "./forms/JobRequestSection";
import CourseSection from "./forms/CourseSection";
import SkillsForm from "./forms/SkillsForm";
@@ -24,6 +21,8 @@ import { WorkExperienceSection } from "./forms/WorkExperienceSection";
import JobInfoForm from "./forms/JobInfoForm";
import { ReferralSection } from "./forms/ReferralForm";
import RelationsForm from "./forms/RelationForm";
import RegistrationCenterForm from "./forms/register-center/RegistrationCenterForm";
import { INITIAL_WIZARD_DATA, WizardFormData } from "@/core/types";
// کامپوننت پیش‌فرض برای مراحلی که هنوز نساختید
const PlaceholderStep = ({ step }: any) => (
@@ -34,8 +33,8 @@ const PlaceholderStep = ({ step }: any) => (
// --- ۲. نگاشت (Mapping) مراحل به کامپوننت‌ها ---
const STEP_COMPONENTS: Record<number, React.FC<any>> = {
1: CenterRegistrationForm,
const STEP_COMPONENTS: Record<number, React.ComponentType<any>> = {
1: RegistrationCenterForm,
2: IdentityForm,
3: PersonalInfoForm,
4: PhysicalInfoForm,
@@ -70,26 +69,15 @@ const STEP_LABELS = [
export default function MultiStepForm() {
const [activeStep, setActiveStep] = useState(1);
const [maxStepReached, setMaxStepReached] = useState(1);
const [formData, setFormData] = useState({
name: "",
address: "",
isUrgent: false,
});
const [formData, setFormData] = useState<WizardFormData>(INITIAL_WIZARD_DATA);
const updateFormData = (patch: Partial<WizardFormData>) => {
setFormData((prev) => ({ ...prev, ...patch }));
};
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const updateFormData = (newData: Partial<typeof formData>) => {
setFormData((prev) => ({ ...prev, ...newData }));
};
const handleNext = () => {
if (activeStep < 12) {
setActiveStep((prev) => prev + 1);
if (activeStep + 1 > maxStepReached) setMaxStepReached(activeStep + 1);
}
};
const ActiveStepComponent = STEP_COMPONENTS[activeStep] || PlaceholderStep;
return (
@@ -160,7 +148,7 @@ export default function MultiStepForm() {
{i + 1 < activeStep ? (
<CheckCircleIcon sx={{ fontSize: 18 }} color="success" />
) : (
i + 1
Number(i + 1).toLocaleString("fa-IR")
)}
</Box>
<Typography
@@ -206,40 +194,12 @@ export default function MultiStepForm() {
{/* رندر شدن داینامیک کامپوننت مرحله فعلی */}
<div className="w-full">
<ActiveStepComponent
data={formData}
update={updateFormData}
data={formData} // کل دیتای فرم
update={updateFormData} // تابع آپدیت‌کننده
step={activeStep}
setStep={setActiveStep}
/>
</div>
<Box
sx={{ display: "flex", justifyContent: "space-between", mt: 5 }}
>
<Button
disabled={activeStep === 1}
onClick={() => setActiveStep((prev) => prev - 1)}
sx={{
borderRadius: "12px",
color: "#64748b",
fontWeight: 700,
}}
>
بازگشت
</Button>
<Button
variant="contained"
onClick={handleNext}
sx={{
borderRadius: "12px",
px: 4,
py: 1.5,
bgcolor: `${activeStep === 12 ? "green" : "#2563eb"}`,
fontWeight: 700,
}}
>
{activeStep === 12 ? "اتمام و ثبت نهايي" : "گام بعدی"}
</Button>
</Box>
</Paper>
</div>
</div>

View File

@@ -1,405 +0,0 @@
"use client";
import React, { useMemo, useState } from "react";
import {
Avatar,
Box,
Button,
MenuItem,
Paper,
TextField,
Typography,
} from "@mui/material";
import { UploadFile } from "@mui/icons-material";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDateFnsJalali } from "@mui/x-date-pickers/AdapterDateFnsJalali";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
type IdentityFormData = {
applicantId: string;
firstName: string;
lastName: string;
fatherName: string;
nationalCode: string;
birthDate: string;
birthPlace: string;
gender: string;
religion: string;
nationality: string;
profilePhotoId: string;
};
type IdentityFormErrors = Partial<Record<keyof IdentityFormData, string>>;
const initialForm: IdentityFormData = {
applicantId: "",
firstName: "",
lastName: "",
fatherName: "",
nationalCode: "",
birthDate: "",
birthPlace: "",
gender: "",
religion: "",
nationality: "",
profilePhotoId: "",
};
export default function IdentityForm() {
const [formData, setFormData] = useState<IdentityFormData>(initialForm);
const [errors, setErrors] = useState<IdentityFormErrors>({});
const [submitted, setSubmitted] = useState(false);
const [profilePhoto, setProfilePhoto] = useState<File | null>(null);
const [profilePhotoPreview, setProfilePhotoPreview] = useState<string>("");
const [profilePhotoError, setProfilePhotoError] = useState<string>("");
const [birthDateValue, setBirthDateValue] = useState<Date | null>(null);
const handleProfilePhotoChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setProfilePhoto(null);
setProfilePhotoPreview("");
setProfilePhotoError("فقط فایل تصویری مجاز است");
return;
}
const maxSize = 500 * 1024; // 500KB
if (file.size > maxSize) {
setProfilePhoto(null);
setProfilePhotoPreview("");
setProfilePhotoError("حجم عکس باید حداکثر ۵۰۰ کیلوبایت باشد");
return;
}
setProfilePhoto(file);
setProfilePhotoError("");
const previewUrl = URL.createObjectURL(file);
setProfilePhotoPreview(previewUrl);
};
const genderOptions = useMemo(() => ["مرد", "زن", "سایر"], []);
const religionOptions = useMemo(
() => ["اسلام", "مسیحیت", "یهودیت", "زرتشتی", "سایر"],
[],
);
const handleChange =
(field: keyof IdentityFormData) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
let value = event.target.value;
if (field === "nationalCode") {
value = value.replace(/\D/g, "").slice(0, 10);
}
setFormData((prev) => ({
...prev,
[field]: value,
}));
if (errors[field]) {
setErrors((prev) => ({
...prev,
[field]: "",
}));
}
};
const validate = () => {
const newErrors: IdentityFormErrors = {};
let hasError = false;
if (!formData.applicantId.trim()) {
newErrors.applicantId = "شناسه متقاضی الزامی است";
hasError = true;
}
if (!formData.firstName.trim()) {
newErrors.firstName = "نام الزامی است";
hasError = true;
}
if (!formData.lastName.trim()) {
newErrors.lastName = "نام خانوادگی الزامی است";
hasError = true;
}
if (!formData.nationalCode.trim()) {
newErrors.nationalCode = "کد ملی الزامی است";
hasError = true;
} else if (!/^\d{10}$/.test(formData.nationalCode)) {
newErrors.nationalCode = "کد ملی باید ۱۰ رقم باشد";
hasError = true;
}
if (!formData.birthDate.trim()) {
newErrors.birthDate = "تاریخ تولد الزامی است";
hasError = true;
}
if (!formData.gender.trim()) {
newErrors.gender = "جنسیت الزامی است";
hasError = true;
}
if (!formData.nationality.trim()) {
newErrors.nationality = "ملیت الزامی است";
hasError = true;
}
if (!profilePhoto) {
setProfilePhotoError("عکس پرسنلی الزامی است");
hasError = true;
} else {
setProfilePhotoError("");
}
setErrors(newErrors);
return !hasError;
};
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
setSubmitted(false);
if (!validate()) return;
const payload = {
...formData,
birthDate: formData.birthDate ? new Date(formData.birthDate) : null,
fatherName: formData.fatherName || null,
birthPlace: formData.birthPlace || null,
religion: formData.religion || null,
profilePhotoId: formData.profilePhotoId || null,
};
console.log("Identity Payload:", payload);
setSubmitted(true);
};
return (
<Box>
<Paper
elevation={0}
sx={{
width: "100%",
background: "#ffffff",
}}
>
<Box component="form" onSubmit={handleSubmit}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))",
gap: "18px",
}}
>
<TextField
label="نام"
value={formData.firstName}
onChange={handleChange("firstName")}
error={!!errors.firstName}
helperText={errors.firstName}
fullWidth
/>
<TextField
label="نام خانوادگی"
value={formData.lastName}
onChange={handleChange("lastName")}
error={!!errors.lastName}
helperText={errors.lastName}
fullWidth
/>
<TextField
label="نام پدر"
value={formData.fatherName}
onChange={handleChange("fatherName")}
fullWidth
/>
<TextField
label="کد ملی"
value={formData.nationalCode}
onChange={handleChange("nationalCode")}
error={!!errors.nationalCode}
helperText={errors.nationalCode}
fullWidth
/>
<DatePicker
label="تاریخ تولد"
value={birthDateValue}
onChange={(newValue) => {
setBirthDateValue(newValue);
setFormData((prev) => ({
...prev,
birthDate: newValue ? newValue.toISOString() : "",
}));
if (errors.birthDate) {
setErrors((prev) => ({
...prev,
birthDate: "",
}));
}
}}
slotProps={{
textField: {
fullWidth: true,
error: !!errors.birthDate,
helperText: errors.birthDate,
},
}}
/>
<TextField
label="محل تولد"
value={formData.birthPlace}
onChange={handleChange("birthPlace")}
fullWidth
/>
<TextField
select
label="جنسیت"
value={formData.gender}
onChange={handleChange("gender")}
error={!!errors.gender}
helperText={errors.gender}
fullWidth
>
{genderOptions.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
<TextField
select
label="دین"
value={formData.religion}
onChange={handleChange("religion")}
fullWidth
>
{religionOptions.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
<TextField
label="ملیت"
value={formData.nationality}
onChange={handleChange("nationality")}
error={!!errors.nationality}
helperText={errors.nationality}
fullWidth
/>
<Box
sx={{
width: "100%",
border: profilePhotoError
? "1px solid #ef4444"
: "1px dashed #cbd5e1",
borderRadius: "18px",
backgroundColor: "#f8fafc",
p: 2,
minHeight: "100%",
transition: "all 0.2s ease",
"&:hover": {
borderColor: profilePhotoError ? "#ef4444" : "#2563eb",
backgroundColor: "#f8fbff",
},
}}
>
<Typography
sx={{
fontWeight: 700,
color: "#0f172a",
mb: 1.5,
fontSize: "0.95rem",
}}
>
عکس پرسنلی
</Typography>
<Typography
sx={{
color: "#64748b",
fontSize: "0.82rem",
mb: 2,
lineHeight: 1.8,
}}
>
فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت باشد.
</Typography>
<Button
component="label"
variant="outlined"
startIcon={<UploadFile />}
sx={{
borderRadius: "12px",
borderColor: "#cbd5e1",
color: "#2563eb",
fontWeight: 700,
px: 2.5,
"&:hover": {
borderColor: "#2563eb",
backgroundColor: "#eff6ff",
},
}}
>
انتخاب عکس
<input
hidden
type="file"
accept="image/*"
onChange={handleProfilePhotoChange}
/>
</Button>
{profilePhoto && (
<Typography
sx={{
mt: 1.5,
fontSize: "0.82rem",
color: "#475569",
wordBreak: "break-word",
}}
>
فایل انتخابشده: {profilePhoto.name}
</Typography>
)}
{profilePhotoError && (
<Typography
sx={{
mt: 1.5,
color: "#dc2626",
fontSize: "0.8rem",
fontWeight: 600,
}}
>
{profilePhotoError}
</Typography>
)}
</Box>
</div>
</Box>
</Paper>
</Box>
);
}

View File

@@ -1,27 +1,26 @@
import React, { useState } from "react";
import {
Box,
Paper,
TextField,
Typography,
Button,
Container,
Stack,
} from "@mui/material";
import { Box, TextField, Typography, Button, Container } from "@mui/material";
import { useApplicantLogin } from "@/hooks/auth.hook";
import { toast } from "sonner";
import { handleAxiosError } from "@/core/utils";
import { useRouter } from "next/navigation";
export default function LoginLayout() {
const [nationalId, setNationalId] = useState("");
const router = useRouter();
const { mutateAsync, isPending } = useApplicantLogin();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const { message } = await mutateAsync(nationalId);
toast.success(message);
await mutateAsync(nationalId);
if (isPending) {
toast.loading("در حال انتقال به فرم استخدامي");
}
router.push("/form");
} catch (error) {
toast.error("خطا رخ داده است");
console.log(error);
toast.error(handleAxiosError(error));
}
};
@@ -60,6 +59,7 @@ export default function LoginLayout() {
fullWidth
variant="contained"
size="large"
loading={isPending}
sx={{ py: 1.5, borderRadius: 2, fontSize: "1rem" }}
>
ورود به سامانه

View File

@@ -0,0 +1,82 @@
import { withFormik } from "formik";
import InnerIdentityForm from "./InnerIdentityForm";
import * as yup from "yup";
export interface IdentityFormValues {
firstName: string;
lastName: string;
birthDate: string;
birthPlace: string;
fatherName: string;
gender: string;
nationalCode: string;
nationality: string;
profilePhotoId: string;
religion: string;
}
export interface WizardFormData {
identity: IdentityFormValues;
}
export interface IdentityFormProps {
step: number;
setStep: React.Dispatch<React.SetStateAction<number>>;
data: WizardFormData;
update: (newData: Partial<WizardFormData>) => void;
}
const IdentityFormValidationSchema = yup.object({
firstName: yup.string().trim().required("نام الزامی است").min(2).max(50),
lastName: yup.string().trim().required("نام خانوادگی الزامی است").min(2).max(50),
birthDate: yup
.string()
.required("تاریخ تولد الزامی است")
.matches(/^\d{4}\/\d{2}\/\d{2}$/, "فرمت تاریخ تولد باید به شکل ۱۴۰۳/۰۱/۲۰ باشد"),
birthPlace: yup.string().trim().required("محل تولد الزامی است").min(2).max(80),
fatherName: yup.string().trim().required("نام پدر الزامی است").min(2).max(50),
gender: yup
.string()
.required("جنسیت الزامی است")
.oneOf(["male", "female", "other"], "جنسیت معتبر نیست"),
nationalCode: yup
.string()
.required("کد ملی الزامی است")
.matches(/^\d{10}$/, "کد ملی باید ۱۰ رقم باشد"),
nationality: yup.string().trim().required("تابعیت الزامی است").min(2).max(50),
profilePhotoId: yup.string().trim().required("عکس پرسنلی الزامی است"),
religion: yup.string().trim().required("دین الزامی است").min(2).max(50),
});
const EMPTY_IDENTITY_VALUES: IdentityFormValues = {
firstName: "",
lastName: "",
birthDate: "",
birthPlace: "",
fatherName: "",
gender: "",
nationalCode: "",
nationality: "",
profilePhotoId: "",
religion: "",
};
const IdentityForm = withFormik<IdentityFormProps, IdentityFormValues>({
enableReinitialize: true,
mapPropsToValues: (props) => {
return props.data?.identity || EMPTY_IDENTITY_VALUES;
},
validationSchema: IdentityFormValidationSchema,
handleSubmit: (values, { props }) => {
props.update({
identity: values,
});
props.setStep((prev) => prev + 1);
},
})(InnerIdentityForm);
export default IdentityForm;

View File

@@ -0,0 +1,310 @@
import {
Box,
Button,
MenuItem,
Paper,
TextField,
Typography,
} from "@mui/material";
import { ErrorMessage, Form, FormikProps } from "formik";
import { IdentityFormValues } from "@/core/types";
import { IdentityFormProps } from "./IdentityForm";
import { UploadFile } from "@mui/icons-material";
import { genderOptions, religionOptions } from "@/core/constant";
import { useState } from "react";
export default function InnerIdentityForm(
props: FormikProps<IdentityFormValues> & IdentityFormProps,
) {
console.log(props.data)
const handleBack = () => {
// قبل از رفتن به عقب، مقادیر فعلی فرم را در استیت والد ذخیره کن
props.update({ identity: props.values });
props.setStep(props.step - 1);
};
const [profilePhoto, setProfilePhoto] = useState<File | null>(null);
const [profilePhotoError, setProfilePhotoError] = useState<string>("");
const handleProfilePhotoChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setProfilePhoto(null);
setProfilePhotoError("فقط فایل تصویری مجاز است");
return;
}
const maxSize = 500 * 1024; // 500KB
if (file.size > maxSize) {
setProfilePhoto(null);
setProfilePhotoError("حجم عکس باید حداکثر ۵۰۰ کیلوبایت باشد");
return;
}
setProfilePhoto(file);
setProfilePhotoError("");
};
return (
<Box>
<Paper
elevation={0}
sx={{
width: "100%",
background: "#ffffff",
}}
>
<Form>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))",
gap: "18px",
}}
>
<div>
<TextField
label="نام"
value={props.values.firstName}
onChange={(e) =>
props.setFieldValue("firstName", e.target.value)
}
error={!!props.errors.firstName}
helperText={props.errors.firstName}
fullWidth
required
/>
<ErrorMessage component={"div"} name="firstName" />
</div>
<TextField
label="نام خانوادگی"
value={props.values.lastName}
onChange={(e) => props.setFieldValue("lastName", e.target.value)}
error={!!props.errors.lastName}
helperText={props.errors.lastName}
fullWidth
required
/>
<TextField
label="نام پدر"
value={props.values.fatherName}
onChange={(e) =>
props.setFieldValue("fatherName", e.target.value)
}
fullWidth
/>
<TextField
label="کد ملی"
value={props.values.nationalCode}
onChange={(e) =>
props.setFieldValue("nationalCode", e.target.value)
}
error={!!props.errors.nationalCode}
helperText={props.errors.nationalCode}
fullWidth
required
/>
{/* <DatePicker
label="تاریخ تولد"
value={props.values.birthDate}
onChange={(newValue) =>
props.setFieldValue("birthDate", newValue)
}
slotProps={{
textField: {
fullWidth: true,
error: !!props.errors.birthDate,
helperText: props.errors.birthDate,
},
}}
/> */}
<TextField
label="محل تولد"
value={props.values.birthPlace}
onChange={(e) =>
props.setFieldValue("birthPlace", e.target.value)
}
fullWidth
/>
<TextField
select
label="جنسیت"
value={props.values.gender}
onChange={(e) => props.setFieldValue("gender", e.target.value)}
error={!!props.errors.gender}
helperText={props.errors.gender}
fullWidth
required
>
{genderOptions.map((item) => (
<MenuItem key={item.id} value={item.value}>
{item.label}
</MenuItem>
))}
</TextField>
<TextField
select
label="دین"
value={props.values.religion}
onChange={(e) => props.setFieldValue("religion", e.target.value)}
fullWidth
>
{religionOptions.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
<TextField
label="ملیت"
value={props.values.nationality}
onChange={(e) =>
props.setFieldValue("nationality", e.target.value)
}
error={!!props.errors.nationality}
helperText={props.errors.nationality}
fullWidth
required
/>
<Box
sx={{
width: "100%",
border: profilePhotoError
? "1px solid #ef4444"
: "1px dashed #cbd5e1",
borderRadius: "18px",
backgroundColor: "#f8fafc",
p: 2,
minHeight: "100%",
transition: "all 0.2s ease",
"&:hover": {
borderColor: profilePhotoError ? "#ef4444" : "#2563eb",
backgroundColor: "#f8fbff",
},
}}
>
<Typography
sx={{
fontWeight: 700,
color: "#0f172a",
mb: 1.5,
fontSize: "0.95rem",
}}
>
عکس پرسنلی
</Typography>
<Typography
sx={{
color: "#64748b",
fontSize: "0.82rem",
mb: 2,
lineHeight: 1.8,
}}
>
فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت باشد.
</Typography>
<Button
component="label"
variant="outlined"
startIcon={<UploadFile />}
sx={{
borderRadius: "12px",
borderColor: "#cbd5e1",
color: "#2563eb",
fontWeight: 700,
px: 2.5,
"&:hover": {
borderColor: "#2563eb",
backgroundColor: "#eff6ff",
},
}}
>
انتخاب عکس
<input
hidden
type="file"
accept="image/*"
onChange={handleProfilePhotoChange}
/>
</Button>
{profilePhoto && (
<Typography
sx={{
mt: 1.5,
fontSize: "0.82rem",
color: "#475569",
wordBreak: "break-word",
}}
>
فایل انتخابشده: {profilePhoto.name}
</Typography>
)}
{profilePhotoError && (
<Typography
sx={{
mt: 1.5,
color: "#dc2626",
fontSize: "0.8rem",
fontWeight: 600,
}}
>
{profilePhotoError}
</Typography>
)}
</Box>
</div>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
mt: 5,
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>
</Form>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import {
Box,
Typography,
useTheme,
useMediaQuery,
Chip,
Button,
FormHelperText,
} from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { Form, FormikProps } from "formik";
import BusinessIcon from "@mui/icons-material/Business";
import LocationOnIcon from "@mui/icons-material/LocationOn";
import LocalHospitalIcon from "@mui/icons-material/LocalHospital";
import { useGetAllCenters } from "@/hooks/center.hook";
import {
RegistrationCenterFormProps,
RegistrationCenterFormValues,
} from "./RegistrationCenterForm";
import { CenterItem } from "@/core/types";
// تعریف اینترفیس برای تمیزی بیشتر
interface InnerFormProps
extends
FormikProps<RegistrationCenterFormValues>,
RegistrationCenterFormProps {}
export default function InnerRegistrationCenterForm(props: InnerFormProps) {
const { data } = useGetAllCenters();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const handleBack = () => {
props.update({ registrationCenter: props.values.selectedCenter});
props.setStep((prev) => Math.max(1, prev - 1));
};
// منطق نمایش خطا
const isSelectedCenterError =
(props.touched.selectedCenter || props.submitCount > 0) &&
!!props.errors.selectedCenter;
const handleCenterSelect = (center: CenterItem) => {
props.setFieldValue("selectedCenter", center);
props.setFieldTouched("selectedCenter", true, true); // فعال کردن حالت لمس شده
};
const handleNext = async () => {
// اگر مرحله اول است، فیلد را لمس کن تا اگر خالی بود خطا نشان دهد
if (props.step === 1) {
props.setFieldTouched("selectedCenter", true, true);
}
// اعتبارسنجی دستی کل فرم
const errors = await props.validateForm();
// اگر در گام فعلی خطایی وجود ندارد، برو مرحله بعد
if (Object.keys(errors).length === 0) {
if (props.step === 12) {
props.submitForm(); // ثبت نهایی
} else {
props.setStep(props.step + 1);
}
}
};
const renderCenterList = () => (
<Box sx={{ width: "100%", gridColumn: "1 / -1" }}>
<div className="w-full grid grid-cols-2 gap-4">
{data?.data.map((center: CenterItem) => {
const isSelected = props.values.selectedCenter?.id === center.id;
return (
<div className="col-span-1" key={center.id}>
<Box
onClick={() => handleCenterSelect(center)}
sx={{
p: 2.5,
borderRadius: "18px",
border: isSelected
? "2px solid #2563eb"
: isSelectedCenterError
? "2px solid #d32f2f"
: "1px solid #e2e8f0",
backgroundColor: isSelected ? "#eff6ff" : "#fff",
cursor: "pointer",
transition: "all 0.25s ease",
"&:hover": { borderColor: "#2563eb" },
}}
>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 2 }}>
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 1,
}}
>
<BusinessIcon sx={{ color: "#2563eb", fontSize: 22 }} />
<Typography sx={{ fontWeight: 800, color: "#0f172a" }}>
{center.name}
</Typography>
</Box>
<Typography sx={{ color: "#64748b", fontSize: "0.92rem" }}>
{center.address}
</Typography>
</Box>
{isSelected && <CheckCircleIcon sx={{ color: "#2563eb" }} />}
</Box>
</Box>
</div>
);
})}
</div>
{/* نمایش پیام خطا به صورت تمیز زیر لیست */}
{isSelectedCenterError && (
<FormHelperText
error
sx={{ mt: 2, fontSize: "0.9rem", fontWeight: 600 }}
>
{props.errors.selectedCenter as string}
</FormHelperText>
)}
</Box>
);
return (
<Form>
<div style={{ width: "100%", flexGrow: 1 }}>
{/* رندر محتوای استپ ها */}
{props.step === 1 ? (
renderCenterList()
) : (
<Typography>محتوای مرحله {props.step}</Typography>
)}
</div>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 5 }}>
<Button
disabled={props.step === 1}
type="button"
onClick={handleBack}
sx={{ borderRadius: "12px", color: "#64748b", fontWeight: 700 }}
>
بازگشت
</Button>
<Button
variant="contained"
type="button" // به جای submit از button استفاده کردیم تا با تابع خودمان چک شود
onClick={handleNext}
sx={{
borderRadius: "12px",
px: 4,
py: 1.5,
bgcolor: props.step === 12 ? "green" : "#2563eb",
fontWeight: 700,
}}
>
{props.step === 12 ? "اتمام و ثبت نهايي" : "گام بعدی"}
</Button>
</Box>
</Form>
);
}

View File

@@ -1,327 +1,56 @@
"use client";
import React, { useState } from "react";
import {
Box,
Button,
Typography,
Paper,
Container,
useTheme,
useMediaQuery,
Chip,
} from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import BusinessIcon from "@mui/icons-material/Business";
import LocationOnIcon from "@mui/icons-material/LocationOn";
import LocalHospitalIcon from "@mui/icons-material/LocalHospital";
import { withFormik } from "formik";
import * as yup from "yup";
import InnerRegistrationCenterForm from "./InnerRegistrationCenterForm";
import { CenterItem, IdentityFormValues } from "@/core/types";
const TOTAL_STEPS = 12;
const STEP_LABELS = [
"انتخاب مرکز",
"موقعیت و آدرس",
"وضعیت فوریت",
"توضیحات تکمیلی",
"ساعات کاری",
"تصاویر مرکز",
"تجهیزات موجود",
"پرسنل",
"بیمه‌های طرف قرارداد",
"مجوزها",
"شرایط پذیرش",
"بررسی نهایی",
];
export interface RegistrationCenterFormValues {
selectedCenter: CenterItem | null;
}
type CenterItem = {
id: string;
name: string;
address: string;
isUrgent: boolean;
export interface WizardFormData {
registrationCenter: {
selectedCenter: CenterItem | null;
};
identity: IdentityFormValues;
}
export interface RegistrationCenterFormProps {
step: number;
setStep: React.Dispatch<React.SetStateAction<number>>;
data: WizardFormData;
update: (patch: Partial<WizardFormData>) => void;
}
const EMPTY_VALUES: RegistrationCenterFormValues = {
selectedCenter: null,
};
const centersMock: CenterItem[] = [
{
id: "1",
name: "مرکز درمانی امید",
address: "تهران، خیابان ولیعصر، بالاتر از پارک ساعی، پلاک ۱۲۳",
isUrgent: true,
const RegistrationCenterFormValidationSchema = yup.object({
selectedCenter: yup
.mixed<CenterItem | null>()
.nullable()
.required("لطفاً یک مرکز را انتخاب کنید"),
});
const RegistrationCenterForm = withFormik<
RegistrationCenterFormProps,
RegistrationCenterFormValues
>({
enableReinitialize: true,
mapPropsToValues: (props) => ({
selectedCenter: props.data.registrationCenter.selectedCenter ?? null,
}),
validationSchema: RegistrationCenterFormValidationSchema,
handleSubmit: (values, { props }) => {
props.update({
registrationCenter: values,
});
props.setStep((prev) => prev + 1);
},
{
id: "2",
name: "کلینیک تخصصی مهر",
address: "مشهد، بلوار وکیل‌آباد، بین وکیل‌آباد ۲۱ و ۲۳",
isUrgent: false,
},
{
id: "3",
name: "بیمارستان شبانه‌روزی آتیه",
address: "اصفهان، خیابان شریعتی، کوچه ۸، ساختمان آتیه",
isUrgent: true,
},
{
id: "4",
name: "مرکز سلامت نوین",
address: "شیراز، میدان مطهری، خیابان معدل، نبش کوچه ۶",
isUrgent: false,
},
];
})(InnerRegistrationCenterForm);
export default function CenterRegistrationForm() {
const [activeStep, setActiveStep] = useState(1);
const [maxStepReached, setMaxStepReached] = useState(1);
const [selectedCenterId, setSelectedCenterId] = useState<string | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const selectedCenter =
centersMock.find((center) => center.id === selectedCenterId) || null;
const handleNext = () => {
if (activeStep < TOTAL_STEPS) {
const nextStep = activeStep + 1;
setActiveStep(nextStep);
if (nextStep > maxStepReached) setMaxStepReached(nextStep);
}
};
const handleBack = () => {
if (activeStep > 1) setActiveStep((prev) => prev - 1);
};
const goToStep = (step: number) => {
if (step <= maxStepReached) setActiveStep(step);
};
const renderCenterList = () => {
return (
<>
<>
{centersMock.map((center) => {
const isSelected = selectedCenterId === center.id;
return (
<div className="col-span-1" key={center.id}>
<Box
onClick={() => setSelectedCenterId(center.id)}
sx={{
p: 2.5,
borderRadius: "18px",
border: isSelected
? "2px solid #2563eb"
: "1px solid #e2e8f0",
backgroundColor: isSelected ? "#eff6ff" : "#fff",
cursor: "pointer",
transition: "all 0.25s ease",
boxShadow: isSelected
? "0 10px 25px -15px rgba(37,99,235,0.45)"
: "0 4px 12px rgba(15,23,42,0.04)",
"&:hover": {
transform: "translateY(-2px)",
borderColor: "#2563eb",
boxShadow: "0 12px 24px -16px rgba(37,99,235,0.35)",
},
}}
>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 1,
}}
>
<BusinessIcon sx={{ color: "#2563eb", fontSize: 22 }} />
<Typography
sx={{
fontWeight: 800,
color: "#0f172a",
fontSize: "1rem",
}}
>
{center.name}
</Typography>
</Box>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
gap: 1,
}}
>
<LocationOnIcon
sx={{ color: "#94a3b8", fontSize: 18, mt: "2px" }}
/>
<Typography
sx={{
color: "#64748b",
fontSize: "0.92rem",
lineHeight: 1.9,
}}
>
{center.address}
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Chip
icon={<LocalHospitalIcon />}
label={
center.isUrgent
? "استخدام فوری دارد"
: "استخدام فوری ندارد"
}
sx={{
fontWeight: 700,
backgroundColor: center.isUrgent
? "#fee2e2"
: "#e2e8f0",
color: center.isUrgent ? "#b91c1c" : "#475569",
"& .MuiChip-icon": {
color: center.isUrgent ? "#dc2626" : "#64748b",
},
}}
/>
{isSelected && (
<CheckCircleIcon
sx={{ color: "#2563eb", fontSize: 24 }}
/>
)}
</Box>
</Box>
</Box>
</div>
);
})}
</>
</>
);
};
const renderStepContent = (step: number) => {
switch (step) {
case 1:
return renderCenterList();
case 2:
return selectedCenter ? (
<Box sx={{ width: "100%" }}>
<Typography sx={{ fontWeight: 800, color: "#0f172a", mb: 2 }}>
مرکز انتخابشده
</Typography>
<Box
sx={{
p: 3,
borderRadius: "20px",
backgroundColor: "#fff",
border: "1px solid #e2e8f0",
}}
>
<Typography
sx={{
fontWeight: 800,
fontSize: "1.1rem",
color: "#2563eb",
mb: 1,
}}
>
{selectedCenter.name}
</Typography>
<Typography sx={{ color: "#64748b", mb: 2 }}>
{selectedCenter.address}
</Typography>
<Chip
label={
selectedCenter.isUrgent
? "استخدام فوری دارد"
: "استخدام فوری ندارد"
}
sx={{
fontWeight: 700,
backgroundColor: selectedCenter.isUrgent
? "#dbeafe"
: "#e2e8f0",
color: selectedCenter.isUrgent ? "#1d4ed8" : "#475569",
}}
/>
</Box>
</Box>
) : (
<Typography className="text-center text-[#94a3b8]">
ابتدا از مرحله قبل یک مرکز را انتخاب کنید.
</Typography>
);
case 3:
return selectedCenter ? (
<Box sx={{ textAlign: "center" }}>
<BusinessIcon
sx={{
fontSize: 60,
color: selectedCenter.isUrgent ? "#ef4444" : "#2563eb",
mb: 2,
}}
/>
<Typography variant="h6" sx={{ mb: 1, fontWeight: 700 }}>
وضعیت استخدام فوری
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{selectedCenter.isUrgent
? "این مرکز دارای استخدام فوری است."
: "این مرکز در حال حاضر استخدام فوری ندارد."}
</Typography>
<Chip
label={selectedCenter.isUrgent ? "فوری" : "عادی"}
sx={{
px: 1,
fontWeight: 800,
backgroundColor: selectedCenter.isUrgent
? "#fee2e2"
: "#e2e8f0",
color: selectedCenter.isUrgent ? "#dc2626" : "#475569",
}}
/>
</Box>
) : (
<Typography className="text-center text-[#94a3b8]">
ابتدا یک مرکز انتخاب کنید.
</Typography>
);
default:
return (
<Typography className="text-center text-[#94a3b8]">
محتوای مرحله <b>«{STEP_LABELS[step - 1]}»</b> <br />
(در حال توسعه...)
</Typography>
);
}
};
return (
<>
<div style={{ width: isMobile ? "100%" : "100%", flexGrow: 1 }}>
<div className="w-full grid grid-cols-2 gap-4">
{renderStepContent(activeStep)}
</div>
</div>
</>
);
}
export default RegistrationCenterForm;