change some files
This commit is contained in:
93
app/api/cdn/upload/route.ts
Normal file
93
app/api/cdn/upload/route.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
// 1) env check
|
||||||
|
const uploadUrl = process.env.CDN_UPLOAD_URL;
|
||||||
|
const token = process.env.CDN_SERVICE_TOKEN;
|
||||||
|
|
||||||
|
if (!uploadUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing env: CDN_UPLOAD_URL" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing env: CDN_SERVICE_TOKEN" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) read multipart
|
||||||
|
const incoming = await req.formData();
|
||||||
|
const file = incoming.get("file");
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "file is required (field name must be 'file')" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Next route handlers, this should be a Web File
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Invalid file type",
|
||||||
|
receivedType: Object.prototype.toString.call(file),
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) forward to CDN
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file, file.name);
|
||||||
|
|
||||||
|
const cdnRes = await fetch(uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = cdnRes.headers.get("content-type") || "";
|
||||||
|
const isJson = contentType.includes("application/json");
|
||||||
|
|
||||||
|
const payload = isJson ? await cdnRes.json() : await cdnRes.text();
|
||||||
|
|
||||||
|
if (!cdnRes.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "CDN upload failed",
|
||||||
|
status: cdnRes.status,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
{ status: cdnRes.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(payload);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// 4) robust error serialization (no toString on undefined)
|
||||||
|
let details = "unknown error";
|
||||||
|
if (e instanceof Error) details = e.message;
|
||||||
|
else if (typeof e === "string") details = e;
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
details = JSON.stringify(e);
|
||||||
|
} catch {
|
||||||
|
details = "non-serializable error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unexpected error", details },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const callAPISetting = axios.create({
|
const callAPISetting = axios.create({
|
||||||
baseURL: "http://localhost:4000/api/v1",
|
baseURL: "http://localhost:5000/api/v1",
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { EducationFormValues } from "@/ui/forms/education/EducationForm";
|
||||||
|
|
||||||
export type genderType = "male" | "female" | "other";
|
export type genderType = "male" | "female" | "other";
|
||||||
export interface IdentityFormValues {
|
export interface IdentityFormValues {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -23,12 +25,13 @@ export interface WizardFormData {
|
|||||||
selectedCenter: CenterItem | null;
|
selectedCenter: CenterItem | null;
|
||||||
};
|
};
|
||||||
identity: IdentityFormValues; // برای مرحله ۲
|
identity: IdentityFormValues; // برای مرحله ۲
|
||||||
|
education: EducationFormValues[]; // به جای education
|
||||||
}
|
}
|
||||||
|
|
||||||
// مقدار اولیه برای همه مراحل
|
// مقدار اولیه برای همه مراحل
|
||||||
export const INITIAL_WIZARD_DATA: WizardFormData = {
|
export const INITIAL_WIZARD_DATA: WizardFormData = {
|
||||||
registrationCenter: {
|
registrationCenter: {
|
||||||
selectedCenter:null
|
selectedCenter: null,
|
||||||
},
|
},
|
||||||
identity: {
|
identity: {
|
||||||
firstName: "",
|
firstName: "",
|
||||||
@@ -42,4 +45,5 @@ export const INITIAL_WIZARD_DATA: WizardFormData = {
|
|||||||
profilePhotoId: "",
|
profilePhotoId: "",
|
||||||
religion: "",
|
religion: "",
|
||||||
},
|
},
|
||||||
|
education: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||||||
|
|
||||||
export function handleAxiosError(error: unknown) {
|
export function handleAxiosError(error: unknown) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
@@ -8,3 +9,53 @@ export function handleAxiosError(error: unknown) {
|
|||||||
return "Unexpected error";
|
return "Unexpected error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const handleBack = (props: any, key: string) => {
|
||||||
|
// قبل از رفتن به عقب، مقادیر فعلی فرم را در استیت والد ذخیره کن
|
||||||
|
props?.update({ [key]: props?.values });
|
||||||
|
props.setStep(props?.step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginData = {
|
||||||
|
token?: string;
|
||||||
|
applicant?: {
|
||||||
|
id?: string;
|
||||||
|
fullname?: string;
|
||||||
|
formStep?: number;
|
||||||
|
centerId?: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleLoginRedirect = (
|
||||||
|
router: AppRouterInstance,
|
||||||
|
data: {data:LoginData},
|
||||||
|
) => {
|
||||||
|
const applicant = data?.data?.applicant;
|
||||||
|
|
||||||
|
console.log(applicant)
|
||||||
|
// اگر کاربر قبلاً applicant داشته باشد
|
||||||
|
if (applicant?.id) {
|
||||||
|
const targetStep = applicant.formStep || 1;
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
"applicationDraft",
|
||||||
|
JSON.stringify({
|
||||||
|
applicantId: applicant.id,
|
||||||
|
centerId: applicant.centerId,
|
||||||
|
formStep: targetStep,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetStep === 1) {
|
||||||
|
router.push("/form");
|
||||||
|
} else {
|
||||||
|
router.push(`/form?step=${targetStep}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// اگر کاربر جدید باشد و هنوز applicant نداشته باشد
|
||||||
|
localStorage.removeItem("applicationDraft");
|
||||||
|
router.push("/form");
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { getAllCenters } from "@/services/apis/center.api";
|
import { getAllCenters, selectCenter } from "@/services/apis/center.api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const useGetAllCenters = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["get-all-centers"],
|
||||||
|
queryFn: getAllCenters,
|
||||||
|
});
|
||||||
|
|
||||||
export const useGetAllCenters = () => useQuery({
|
export const useSelectCenter = () => useMutation({ mutationFn: selectCenter });
|
||||||
queryKey:["get-all-centers"],
|
|
||||||
queryFn:getAllCenters
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -3,3 +3,8 @@ import callAPI from "@/core/caller";
|
|||||||
export async function getAllCenters() {
|
export async function getAllCenters() {
|
||||||
return await callAPI.get(`/center/all`).then((res) => res.data);
|
return await callAPI.get(`/center/all`).then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
export async function selectCenter(centerId: string) {
|
||||||
|
return await callAPI
|
||||||
|
.post("/center/select", { centerId })
|
||||||
|
.then((res) => res.data);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import { IdentityFormValues } from "@/core/types";
|
|||||||
|
|
||||||
export async function sendIdentityForm(data: IdentityFormValues) {
|
export async function sendIdentityForm(data: IdentityFormValues) {
|
||||||
return await callAPI
|
return await callAPI
|
||||||
.post(`/form/identity/create`, { data })
|
.post(`/form/identity/create`, data )
|
||||||
.then((res) => res.data);
|
.then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,20 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
import IdentityForm from "./forms/identity/IdentityForm";
|
import IdentityForm from "./forms/identity/IdentityForm";
|
||||||
import PersonalInfoForm from "./forms/PersonalInfoForm";
|
import PersonalInfoForm from "./forms/personal/PersonalInfoForm";
|
||||||
import PhysicalInfoForm from "./forms/PhysicalInfoForm";
|
import PhysicalInfoForm from "./forms/physicalInfo/PhysicalInfoForm";
|
||||||
import EducationSection from "./forms/EducationSection";
|
|
||||||
import JobRequestSection from "./forms/JobRequestSection";
|
import SkillsForm from "./forms/skillsForm/SkillsForm";
|
||||||
import CourseSection from "./forms/CourseSection";
|
import JobInfoForm from "./forms/jobInfo/JobInfoForm";
|
||||||
import SkillsForm from "./forms/SkillsForm";
|
import RelationsForm from "./forms/relation/RelationForm";
|
||||||
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 RegistrationCenterForm from "./forms/register-center/RegistrationCenterForm";
|
||||||
import { INITIAL_WIZARD_DATA, WizardFormData } from "@/core/types";
|
import { INITIAL_WIZARD_DATA, WizardFormData } from "@/core/types";
|
||||||
|
import EducationForm from "./forms/education/EducationForm";
|
||||||
|
import CourseForm from "./forms/course/CourseForm";
|
||||||
|
import JobRequestForm from "./forms/jobRequest/JobRequestForm";
|
||||||
|
import WorkExperienceForm from "./forms/workExperience/WorkExperienceForm";
|
||||||
|
import ReferralForm from "./forms/referral/ReferralForm";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
// کامپوننت پیشفرض برای مراحلی که هنوز نساختید
|
// کامپوننت پیشفرض برای مراحلی که هنوز نساختید
|
||||||
const PlaceholderStep = ({ step }: any) => (
|
const PlaceholderStep = ({ step }: any) => (
|
||||||
@@ -38,13 +40,13 @@ const STEP_COMPONENTS: Record<number, React.ComponentType<any>> = {
|
|||||||
2: IdentityForm,
|
2: IdentityForm,
|
||||||
3: PersonalInfoForm,
|
3: PersonalInfoForm,
|
||||||
4: PhysicalInfoForm,
|
4: PhysicalInfoForm,
|
||||||
5: EducationSection,
|
5: EducationForm,
|
||||||
6: JobRequestSection,
|
6: JobRequestForm,
|
||||||
7: CourseSection,
|
7: CourseForm,
|
||||||
8: SkillsForm,
|
8: SkillsForm,
|
||||||
9: WorkExperienceSection,
|
9: WorkExperienceForm,
|
||||||
10: JobInfoForm,
|
10: JobInfoForm,
|
||||||
11: ReferralSection,
|
11: ReferralForm,
|
||||||
12: RelationsForm,
|
12: RelationsForm,
|
||||||
// بقیه مراحل از Placeholder استفاده میکنند
|
// بقیه مراحل از Placeholder استفاده میکنند
|
||||||
};
|
};
|
||||||
@@ -67,9 +69,16 @@ const STEP_LABELS = [
|
|||||||
// --- ۳. کامپوننت اصلی استپر ---
|
// --- ۳. کامپوننت اصلی استپر ---
|
||||||
|
|
||||||
export default function MultiStepForm() {
|
export default function MultiStepForm() {
|
||||||
const [activeStep, setActiveStep] = useState(1);
|
const searchParams = useSearchParams();
|
||||||
const [maxStepReached, setMaxStepReached] = useState(1);
|
|
||||||
const [formData, setFormData] = useState<WizardFormData>(INITIAL_WIZARD_DATA);
|
// خواندن مرحله از URL (اگر نبود، پیشفرض ۱)
|
||||||
|
const initialStep = Number(searchParams.get("step")) || 1;
|
||||||
|
const [activeStep, setActiveStep] = useState(initialStep);
|
||||||
|
const [maxStepReached, setMaxStepReached] = useState(initialStep);
|
||||||
|
const [formData, setFormData] = useState<WizardFormData>(() => {
|
||||||
|
// اگر میخواهی بعد از رفرش دیتا نپرد، اینجا از localStorage بخون
|
||||||
|
return INITIAL_WIZARD_DATA;
|
||||||
|
});
|
||||||
|
|
||||||
const updateFormData = (patch: Partial<WizardFormData>) => {
|
const updateFormData = (patch: Partial<WizardFormData>) => {
|
||||||
setFormData((prev) => ({ ...prev, ...patch }));
|
setFormData((prev) => ({ ...prev, ...patch }));
|
||||||
@@ -105,7 +114,7 @@ export default function MultiStepForm() {
|
|||||||
variant="h5"
|
variant="h5"
|
||||||
sx={{ fontWeight: 900, mb: 4, color: "#1e293b" }}
|
sx={{ fontWeight: 900, mb: 4, color: "#1e293b" }}
|
||||||
>
|
>
|
||||||
پنل ثبت مرکز
|
مراحل فرم
|
||||||
</Typography>
|
</Typography>
|
||||||
{STEP_LABELS.map((label, i) => (
|
{STEP_LABELS.map((label, i) => (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Box, TextField, IconButton } from "@mui/material";
|
|
||||||
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
|
|
||||||
|
|
||||||
interface CourseAttributes {
|
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
institution: string;
|
|
||||||
year: number | string;
|
|
||||||
duration: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
data: CourseAttributes;
|
|
||||||
onChange: (data: CourseAttributes) => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
isDeletable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CourseForm({ data, onChange, onRemove, isDeletable }: Props) {
|
|
||||||
const handleChange = (field: keyof CourseAttributes, value: any) => {
|
|
||||||
onChange({ ...data, [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ mb: 2, position: "relative" }}>
|
|
||||||
{isDeletable && (
|
|
||||||
<IconButton
|
|
||||||
onClick={onRemove}
|
|
||||||
color="error"
|
|
||||||
sx={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}
|
|
||||||
aria-label="remove-course"
|
|
||||||
>
|
|
||||||
<DeleteOutlineOutlinedIcon />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="عنوان دوره"
|
|
||||||
value={data.title}
|
|
||||||
onChange={(e) => handleChange("title", e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="موسسه برگزار کننده"
|
|
||||||
value={data.institution}
|
|
||||||
onChange={(e) => handleChange("institution", e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
type="number"
|
|
||||||
label="سال برگزاری"
|
|
||||||
value={data.year}
|
|
||||||
onChange={(e) => handleChange("year", e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="مدت دوره"
|
|
||||||
value={data.duration}
|
|
||||||
onChange={(e) => handleChange("duration", e.target.value)}
|
|
||||||
placeholder="مثلاً 40 ساعت"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
label="توضیحات"
|
|
||||||
value={data.description || ""}
|
|
||||||
onChange={(e) => handleChange("description", e.target.value)}
|
|
||||||
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Button, Box } from "@mui/material";
|
|
||||||
import CourseForm from "./CourseForm";
|
|
||||||
import { AddCircleOutlineOutlined } from "@mui/icons-material";
|
|
||||||
|
|
||||||
export default function CourseSection() {
|
|
||||||
const [courses, setCourses] = useState([
|
|
||||||
{
|
|
||||||
id: Date.now(),
|
|
||||||
title: "",
|
|
||||||
institution: "",
|
|
||||||
year: "",
|
|
||||||
duration: "",
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
setCourses([
|
|
||||||
...courses,
|
|
||||||
{
|
|
||||||
id: Date.now(),
|
|
||||||
title: "",
|
|
||||||
institution: "",
|
|
||||||
year: "",
|
|
||||||
duration: "",
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = (id: number | string, updatedData: any) => {
|
|
||||||
setCourses(courses.map((c) => (c.id === id ? updatedData : c)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = (id: number | string) => {
|
|
||||||
setCourses(courses.filter((c) => c.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{courses.map((course) => (
|
|
||||||
<CourseForm
|
|
||||||
key={course.id}
|
|
||||||
data={course}
|
|
||||||
onChange={(data) => handleUpdate(course.id, data)}
|
|
||||||
onRemove={() => handleRemove(course.id)}
|
|
||||||
isDeletable={courses.length > 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<AddCircleOutlineOutlined />}
|
|
||||||
onClick={handleAdd}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: "#4caf50",
|
|
||||||
"&:hover": { backgroundColor: "#388e3c" },
|
|
||||||
borderRadius: "12px",
|
|
||||||
mt: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
افزودن دوره آموزشی جدید
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
// EducationForm.tsx
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Box, Button, MenuItem, TextField, Typography } from "@mui/material";
|
|
||||||
import { UploadFile } from "@mui/icons-material";
|
|
||||||
|
|
||||||
type DegreeValue =
|
|
||||||
| ""
|
|
||||||
| "زیر دیپلم"
|
|
||||||
| "دیپلم"
|
|
||||||
| "کاردانی"
|
|
||||||
| "کارشناسی"
|
|
||||||
| "کارشناسی ارشد"
|
|
||||||
| "دکتری"
|
|
||||||
| "حوزوی"
|
|
||||||
| "سایر";
|
|
||||||
|
|
||||||
export interface EducationFormState {
|
|
||||||
applicantId: string;
|
|
||||||
|
|
||||||
degree: DegreeValue | string; // allow custom too
|
|
||||||
field: string;
|
|
||||||
university: string;
|
|
||||||
|
|
||||||
startYear: number | "";
|
|
||||||
endYear: number | "";
|
|
||||||
|
|
||||||
gpa: number | "";
|
|
||||||
|
|
||||||
description: string;
|
|
||||||
certificateImageId: string; // FK (uuid as string)
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialValues: EducationFormState = {
|
|
||||||
applicantId: "",
|
|
||||||
|
|
||||||
degree: "",
|
|
||||||
field: "",
|
|
||||||
university: "",
|
|
||||||
|
|
||||||
startYear: "",
|
|
||||||
endYear: "",
|
|
||||||
|
|
||||||
gpa: "",
|
|
||||||
|
|
||||||
description: "",
|
|
||||||
certificateImageId: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
function toNumberOrEmpty(v: string): number | "" {
|
|
||||||
if (v === "") return "";
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isFinite(n) ? n : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EducationForm(props: {
|
|
||||||
value?: EducationFormState;
|
|
||||||
onChange?: (next: EducationFormState) => void;
|
|
||||||
applicantId?: string;
|
|
||||||
// اگر آپلودر فایل داری، آیدی خروجیاش رو اینجا ست میکنی
|
|
||||||
// onPickCertificateId?: () => void; // (اختیاری)
|
|
||||||
}) {
|
|
||||||
const { value, onChange, applicantId } = props;
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<EducationFormState>(
|
|
||||||
value ?? { ...initialValues, applicantId: applicantId ?? "" },
|
|
||||||
);
|
|
||||||
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("");
|
|
||||||
|
|
||||||
};
|
|
||||||
// controlled sync
|
|
||||||
useEffect(() => {
|
|
||||||
if (value) setFormData(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
// applicantId sync when uncontrolled
|
|
||||||
useEffect(() => {
|
|
||||||
if (!value && applicantId) setFormData((p) => ({ ...p, applicantId }));
|
|
||||||
}, [applicantId, value]);
|
|
||||||
|
|
||||||
const setNext = (
|
|
||||||
updater: (prev: EducationFormState) => EducationFormState,
|
|
||||||
) => {
|
|
||||||
setFormData((prev) => {
|
|
||||||
const next = updater(prev);
|
|
||||||
onChange?.(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleText =
|
|
||||||
(field: keyof EducationFormState) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setNext((p) => ({ ...p, [field]: v }) as EducationFormState);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNumber =
|
|
||||||
(field: keyof EducationFormState) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const v = toNumberOrEmpty(e.target.value);
|
|
||||||
setNext((p) => ({ ...p, [field]: v }) as EducationFormState);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validation helpers (optional UI only)
|
|
||||||
const startYearError =
|
|
||||||
formData.startYear !== "" &&
|
|
||||||
(formData.startYear < 1300 || formData.startYear > 1600);
|
|
||||||
|
|
||||||
const endYearError =
|
|
||||||
formData.endYear !== "" &&
|
|
||||||
(formData.endYear < 1300 || formData.endYear > 1600);
|
|
||||||
|
|
||||||
const endBeforeStart =
|
|
||||||
formData.startYear !== "" &&
|
|
||||||
formData.endYear !== "" &&
|
|
||||||
Number(formData.endYear) < Number(formData.startYear);
|
|
||||||
|
|
||||||
const gpaError =
|
|
||||||
formData.gpa !== "" &&
|
|
||||||
(Number(formData.gpa) < 0 || Number(formData.gpa) > 20);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" },
|
|
||||||
gap: 2,
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="مقطع تحصیلی"
|
|
||||||
value={formData.degree}
|
|
||||||
onChange={handleText("degree")}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<MenuItem value="">انتخاب کنید</MenuItem>
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
"زیر دیپلم",
|
|
||||||
"دیپلم",
|
|
||||||
"کاردانی",
|
|
||||||
"کارشناسی",
|
|
||||||
"کارشناسی ارشد",
|
|
||||||
"دکتری",
|
|
||||||
"حوزوی",
|
|
||||||
"سایر",
|
|
||||||
] as const
|
|
||||||
).map((d) => (
|
|
||||||
<MenuItem key={d} value={d}>
|
|
||||||
{d}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="رشته تحصیلی"
|
|
||||||
value={formData.field}
|
|
||||||
onChange={handleText("field")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="دانشگاه / موسسه"
|
|
||||||
value={formData.university}
|
|
||||||
onChange={handleText("university")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="سال شروع"
|
|
||||||
type="number"
|
|
||||||
value={formData.startYear}
|
|
||||||
onChange={handleNumber("startYear")}
|
|
||||||
fullWidth
|
|
||||||
error={startYearError}
|
|
||||||
helperText={startYearError ? "سال شروع معتبر نیست (مثلاً ۱۳۹۵)" : " "}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="سال پایان"
|
|
||||||
type="number"
|
|
||||||
value={formData.endYear}
|
|
||||||
onChange={handleNumber("endYear")}
|
|
||||||
fullWidth
|
|
||||||
error={endYearError || endBeforeStart}
|
|
||||||
helperText={
|
|
||||||
endBeforeStart
|
|
||||||
? "سال پایان نمیتواند قبل از سال شروع باشد"
|
|
||||||
: endYearError
|
|
||||||
? "سال پایان معتبر نیست (مثلاً ۱۳۹۹)"
|
|
||||||
: " "
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="معدل (از ۲۰)"
|
|
||||||
type="number"
|
|
||||||
value={formData.gpa}
|
|
||||||
onChange={handleNumber("gpa")}
|
|
||||||
fullWidth
|
|
||||||
error={gpaError}
|
|
||||||
helperText={gpaError ? "معدل باید بین ۰ تا ۲۰ باشد" : " "}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
|
||||||
<TextField
|
|
||||||
label="توضیحات"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={handleText("description")}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { initialValues as educationInitialValues };
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Box, Button, Divider, Typography, Paper, IconButton } from "@mui/material";
|
|
||||||
import { Add, Delete } from "@mui/icons-material";
|
|
||||||
import EducationForm, { EducationFormState, educationInitialValues } from "./EducationForm";
|
|
||||||
|
|
||||||
export default function EducationSection({
|
|
||||||
applicantId,
|
|
||||||
onChange
|
|
||||||
}: {
|
|
||||||
applicantId: string,
|
|
||||||
onChange: (educations: EducationFormState[]) => void
|
|
||||||
}) {
|
|
||||||
const [educations, setEducations] = useState<EducationFormState[]>([
|
|
||||||
{ ...educationInitialValues, applicantId }
|
|
||||||
]);
|
|
||||||
|
|
||||||
// افزودن یک فرم جدید
|
|
||||||
const addEducation = () => {
|
|
||||||
setEducations((prev) => [...prev, { ...educationInitialValues, applicantId }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// حذف یک فرم از لیست
|
|
||||||
const removeEducation = (index: number) => {
|
|
||||||
setEducations((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
// بهروزرسانی محتوای یک فرم خاص
|
|
||||||
const updateEducation = (index: number, data: EducationFormState) => {
|
|
||||||
const nextList = [...educations];
|
|
||||||
nextList[index] = data;
|
|
||||||
setEducations(nextList);
|
|
||||||
onChange(nextList); // ارسال لیست نهایی به کامپوننت اصلی
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
||||||
<Typography variant="h6" sx={{ color: "#1e293b", fontWeight: 700 }}>
|
|
||||||
سوابق تحصیلی
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{educations.map((ed, index) => (
|
|
||||||
<Paper key={index} sx={{ p: 3, borderRadius: "16px", border: "1px solid #e2e8f0", position: "relative" }}>
|
|
||||||
|
|
||||||
{/* دکمه حذف برای هر آیتم */}
|
|
||||||
{educations.length > 1 && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => removeEducation(index)}
|
|
||||||
sx={{ position: "absolute", top: 8, right: 8, color: "#ef4444" }}
|
|
||||||
>
|
|
||||||
<Delete />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2, color: "#64748b" }}>
|
|
||||||
مدرک تحصیلی {index + 1}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<EducationForm
|
|
||||||
value={ed}
|
|
||||||
onChange={(newData) => updateEducation(index, newData)}
|
|
||||||
applicantId={applicantId}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<Add />}
|
|
||||||
onClick={addEducation}
|
|
||||||
sx={{
|
|
||||||
borderRadius: "12px",
|
|
||||||
backgroundColor: "#2563eb",
|
|
||||||
py: 1.5,
|
|
||||||
textTransform: "none",
|
|
||||||
fontWeight: 600
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
افزودن مدرک تحصیلی جدید
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Paper,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
Switch,
|
|
||||||
FormControlLabel,
|
|
||||||
MenuItem,
|
|
||||||
Grid,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { DatePicker } from "@mui/x-date-pickers";
|
|
||||||
import { format } from "date-fns-jalali"; // برای تبدیل تاریخ به رشته استاندارد
|
|
||||||
|
|
||||||
// --- Types ---
|
|
||||||
export interface JobInfoFormData {
|
|
||||||
readyToWorkDate: string; // YYYY-MM-DD
|
|
||||||
isCurrentEmployee: boolean;
|
|
||||||
hasPastCooperation: boolean;
|
|
||||||
isCurrentlyEmployed: boolean;
|
|
||||||
dualJobInterest: boolean;
|
|
||||||
retirementStatus: "None" | "Retired" | "Redeemed";
|
|
||||||
isMilitary: boolean;
|
|
||||||
hasInsurance: boolean;
|
|
||||||
insuranceType: string;
|
|
||||||
totalInsuranceYears: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
value: JobInfoFormData;
|
|
||||||
onChange: (next: JobInfoFormData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function JobInfoForm({ value, onChange }: Props) {
|
|
||||||
const setField = (key: keyof JobInfoFormData) => (e: any) => {
|
|
||||||
onChange({ ...value, [key]: e.target.value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSwitch =
|
|
||||||
(key: keyof JobInfoFormData) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
const next = { ...value, [key]: checked };
|
|
||||||
|
|
||||||
// پاکسازی فیلدهای وابسته بیمه در صورت غیرفعال شدن
|
|
||||||
if (key === "hasInsurance" && !checked) {
|
|
||||||
next.insuranceType = "";
|
|
||||||
next.totalInsuranceYears = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(next);
|
|
||||||
};
|
|
||||||
const handleDateChange = (date: Date | null) => {
|
|
||||||
if (date) {
|
|
||||||
// تبدیل تاریخ جاوا اسکریپت به فرمت YYYY-MM-DD برای دیتابیس
|
|
||||||
const formattedDate = format(date, "yyyy-MM-dd");
|
|
||||||
onChange({ ...value, readyToWorkDate: formattedDate });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
|
|
||||||
>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
|
||||||
gap: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DatePicker
|
|
||||||
label="تاریخ آمادگی برای شروع کار"
|
|
||||||
value={value?.readyToWorkDate ? new Date(value?.readyToWorkDate) : null}
|
|
||||||
onChange={handleDateChange}
|
|
||||||
slotProps={{
|
|
||||||
textField: { fullWidth: true },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* وضعیت بازنشستگی */}
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="وضعیت بازنشستگی"
|
|
||||||
value={value?.retirementStatus}
|
|
||||||
onChange={setField("retirementStatus")}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<MenuItem value="None">هیچکدام</MenuItem>
|
|
||||||
<MenuItem value="Retired">بازنشسته</MenuItem>
|
|
||||||
<MenuItem value="Redeemed">بازخرید</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{/* Switch Buttons */}
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={value?.isCurrentEmployee}
|
|
||||||
onChange={setSwitch("isCurrentEmployee")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="از پرسنل حال حاضر هستم"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={value?.hasPastCooperation}
|
|
||||||
onChange={setSwitch("hasPastCooperation")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="سابقه همکاری در گذشته دارم"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={value?.isCurrentlyEmployed}
|
|
||||||
onChange={setSwitch("isCurrentlyEmployed")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="در حال حاضر مشغول به کار هستم"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={value?.dualJobInterest}
|
|
||||||
onChange={setSwitch("dualJobInterest")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="تمایل به شغل دوم دارم"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={value?.isMilitary}
|
|
||||||
onChange={setSwitch("isMilitary")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="نظامی هستم"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={value?.hasInsurance}
|
|
||||||
onChange={setSwitch("hasInsurance")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="دارای سابقه بیمه هستم"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Conditional Insurance Fields */}
|
|
||||||
{value?.hasInsurance && (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
label="نوع بیمه"
|
|
||||||
value={value?.insuranceType}
|
|
||||||
onChange={setField("insuranceType")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
label="جمع سالهای سابقه بیمه"
|
|
||||||
value={value?.totalInsuranceYears}
|
|
||||||
onChange={setField("totalInsuranceYears")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
InputAdornment,
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
|
|
||||||
type JobCategoryOption = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type JobOption = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
jobCategoryId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface JobRequestFormData {
|
|
||||||
jobCategoryId: string;
|
|
||||||
jobId: string;
|
|
||||||
requestedJobDescription: string;
|
|
||||||
employmentRelationType: string;
|
|
||||||
description: string;
|
|
||||||
requestedShiftType: string;
|
|
||||||
expectedSalary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JobRequestFormProps {
|
|
||||||
jobCategories?: JobCategoryOption[];
|
|
||||||
jobs?: JobOption[];
|
|
||||||
value?: JobRequestFormData;
|
|
||||||
onChange?: (data: JobRequestFormData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const relationTypes = [
|
|
||||||
"تمام وقت",
|
|
||||||
"پاره وقت",
|
|
||||||
"پروژهای",
|
|
||||||
"ساعتی",
|
|
||||||
"قراردادی",
|
|
||||||
"کارورزی",
|
|
||||||
];
|
|
||||||
|
|
||||||
const shiftTypes = [
|
|
||||||
"ثابت صبح",
|
|
||||||
"ثابت عصر",
|
|
||||||
"ثابت شب",
|
|
||||||
"چرخشی",
|
|
||||||
"شیفتی",
|
|
||||||
"فرقی ندارد",
|
|
||||||
];
|
|
||||||
|
|
||||||
const defaultCategories: JobCategoryOption[] = [
|
|
||||||
{ id: "1", name: "پاراکلینیک" },
|
|
||||||
{ id: "2", name: "اداری" },
|
|
||||||
{ id: "3", name: "درمانی" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const defaultJobs: JobOption[] = [
|
|
||||||
{ id: "1", title: "کارشناس آزمایشگاه", jobCategoryId: "1" },
|
|
||||||
{ id: "2", title: "کارشناس رادیولوژی", jobCategoryId: "1" },
|
|
||||||
{ id: "3", title: "منشی", jobCategoryId: "2" },
|
|
||||||
{ id: "4", title: "مسئول بایگانی", jobCategoryId: "2" },
|
|
||||||
{ id: "5", title: "پرستار", jobCategoryId: "3" },
|
|
||||||
{ id: "6", title: "کمک پرستار", jobCategoryId: "3" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialValues: JobRequestFormData = {
|
|
||||||
jobCategoryId: "",
|
|
||||||
jobId: "",
|
|
||||||
requestedJobDescription: "",
|
|
||||||
employmentRelationType: "",
|
|
||||||
description: "",
|
|
||||||
requestedShiftType: "",
|
|
||||||
expectedSalary: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function JobRequestForm({
|
|
||||||
jobCategories = defaultCategories,
|
|
||||||
jobs = defaultJobs,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: JobRequestFormProps) {
|
|
||||||
const [formData, setFormData] = useState<JobRequestFormData>(
|
|
||||||
value ?? initialValues,
|
|
||||||
);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const filteredJobs = useMemo(() => {
|
|
||||||
if (!formData.jobCategoryId) return [];
|
|
||||||
return jobs.filter((job) => job.jobCategoryId === formData.jobCategoryId);
|
|
||||||
}, [jobs, formData.jobCategoryId]);
|
|
||||||
|
|
||||||
const updateForm = (next: JobRequestFormData) => {
|
|
||||||
setFormData(next);
|
|
||||||
onChange?.(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange =
|
|
||||||
(field: keyof JobRequestFormData) =>
|
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.target.value;
|
|
||||||
|
|
||||||
let next = { ...formData, [field]: value };
|
|
||||||
|
|
||||||
// اگر رسته شغلی عوض شد، شغل قبلی پاک شود
|
|
||||||
if (field === "jobCategoryId") {
|
|
||||||
next.jobId = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// فقط عدد برای حقوق
|
|
||||||
if (field === "expectedSalary") {
|
|
||||||
next.expectedSalary = value.replace(/[^\d]/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
updateForm(next);
|
|
||||||
|
|
||||||
setErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[field]: "",
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (!formData.jobCategoryId) {
|
|
||||||
newErrors.jobCategoryId = "یک گزینه را انتخاب کنید!";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.jobId) {
|
|
||||||
newErrors.jobId = "یک گزینه را انتخاب کنید!";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.employmentRelationType) {
|
|
||||||
newErrors.employmentRelationType = "یک گزینه را انتخاب کنید!";
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// اگر خواستی بعداً برای submit استفاده کن
|
|
||||||
// const handleSubmit = () => {
|
|
||||||
// if (!validate()) return;
|
|
||||||
// console.log(formData);
|
|
||||||
// };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
borderRadius: "32px",
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))",
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
fullWidth
|
|
||||||
label="رسته شغلی*"
|
|
||||||
value={formData.jobCategoryId}
|
|
||||||
onChange={handleChange("jobCategoryId")}
|
|
||||||
error={!!errors.jobCategoryId}
|
|
||||||
helperText={errors.jobCategoryId || " "}
|
|
||||||
|
|
||||||
>
|
|
||||||
<MenuItem value="">انتخاب...</MenuItem>
|
|
||||||
{jobCategories.map((item) => (
|
|
||||||
<MenuItem key={item.id} value={item.id}>
|
|
||||||
{item.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
fullWidth
|
|
||||||
label="شغل درخواستی*"
|
|
||||||
value={formData.jobId}
|
|
||||||
onChange={handleChange("jobId")}
|
|
||||||
error={!!errors.jobId}
|
|
||||||
helperText={errors.jobId || " "}
|
|
||||||
disabled={!formData.jobCategoryId}
|
|
||||||
|
|
||||||
>
|
|
||||||
<MenuItem value="">انتخاب...</MenuItem>
|
|
||||||
{filteredJobs.map((item) => (
|
|
||||||
<MenuItem key={item.id} value={item.id}>
|
|
||||||
{item.title}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="توضیحات شغل درخواست"
|
|
||||||
value={formData.requestedJobDescription}
|
|
||||||
onChange={handleChange("requestedJobDescription")}
|
|
||||||
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
fullWidth
|
|
||||||
label="نوع رابطه کاری*"
|
|
||||||
value={formData.employmentRelationType}
|
|
||||||
onChange={handleChange("employmentRelationType")}
|
|
||||||
error={!!errors.employmentRelationType}
|
|
||||||
helperText={errors.employmentRelationType || " "}
|
|
||||||
|
|
||||||
>
|
|
||||||
<MenuItem value="">انتخاب ...</MenuItem>
|
|
||||||
{relationTypes.map((item) => (
|
|
||||||
<MenuItem key={item} value={item}>
|
|
||||||
{item}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="نوع شیفت درخواستی"
|
|
||||||
value={formData.requestedShiftType}
|
|
||||||
onChange={handleChange("requestedShiftType")}
|
|
||||||
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="حقوق درخواستی(ریال)"
|
|
||||||
value={formData.expectedSalary}
|
|
||||||
onChange={handleChange("expectedSalary")}
|
|
||||||
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="توضیحات"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={handleChange("description")}
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Button, Box } from "@mui/material";
|
|
||||||
import JobRequestForm from "./JobRequestForm";
|
|
||||||
import { AddCircleOutlineOutlined } from "@mui/icons-material";
|
|
||||||
|
|
||||||
export default function JobRequestSection() {
|
|
||||||
const [jobs, setJobs] = useState([{ id: Date.now(), jobCategoryId: "", hasPlan: false, planStartDate: null, degree: "" }]);
|
|
||||||
|
|
||||||
const addJob = () => {
|
|
||||||
setJobs([...jobs, { id: Date.now(), jobCategoryId: "", hasPlan: false, planStartDate: null, degree: "" }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateJob = (id, updatedData) => {
|
|
||||||
setJobs(jobs.map(j => j.id === id ? { ...updatedData, id } : j));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeJob = (id) => {
|
|
||||||
if (jobs.length > 1) setJobs(jobs.filter(j => j.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-10 ">
|
|
||||||
{jobs.map((job) => (
|
|
||||||
<JobRequestForm
|
|
||||||
key={job.id}
|
|
||||||
data={job}
|
|
||||||
onChange={(newData) => updateJob(job.id, newData)}
|
|
||||||
onRemove={() => removeJob(job.id)}
|
|
||||||
isDeletable={jobs.length > 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<AddCircleOutlineOutlined />}
|
|
||||||
onClick={addJob}
|
|
||||||
sx={{ mt: 1, borderColor: '#4caf50', color: '#4caf50' }}
|
|
||||||
>
|
|
||||||
افزودن شغل درخواستی جدید
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { Box, TextField, Typography, Button, Container } from "@mui/material";
|
import { Box, TextField, Typography, Button, Container } from "@mui/material";
|
||||||
import { useApplicantLogin } from "@/hooks/auth.hook";
|
import { useApplicantLogin } from "@/hooks/auth.hook";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { handleAxiosError } from "@/core/utils";
|
import { handleAxiosError, handleLoginRedirect } from "@/core/utils";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function LoginLayout() {
|
export default function LoginLayout() {
|
||||||
@@ -13,11 +13,12 @@ export default function LoginLayout() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
await mutateAsync(nationalId);
|
const response = await mutateAsync(nationalId);
|
||||||
|
console.log("response => ",response)
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
toast.loading("در حال انتقال به فرم استخدامي");
|
toast.loading("در حال انتقال به فرم استخدامي");
|
||||||
}
|
}
|
||||||
router.push("/form");
|
handleLoginRedirect(router, response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
toast.error(handleAxiosError(error));
|
toast.error(handleAxiosError(error));
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Box, MenuItem, TextField } from "@mui/material";
|
|
||||||
|
|
||||||
type MilitaryStatus =
|
|
||||||
| ""
|
|
||||||
| "کارت پایان خدمت"
|
|
||||||
| "در حال خدمت"
|
|
||||||
| "معافیت تحصیلی"
|
|
||||||
| "معافیت دائم"
|
|
||||||
| "انجام نشده";
|
|
||||||
|
|
||||||
type EducationLevel =
|
|
||||||
| ""
|
|
||||||
| "زیر دیپلم"
|
|
||||||
| "دیپلم"
|
|
||||||
| "دانشجو"
|
|
||||||
| "کاردانی"
|
|
||||||
| "کارشناسی"
|
|
||||||
| "کارشناسی ارشد"
|
|
||||||
| "دکترا";
|
|
||||||
|
|
||||||
export interface PersonalInfoFormState {
|
|
||||||
applicantId: string;
|
|
||||||
|
|
||||||
maritalStatus: string;
|
|
||||||
|
|
||||||
// نظام وظیفه
|
|
||||||
militaryStatus: MilitaryStatus;
|
|
||||||
permanentExemptionReason: string; // علت معافیت دائم (شرطی)
|
|
||||||
|
|
||||||
// والدین
|
|
||||||
fatherEducation: EducationLevel;
|
|
||||||
fatherJob: string;
|
|
||||||
motherEducation: EducationLevel;
|
|
||||||
motherJob: string;
|
|
||||||
|
|
||||||
housingStatus: string;
|
|
||||||
city: string;
|
|
||||||
address: string;
|
|
||||||
homePhone: string;
|
|
||||||
mobilePhone: string;
|
|
||||||
emergencyPhone: string;
|
|
||||||
email: string;
|
|
||||||
residenceDuration: number | "";
|
|
||||||
isVeteran: boolean;
|
|
||||||
|
|
||||||
hasCriminalRecord: boolean;
|
|
||||||
criminalDescription: string;
|
|
||||||
|
|
||||||
// فیلدهای همسر/فرزند
|
|
||||||
spouseName?: string;
|
|
||||||
spouseEducation?: string;
|
|
||||||
spouseJob?: string;
|
|
||||||
spouseWorkplace?: string;
|
|
||||||
childrenCount?: number | "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialValues: PersonalInfoFormState = {
|
|
||||||
applicantId: "",
|
|
||||||
maritalStatus: "",
|
|
||||||
|
|
||||||
militaryStatus: "",
|
|
||||||
permanentExemptionReason: "",
|
|
||||||
|
|
||||||
fatherEducation: "",
|
|
||||||
fatherJob: "",
|
|
||||||
motherEducation: "",
|
|
||||||
motherJob: "",
|
|
||||||
|
|
||||||
housingStatus: "",
|
|
||||||
city: "",
|
|
||||||
address: "",
|
|
||||||
homePhone: "",
|
|
||||||
mobilePhone: "",
|
|
||||||
emergencyPhone: "",
|
|
||||||
email: "",
|
|
||||||
residenceDuration: "",
|
|
||||||
isVeteran: false,
|
|
||||||
|
|
||||||
hasCriminalRecord: false,
|
|
||||||
criminalDescription: "",
|
|
||||||
|
|
||||||
spouseName: "",
|
|
||||||
spouseEducation: "",
|
|
||||||
spouseJob: "",
|
|
||||||
spouseWorkplace: "",
|
|
||||||
childrenCount: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const MILITARY_OPTIONS: Exclude<MilitaryStatus, "">[] = [
|
|
||||||
"کارت پایان خدمت",
|
|
||||||
"در حال خدمت",
|
|
||||||
"معافیت تحصیلی",
|
|
||||||
"معافیت دائم",
|
|
||||||
"انجام نشده",
|
|
||||||
];
|
|
||||||
|
|
||||||
const EDUCATION_OPTIONS: Exclude<EducationLevel, "">[] = [
|
|
||||||
"زیر دیپلم",
|
|
||||||
"دیپلم",
|
|
||||||
"دانشجو",
|
|
||||||
"کاردانی",
|
|
||||||
"کارشناسی",
|
|
||||||
"کارشناسی ارشد",
|
|
||||||
"دکترا",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PersonalInfoForm({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value?: PersonalInfoFormState;
|
|
||||||
onChange?: (val: PersonalInfoFormState) => void;
|
|
||||||
}) {
|
|
||||||
const [formData, setFormData] = useState<PersonalInfoFormState>(value || initialValues);
|
|
||||||
|
|
||||||
// اگر prop value از بیرون تغییر کرد، state داخلی sync شود
|
|
||||||
useEffect(() => {
|
|
||||||
if (value) setFormData(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const setNext = (updater: (prev: PersonalInfoFormState) => PersonalInfoFormState) => {
|
|
||||||
setFormData((prev) => {
|
|
||||||
const next = updater(prev);
|
|
||||||
onChange?.(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange =
|
|
||||||
<K extends keyof PersonalInfoFormState>(field: K) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const val = e.target.value as PersonalInfoFormState[K];
|
|
||||||
setNext((p) => ({ ...p, [field]: val }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNumber =
|
|
||||||
<K extends keyof PersonalInfoFormState>(field: K) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setNext((p) => ({ ...p, [field]: (v === "" ? "" : Number(v)) as PersonalInfoFormState[K] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// تغییر وضعیت تاهل + پاکسازی فیلدهای شرطی مربوط به همسر/فرزند
|
|
||||||
const handleMaritalStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const status = e.target.value;
|
|
||||||
setNext((p) => {
|
|
||||||
const isMarried = status === "متاهل";
|
|
||||||
const hasChildren = ["متاهل", "متارکه", "فوت همسر"].includes(status);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
maritalStatus: status,
|
|
||||||
spouseName: isMarried ? p.spouseName : "",
|
|
||||||
spouseEducation: isMarried ? p.spouseEducation : "",
|
|
||||||
spouseJob: isMarried ? p.spouseJob : "",
|
|
||||||
spouseWorkplace: isMarried ? p.spouseWorkplace : "",
|
|
||||||
childrenCount: hasChildren ? p.childrenCount : "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// تغییر وضعیت نظام وظیفه + پاکسازی علت معافیت در صورت عدم نیاز
|
|
||||||
const handleMilitaryStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const ms = e.target.value as MilitaryStatus;
|
|
||||||
setNext((p) => ({
|
|
||||||
...p,
|
|
||||||
militaryStatus: ms,
|
|
||||||
permanentExemptionReason: ms === "معافیت دائم" ? p.permanentExemptionReason : "",
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPermanentExempt = formData.militaryStatus === "معافیت دائم";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" }, gap: 2 }}>
|
|
||||||
|
|
||||||
|
|
||||||
{/* وضعیت تاهل */}
|
|
||||||
<TextField select label="وضعیت تاهل" value={formData.maritalStatus} onChange={handleMaritalStatusChange} fullWidth>
|
|
||||||
<MenuItem value="">انتخاب کنید</MenuItem>
|
|
||||||
<MenuItem value="مجرد">مجرد</MenuItem>
|
|
||||||
<MenuItem value="متاهل">متاهل</MenuItem>
|
|
||||||
<MenuItem value="متارکه">متارکه</MenuItem>
|
|
||||||
<MenuItem value="فوت همسر">فوت همسر</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{/* همسر (شرطی) */}
|
|
||||||
{formData.maritalStatus === "متاهل" && (
|
|
||||||
<>
|
|
||||||
<TextField label="نام و نام خانوادگی همسر" value={formData.spouseName} onChange={handleChange("spouseName")} fullWidth />
|
|
||||||
<TextField label="تحصیلات همسر" value={formData.spouseEducation} onChange={handleChange("spouseEducation")} fullWidth />
|
|
||||||
<TextField label="شغل همسر" value={formData.spouseJob} onChange={handleChange("spouseJob")} fullWidth />
|
|
||||||
<TextField label="محل کار همسر" value={formData.spouseWorkplace} onChange={handleChange("spouseWorkplace")} fullWidth />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* تعداد فرزند (شرطی) */}
|
|
||||||
{["متاهل", "متارکه", "فوت همسر"].includes(formData.maritalStatus) && (
|
|
||||||
<TextField
|
|
||||||
label="تعداد فرزند"
|
|
||||||
type="number"
|
|
||||||
value={formData.childrenCount}
|
|
||||||
onChange={handleNumber("childrenCount")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* وضعیت نظام وظیفه (لیستی) */}
|
|
||||||
<TextField select label="وضعیت نظام وظیفه" value={formData.militaryStatus} onChange={handleMilitaryStatusChange} fullWidth>
|
|
||||||
<MenuItem value="">انتخاب کنید</MenuItem>
|
|
||||||
{MILITARY_OPTIONS.map((opt) => (
|
|
||||||
<MenuItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{/* علت معافیت دائم (شرطی) */}
|
|
||||||
{isPermanentExempt && (
|
|
||||||
<TextField
|
|
||||||
label="علت معافیت دائم"
|
|
||||||
value={formData.permanentExemptionReason}
|
|
||||||
onChange={handleChange("permanentExemptionReason")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* تحصیلات پدر/مادر (لیستی) */}
|
|
||||||
<TextField select label="تحصیلات پدر" value={formData.fatherEducation} onChange={handleChange("fatherEducation")} fullWidth>
|
|
||||||
<MenuItem value="">انتخاب کنید</MenuItem>
|
|
||||||
{EDUCATION_OPTIONS.map((opt) => (
|
|
||||||
<MenuItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField label="شغل پدر" value={formData.fatherJob} onChange={handleChange("fatherJob")} fullWidth />
|
|
||||||
|
|
||||||
<TextField select label="تحصیلات مادر" value={formData.motherEducation} onChange={handleChange("motherEducation")} fullWidth>
|
|
||||||
<MenuItem value="">انتخاب کنید</MenuItem>
|
|
||||||
{EDUCATION_OPTIONS.map((opt) => (
|
|
||||||
<MenuItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField label="شغل مادر" value={formData.motherJob} onChange={handleChange("motherJob")} fullWidth />
|
|
||||||
|
|
||||||
{/* بقیه فیلدهای قبلی (نمونه، اگر لازم داری کاملش میکنم یا در همین فایل نگه میداریم) */}
|
|
||||||
<TextField label="شهر" value={formData.city} onChange={handleChange("city")} fullWidth />
|
|
||||||
<TextField label="تلفن منزل" value={formData.homePhone} onChange={handleChange("homePhone")} fullWidth />
|
|
||||||
<TextField label="تلفن همراه" value={formData.mobilePhone} onChange={handleChange("mobilePhone")} fullWidth />
|
|
||||||
<TextField label="ایمیل" value={formData.email} onChange={handleChange("email")} fullWidth />
|
|
||||||
|
|
||||||
<Box sx={{ gridColumn: { md: "span 2" } }}>
|
|
||||||
<TextField label="آدرس" value={formData.address} onChange={handleChange("address")} fullWidth multiline minRows={2} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="سابقه کیفری"
|
|
||||||
value={String(formData.hasCriminalRecord)}
|
|
||||||
onChange={(e) => setNext((p) => ({ ...p, hasCriminalRecord: e.target.value === "true", criminalDescription: e.target.value === "true" ? p.criminalDescription : "" }))}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<MenuItem value="false">خیر</MenuItem>
|
|
||||||
<MenuItem value="true">بله</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{formData.hasCriminalRecord && (
|
|
||||||
<TextField
|
|
||||||
label="توضیحات سوء پیشینه"
|
|
||||||
value={formData.criminalDescription}
|
|
||||||
onChange={handleChange("criminalDescription")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Box, MenuItem, TextField } from "@mui/material";
|
|
||||||
|
|
||||||
type BloodType = "" | "A+" | "A-" | "B+" | "B-" | "AB+" | "AB-" | "O+" | "O-";
|
|
||||||
|
|
||||||
export interface PhysicalInfoFormState {
|
|
||||||
applicantId: string;
|
|
||||||
|
|
||||||
bloodType: BloodType;
|
|
||||||
height: number | ""; // cm
|
|
||||||
weight: number | ""; // kg
|
|
||||||
bmi: number | ""; // auto or manual
|
|
||||||
|
|
||||||
hasDisability: boolean;
|
|
||||||
disabilityDescription: string;
|
|
||||||
|
|
||||||
hasChronicDisease: boolean;
|
|
||||||
chronicDiseaseDescription: string;
|
|
||||||
|
|
||||||
surgeryHistory: string;
|
|
||||||
medications: string;
|
|
||||||
|
|
||||||
specialMark: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialValues: PhysicalInfoFormState = {
|
|
||||||
applicantId: "",
|
|
||||||
|
|
||||||
bloodType: "",
|
|
||||||
height: "",
|
|
||||||
weight: "",
|
|
||||||
bmi: "",
|
|
||||||
|
|
||||||
hasDisability: false,
|
|
||||||
disabilityDescription: "",
|
|
||||||
|
|
||||||
hasChronicDisease: false,
|
|
||||||
chronicDiseaseDescription: "",
|
|
||||||
|
|
||||||
surgeryHistory: "",
|
|
||||||
medications: "",
|
|
||||||
|
|
||||||
specialMark: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
function toNumberOrEmpty(v: string): number | "" {
|
|
||||||
if (v === "") return "";
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isFinite(n) ? n : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function round1(n: number) {
|
|
||||||
return Math.round(n * 10) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PhysicalInfoForm(props: {
|
|
||||||
value?: PhysicalInfoFormState;
|
|
||||||
onChange?: (next: PhysicalInfoFormState) => void;
|
|
||||||
applicantId?: string; // اگر خواستی از بیرون تزریق کنی
|
|
||||||
}) {
|
|
||||||
const { value, onChange, applicantId } = props;
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<PhysicalInfoFormState>(
|
|
||||||
value ?? { ...initialValues, applicantId: applicantId ?? "" },
|
|
||||||
);
|
|
||||||
|
|
||||||
// اگر value کنترلشده بود، همگامسازی
|
|
||||||
useEffect(() => {
|
|
||||||
if (value) setFormData(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
// اگر applicantId از بیرون تغییر کرد
|
|
||||||
useEffect(() => {
|
|
||||||
if (!value && applicantId) {
|
|
||||||
setFormData((p) => ({ ...p, applicantId }));
|
|
||||||
}
|
|
||||||
}, [applicantId, value]);
|
|
||||||
|
|
||||||
const setNext = (
|
|
||||||
updater: (prev: PhysicalInfoFormState) => PhysicalInfoFormState,
|
|
||||||
) => {
|
|
||||||
setFormData((prev) => {
|
|
||||||
const next = updater(prev);
|
|
||||||
onChange?.(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleText =
|
|
||||||
(field: keyof PhysicalInfoFormState) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setNext((p) => ({ ...p, [field]: v }) as PhysicalInfoFormState);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNumber =
|
|
||||||
(field: keyof PhysicalInfoFormState) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const v = toNumberOrEmpty(e.target.value);
|
|
||||||
setNext((p) => ({ ...p, [field]: v }) as PhysicalInfoFormState);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBoolSelect =
|
|
||||||
(field: "hasDisability" | "hasChronicDisease") =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const v = e.target.value === "true";
|
|
||||||
setNext((p) => {
|
|
||||||
// اگر false شد، توضیحات را پاک میکنیم تا داده کثیف نماند
|
|
||||||
if (field === "hasDisability" && !v) {
|
|
||||||
return { ...p, hasDisability: false, disabilityDescription: "" };
|
|
||||||
}
|
|
||||||
if (field === "hasChronicDisease" && !v) {
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
hasChronicDisease: false,
|
|
||||||
chronicDiseaseDescription: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ...p, [field]: v };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// محاسبه BMI از روی قد و وزن (cm, kg)
|
|
||||||
const computedBmi = useMemo(() => {
|
|
||||||
if (formData.height === "" || formData.weight === "") return "";
|
|
||||||
const hMeters = Number(formData.height) / 100;
|
|
||||||
if (!hMeters || hMeters <= 0) return "";
|
|
||||||
const bmi = Number(formData.weight) / (hMeters * hMeters);
|
|
||||||
return Number.isFinite(bmi) ? round1(bmi) : "";
|
|
||||||
}, [formData.height, formData.weight]);
|
|
||||||
|
|
||||||
// sync bmi (فقط وقتی قد/وزن داریم)
|
|
||||||
useEffect(() => {
|
|
||||||
// اگر بخوای دستی BMI وارد کنی، این بخش رو حذف کن.
|
|
||||||
setNext((p) => ({ ...p, bmi: computedBmi }));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [computedBmi]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" },
|
|
||||||
gap: 2,
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
|
|
||||||
{/* bloodType */}
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="گروه خونی"
|
|
||||||
value={formData.bloodType}
|
|
||||||
onChange={handleText("bloodType")}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<MenuItem value="">انتخاب کنید</MenuItem>
|
|
||||||
{(["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"] as const).map(
|
|
||||||
(bt) => (
|
|
||||||
<MenuItem key={bt} value={bt}>
|
|
||||||
{bt}
|
|
||||||
</MenuItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{/* height */}
|
|
||||||
<TextField
|
|
||||||
label="قد (سانتیمتر)"
|
|
||||||
type="number"
|
|
||||||
value={formData.height}
|
|
||||||
onChange={handleNumber("height")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* weight */}
|
|
||||||
<TextField
|
|
||||||
label="وزن (کیلوگرم)"
|
|
||||||
type="number"
|
|
||||||
value={formData.weight}
|
|
||||||
onChange={handleNumber("weight")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* bmi */}
|
|
||||||
<TextField
|
|
||||||
label="BMI"
|
|
||||||
type="number"
|
|
||||||
value={formData.bmi}
|
|
||||||
onChange={handleNumber("bmi")}
|
|
||||||
fullWidth
|
|
||||||
disabled // چون خودکار محاسبه میکنیم
|
|
||||||
helperText={
|
|
||||||
formData.height !== "" && formData.weight !== ""
|
|
||||||
? "بهصورت خودکار از قد و وزن محاسبه میشود"
|
|
||||||
: "برای محاسبه BMI، قد و وزن را وارد کنید"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* specialMark */}
|
|
||||||
<TextField
|
|
||||||
label="علامت مشخصه"
|
|
||||||
value={formData.specialMark}
|
|
||||||
onChange={handleText("specialMark")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* hasDisability */}
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="معلولیت دارد؟"
|
|
||||||
value={String(formData.hasDisability)}
|
|
||||||
onChange={handleBoolSelect("hasDisability")}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<MenuItem value="false">خیر</MenuItem>
|
|
||||||
<MenuItem value="true">بله</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{/* hasChronicDisease */}
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="بیماری مزمن دارد؟"
|
|
||||||
value={String(formData.hasChronicDisease)}
|
|
||||||
onChange={handleBoolSelect("hasChronicDisease")}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<MenuItem value="false">خیر</MenuItem>
|
|
||||||
<MenuItem value="true">بله</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{/* disabilityDescription */}
|
|
||||||
{formData.hasDisability && (
|
|
||||||
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
|
||||||
<TextField
|
|
||||||
label="توضیحات معلولیت"
|
|
||||||
value={formData.disabilityDescription}
|
|
||||||
onChange={handleText("disabilityDescription")}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* chronicDiseaseDescription */}
|
|
||||||
{formData.hasChronicDisease && (
|
|
||||||
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
|
||||||
<TextField
|
|
||||||
label="توضیحات بیماری مزمن"
|
|
||||||
value={formData.chronicDiseaseDescription}
|
|
||||||
onChange={handleText("chronicDiseaseDescription")}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* surgeryHistory */}
|
|
||||||
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
|
||||||
<TextField
|
|
||||||
label="سابقه جراحی"
|
|
||||||
value={formData.surgeryHistory}
|
|
||||||
onChange={handleText("surgeryHistory")}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* medications */}
|
|
||||||
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
|
||||||
<TextField
|
|
||||||
label="داروهای مصرفی"
|
|
||||||
value={formData.medications}
|
|
||||||
onChange={handleText("medications")}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Paper,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
IconButton,
|
|
||||||
Button,
|
|
||||||
MenuItem,
|
|
||||||
Divider,
|
|
||||||
} from "@mui/material";
|
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
|
||||||
import { DeleteOutlineOutlined } from "@mui/icons-material";
|
|
||||||
|
|
||||||
// ---------------- Types ----------------
|
|
||||||
export type AcquaintanceType = "Direct" | "Indirect";
|
|
||||||
|
|
||||||
export interface ReferralFormData {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
relationship: string;
|
|
||||||
acquaintanceDuration: string; // optional in DB, but keep as string
|
|
||||||
acquaintanceType: AcquaintanceType;
|
|
||||||
jobTitle: string;
|
|
||||||
workplaceName: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReferralItemFormProps {
|
|
||||||
index: number;
|
|
||||||
value: ReferralFormData;
|
|
||||||
onChange: (next: ReferralFormData) => void;
|
|
||||||
onRemove?: () => void;
|
|
||||||
disableRemove?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- Item Form ----------------
|
|
||||||
export function ReferralItemForm({
|
|
||||||
index,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onRemove,
|
|
||||||
disableRemove,
|
|
||||||
}: ReferralItemFormProps) {
|
|
||||||
const setField =
|
|
||||||
(key: keyof ReferralFormData) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange({ ...value, [key]: e.target.value });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: { xs: 2, md: 2.5 },
|
|
||||||
borderRadius: 2,
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
backgroundColor: "#fff", // بدون سبز
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
mb: 1.5,
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontWeight: 700 }}>معرف {index + 1}</Typography>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
onClick={onRemove}
|
|
||||||
disabled={disableRemove}
|
|
||||||
color="error"
|
|
||||||
size="small"
|
|
||||||
aria-label="حذف معرف"
|
|
||||||
>
|
|
||||||
<DeleteOutlineOutlined />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
label="نام"
|
|
||||||
value={value.firstName}
|
|
||||||
onChange={setField("firstName")}
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="نام خانوادگی"
|
|
||||||
value={value.lastName}
|
|
||||||
onChange={setField("lastName")}
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="نسبت / رابطه"
|
|
||||||
value={value.relationship}
|
|
||||||
onChange={setField("relationship")}
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
placeholder="مثلاً: دوست، همکار، فامیل..."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="مدت زمان آشنایی"
|
|
||||||
value={value.acquaintanceDuration}
|
|
||||||
onChange={setField("acquaintanceDuration")}
|
|
||||||
fullWidth
|
|
||||||
placeholder="مثلاً: ۵ سال"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="نوع آشنایی"
|
|
||||||
value={value.acquaintanceType}
|
|
||||||
onChange={setField("acquaintanceType")}
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<MenuItem value="Direct">مستقیم</MenuItem>
|
|
||||||
<MenuItem value="Indirect">غیرمستقیم</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="تلفن تماس"
|
|
||||||
value={value.phoneNumber}
|
|
||||||
onChange={setField("phoneNumber")}
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
placeholder="مثلاً: 0912xxxxxxx"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="شغل معرف"
|
|
||||||
value={value.jobTitle}
|
|
||||||
onChange={setField("jobTitle")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="نام محل کار معرف"
|
|
||||||
value={value.workplaceName}
|
|
||||||
onChange={setField("workplaceName")}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- Section (Multi) ----------------
|
|
||||||
interface ReferralSectionProps {
|
|
||||||
value: ReferralFormData[];
|
|
||||||
onChange: (next: ReferralFormData[]) => void;
|
|
||||||
minItems?: number; // پیشفرض 1
|
|
||||||
maxItems?: number; // اختیاری
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyReferral = (): ReferralFormData => ({
|
|
||||||
firstName: "",
|
|
||||||
lastName: "",
|
|
||||||
relationship: "",
|
|
||||||
acquaintanceDuration: "",
|
|
||||||
acquaintanceType: "Direct",
|
|
||||||
jobTitle: "",
|
|
||||||
workplaceName: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ReferralSection({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
minItems = 1,
|
|
||||||
maxItems,
|
|
||||||
title = "معرفها",
|
|
||||||
}: ReferralSectionProps) {
|
|
||||||
const items = value?.length ? value : Array.from({ length: minItems }, emptyReferral);
|
|
||||||
|
|
||||||
const addItem = () => {
|
|
||||||
if (maxItems && items.length >= maxItems) return;
|
|
||||||
onChange([...(items || []), emptyReferral()]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateItem = (idx: number, nextItem: ReferralFormData) => {
|
|
||||||
const next = items.map((it, i) => (i === idx ? nextItem : it));
|
|
||||||
onChange(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeItem = (idx: number) => {
|
|
||||||
if (items.length <= minItems) return;
|
|
||||||
const next = items.filter((_, i) => i !== idx);
|
|
||||||
onChange(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
|
|
||||||
>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2 }}>
|
|
||||||
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={addItem}
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
disabled={!!maxItems && items.length >= maxItems}
|
|
||||||
>
|
|
||||||
افزودن معرف جديد
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gap: 2 }}>
|
|
||||||
{items.map((item, idx) => (
|
|
||||||
<ReferralItemForm
|
|
||||||
key={idx}
|
|
||||||
index={idx}
|
|
||||||
value={item}
|
|
||||||
onChange={(next) => updateItem(idx, next)}
|
|
||||||
onRemove={() => removeItem(idx)}
|
|
||||||
disableRemove={items.length <= minItems}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Paper,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
Grid,
|
|
||||||
Alert,
|
|
||||||
AlertTitle,
|
|
||||||
Divider,
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
export interface ReferenceFormData {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
relationship: string;
|
|
||||||
jobTitle: string;
|
|
||||||
workplaceName: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ValueType = [ReferenceFormData, ReferenceFormData];
|
|
||||||
|
|
||||||
const emptyRef = (): ReferenceFormData => ({
|
|
||||||
firstName: "",
|
|
||||||
lastName: "",
|
|
||||||
relationship: "",
|
|
||||||
jobTitle: "",
|
|
||||||
workplaceName: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
value?: ValueType; // ✅ optional
|
|
||||||
onChange?: (next: ValueType) => void; // ✅ optional
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RelationForm({ value, onChange }: Props) {
|
|
||||||
// ✅ همیشه یک آرایه 2تایی معتبر داریم
|
|
||||||
const safeValue: ValueType = value ?? [emptyRef(), emptyRef()];
|
|
||||||
|
|
||||||
const safeOnChange = onChange ?? (() => {});
|
|
||||||
|
|
||||||
const updateField =
|
|
||||||
(index: 0 | 1, key: keyof ReferenceFormData) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const next: ValueType = [
|
|
||||||
{ ...safeValue[0] },
|
|
||||||
{ ...safeValue[1] },
|
|
||||||
];
|
|
||||||
|
|
||||||
next[index] = { ...next[index], [key]: e.target.value };
|
|
||||||
safeOnChange(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPersonFields = (index: 0 | 1) => (
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
|
||||||
آشنای {index === 0 ? "اول" : "دوم"}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
label="نام *"
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
value={safeValue[index].firstName}
|
|
||||||
onChange={updateField(index, "firstName")}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
label="نام خانوادگی *"
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
value={safeValue[index].lastName}
|
|
||||||
onChange={updateField(index, "lastName")}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
label="نسبت *"
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
value={safeValue[index].relationship}
|
|
||||||
onChange={updateField(index, "relationship")}
|
|
||||||
placeholder="مثلاً: همکار، دوست"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
label="تلفن تماس *"
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
inputMode="tel"
|
|
||||||
value={safeValue[index].phoneNumber}
|
|
||||||
onChange={updateField(index, "phoneNumber")}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid >
|
|
||||||
<TextField
|
|
||||||
label="شغل *"
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
value={safeValue[index].jobTitle}
|
|
||||||
onChange={updateField(index, "jobTitle")}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
label="نام محل کار *"
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
value={safeValue[index].workplaceName}
|
|
||||||
onChange={updateField(index, "workplaceName")}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} >
|
|
||||||
|
|
||||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
|
||||||
<AlertTitle>توجه</AlertTitle>
|
|
||||||
مشخصات دو نفر از آشنایان را وارد کنید و از درج بستگان درجه یک (پدر، مادر، همسر، برادر و خواهر) خودداری نمایید.
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{renderPersonFields(0)}
|
|
||||||
<Divider sx={{ my: 3 }} />
|
|
||||||
{renderPersonFields(1)}
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Box, MenuItem, Paper, TextField, Typography, Divider, Switch, FormControlLabel } from "@mui/material";
|
|
||||||
|
|
||||||
// --- Types ---
|
|
||||||
type ProficiencyLevel = "" | "NONE" | "VERY_WEAK" | "WEAK" | "AVERAGE" | "GOOD" | "VERY_GOOD" | "EXCELLENT";
|
|
||||||
|
|
||||||
export interface ComputerSkillFormData {
|
|
||||||
pcUsage: ProficiencyLevel;
|
|
||||||
word: ProficiencyLevel;
|
|
||||||
excel: ProficiencyLevel;
|
|
||||||
powerPoint: ProficiencyLevel;
|
|
||||||
rahkaran: ProficiencyLevel;
|
|
||||||
kasra: ProficiencyLevel;
|
|
||||||
didgah: ProficiencyLevel;
|
|
||||||
his: ProficiencyLevel;
|
|
||||||
otherSoftware?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LanguageSkillsFormData {
|
|
||||||
englishLevel: ProficiencyLevel;
|
|
||||||
englishDescription: string;
|
|
||||||
hasEnglishCertificate: boolean; // جدید
|
|
||||||
englishCertificateType: string; // جدید
|
|
||||||
arabicLevel: ProficiencyLevel;
|
|
||||||
arabicDescription: string;
|
|
||||||
otherLanguagesDescription: string;
|
|
||||||
dialectsDescription: string;
|
|
||||||
otherSkills: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SkillsFormState {
|
|
||||||
computerSkill: ComputerSkillFormData;
|
|
||||||
languageSkill: LanguageSkillsFormData;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
value: SkillsFormState;
|
|
||||||
onChange: (next: SkillsFormState) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Constants ---
|
|
||||||
const proficiencyOptions: { value: ProficiencyLevel; label: string }[] = [
|
|
||||||
{ value: "", label: "انتخاب ..." },
|
|
||||||
{ value: "NONE", label: "ندارد" },
|
|
||||||
{ value: "VERY_WEAK", label: "خیلی ضعیف" },
|
|
||||||
{ value: "WEAK", label: "ضعیف" },
|
|
||||||
{ value: "AVERAGE", label: "متوسط" },
|
|
||||||
{ value: "GOOD", label: "خوب" },
|
|
||||||
{ value: "VERY_GOOD", label: "خیلی خوب" },
|
|
||||||
{ value: "EXCELLENT", label: "عالی" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const certTypes = ["IELTS", "TOFEL", "TOLIMO", "MCHE"];
|
|
||||||
|
|
||||||
export default function SkillsForm({ value, onChange }: Props) {
|
|
||||||
|
|
||||||
const handleComputerChange = (key: keyof ComputerSkillFormData, val: string) => {
|
|
||||||
onChange({
|
|
||||||
...value,
|
|
||||||
computerSkill: { ...value.computerSkill, [key]: val },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLanguageChange = (key: keyof LanguageSkillsFormData, val: any) => {
|
|
||||||
const nextLang = { ...value.languageSkill, [key]: val };
|
|
||||||
|
|
||||||
// پاکسازی فیلد نوع مدرک در صورتی که سوییچ خاموش شود
|
|
||||||
if (key === "hasEnglishCertificate" && val === false) {
|
|
||||||
nextLang.englishCertificateType = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange({
|
|
||||||
...value,
|
|
||||||
languageSkill: nextLang,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "grid", gap: 3 }}>
|
|
||||||
{/* 1. Computer Skills Section */}
|
|
||||||
<Paper elevation={0} sx={{ p: { xs: 2, md: 3 }, borderRadius: "24px", border: "1px solid #e2e8f0" }}>
|
|
||||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>مهارتهای کامپیوتری</Typography>
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
|
||||||
{(['pcUsage', 'word', 'excel', 'powerPoint', 'rahkaran', 'kasra', 'didgah', 'his'] as const).map((field) => (
|
|
||||||
<TextField
|
|
||||||
key={field}
|
|
||||||
select
|
|
||||||
label={field.toUpperCase()}
|
|
||||||
value={value?.computerSkill[field]}
|
|
||||||
onChange={(e) => handleComputerChange(field, e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{proficiencyOptions.map((o) => (
|
|
||||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
))}
|
|
||||||
<TextField
|
|
||||||
label="سایر نرمافزارها"
|
|
||||||
value={value?.computerSkill.otherSoftware || ""}
|
|
||||||
onChange={(e) => handleComputerChange('otherSoftware', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* 2. Language Skills Section */}
|
|
||||||
<Paper elevation={0} sx={{ p: { xs: 2, md: 3 }, borderRadius: "24px", border: "1px solid #e2e8f0" }}>
|
|
||||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>آشنایی با زبانهای خارجه</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
|
||||||
{/* English */}
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="زبان انگلیسی*"
|
|
||||||
value={value?.languageSkill.englishLevel}
|
|
||||||
onChange={(e) => handleLanguageChange("englishLevel", e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{proficiencyOptions.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={value?.languageSkill.hasEnglishCertificate}
|
|
||||||
onChange={(e) => handleLanguageChange("hasEnglishCertificate", e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="مدرک معتبر زبان انگلیسی دارد"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{value?.languageSkill.hasEnglishCertificate && (
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="نوع مدرک"
|
|
||||||
value={value?.languageSkill.englishCertificateType}
|
|
||||||
onChange={(e) => handleLanguageChange("englishCertificateType", e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
|
||||||
>
|
|
||||||
{certTypes.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
|
||||||
</TextField>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="توضیحات زبان انگلیسی"
|
|
||||||
value={value?.languageSkill.englishDescription}
|
|
||||||
onChange={(e) => handleLanguageChange("englishDescription", e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider sx={{ gridColumn: { xs: "1", md: "1 / -1" }, my: 1 }} />
|
|
||||||
|
|
||||||
{/* Arabic */}
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="زبان عربی*"
|
|
||||||
value={value?.languageSkill.arabicLevel}
|
|
||||||
onChange={(e) => handleLanguageChange("arabicLevel", e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{proficiencyOptions.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<Box />
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="توضیحات زبان عربی"
|
|
||||||
value={value?.languageSkill.arabicDescription}
|
|
||||||
onChange={(e) => handleLanguageChange("arabicDescription", e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider sx={{ gridColumn: { xs: "1", md: "1 / -1" }, my: 1 }} />
|
|
||||||
|
|
||||||
{/* Other Skills */}
|
|
||||||
<TextField
|
|
||||||
label="سایر زبان ها (توضیحات در مورد میزان تسلط)"
|
|
||||||
value={value?.languageSkill.otherLanguagesDescription}
|
|
||||||
onChange={(e) => handleLanguageChange("otherLanguagesDescription", e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="آشنایی با گویش ها و لهجه های کشور (توضیحات در مورد میزان تسلط)"
|
|
||||||
value={value?.languageSkill.dialectsDescription}
|
|
||||||
onChange={(e) => handleLanguageChange("dialectsDescription", e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="سایر مهارت ها (اعم از ورزشی، هنری، فرهنگی، اجتماعی و ...)"
|
|
||||||
value={value?.languageSkill.otherSkills}
|
|
||||||
onChange={(e) => handleLanguageChange("otherSkills", e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Box, FormControlLabel, IconButton, Paper, Switch, TextField, Typography } from "@mui/material";
|
|
||||||
import { DeleteOutlineOutlined } from "@mui/icons-material";
|
|
||||||
|
|
||||||
export interface WorkExperienceFormItem {
|
|
||||||
id?: string;
|
|
||||||
hasNoExperience: boolean;
|
|
||||||
companyName: string;
|
|
||||||
lastPosition: string;
|
|
||||||
startYear: string;
|
|
||||||
endYear: string;
|
|
||||||
leavingReason: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
value: WorkExperienceFormItem;
|
|
||||||
index: number;
|
|
||||||
onChange: (next: WorkExperienceFormItem) => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
disableRemove: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkExperienceItemForm({ value, index, onChange, onRemove, disableRemove }: Props) {
|
|
||||||
const setField = (key: keyof WorkExperienceFormItem) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange({ ...value, [key]: e.target.value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const setHasNoExperience = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
// پاکسازی کامل سایر فیلدها در صورت انتخاب "فاقد سابقه"
|
|
||||||
onChange({
|
|
||||||
hasNoExperience: true,
|
|
||||||
companyName: "",
|
|
||||||
lastPosition: "",
|
|
||||||
startYear: "",
|
|
||||||
endYear: "",
|
|
||||||
leavingReason: "",
|
|
||||||
description: "",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onChange({ ...value, hasNoExperience: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} >
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 1 }}>
|
|
||||||
<IconButton onClick={onRemove} disabled={disableRemove} size="small"><DeleteOutlineOutlined /></IconButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" }, gap: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
sx={{ gridColumn: "1 / -1" }}
|
|
||||||
control={<Switch checked={value.hasNoExperience} onChange={(e) => setHasNoExperience(e.target.checked)} />}
|
|
||||||
label="فاقد سابقه کاری هستم"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField label="نام شرکت" value={value.companyName} onChange={setField("companyName")} fullWidth disabled={value.hasNoExperience} />
|
|
||||||
<TextField label="آخرین سمت" value={value.lastPosition} onChange={setField("lastPosition")} fullWidth disabled={value.hasNoExperience} />
|
|
||||||
<TextField label="سال شروع" value={value.startYear} onChange={(e) => onChange({ ...value, startYear: e.target.value.replace(/[^\d]/g, "") })} fullWidth disabled={value.hasNoExperience} inputMode="numeric" />
|
|
||||||
<TextField label="سال پایان" value={value.endYear} onChange={(e) => onChange({ ...value, endYear: e.target.value.replace(/[^\d]/g, "") })} fullWidth disabled={value.hasNoExperience} inputMode="numeric" />
|
|
||||||
<TextField label="علت ترک کار" value={value.leavingReason} onChange={setField("leavingReason")} fullWidth disabled={value.hasNoExperience} sx={{ gridColumn: { md: "1 / -1" } }} />
|
|
||||||
<TextField label="توضیحات" value={value.description} onChange={setField("description")} fullWidth disabled={value.hasNoExperience} multiline minRows={3} sx={{ gridColumn: { md: "1 / -1" } }} />
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Box, Button } from "@mui/material";
|
|
||||||
import { WorkExperienceFormItem, WorkExperienceItemForm } from "./WorkExperienceForm";
|
|
||||||
|
|
||||||
export interface WorkExperienceSectionProps {
|
|
||||||
value: WorkExperienceFormItem[];
|
|
||||||
onChange: (next: WorkExperienceFormItem[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyItem = (): WorkExperienceFormItem => ({
|
|
||||||
hasNoExperience: false,
|
|
||||||
companyName: "",
|
|
||||||
lastPosition: "",
|
|
||||||
startYear: "",
|
|
||||||
endYear: "",
|
|
||||||
leavingReason: "",
|
|
||||||
description: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function WorkExperienceSection({ value, onChange }: WorkExperienceSectionProps) {
|
|
||||||
|
|
||||||
// رفع خطا: ایجاد تابع ایمن برای جلوگیری از "undefined is not a function"
|
|
||||||
const safeOnChange = (next: WorkExperienceFormItem[]) => {
|
|
||||||
if (typeof onChange === "function") {
|
|
||||||
onChange(next);
|
|
||||||
} else {
|
|
||||||
console.error("onChange is missing in WorkExperienceSection parent!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const items = value && value.length > 0 ? value : [emptyItem()];
|
|
||||||
|
|
||||||
const handleAddItem = () => {
|
|
||||||
safeOnChange([...items, emptyItem()]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveItem = (index: number) => {
|
|
||||||
const next = items.filter((_, i) => i !== index);
|
|
||||||
safeOnChange(next.length > 0 ? next : [emptyItem()]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleItemChange = (index: number, nextItem: WorkExperienceFormItem) => {
|
|
||||||
// منطق اصلی: اگر یک آیتم "فاقد سابقه" شد، لیست باید فقط شامل همان یک آیتم باشد
|
|
||||||
if (nextItem.hasNoExperience) {
|
|
||||||
safeOnChange([nextItem]);
|
|
||||||
} else {
|
|
||||||
const nextItems = items.map((it, i) => (i === index ? nextItem : it));
|
|
||||||
safeOnChange(nextItems);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasNoExperienceSelected = items.some((x) => x.hasNoExperience);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "grid", gap: 2 }}>
|
|
||||||
{items.map((item, idx) => (
|
|
||||||
<WorkExperienceItemForm
|
|
||||||
key={idx}
|
|
||||||
index={idx}
|
|
||||||
value={item}
|
|
||||||
onChange={(next:any) => handleItemChange(idx, next)}
|
|
||||||
onRemove={() => handleRemoveItem(idx)}
|
|
||||||
disableRemove={items.length === 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
||||||
<Button variant="outlined" onClick={handleAddItem} disabled={hasNoExperienceSelected}>
|
|
||||||
افزودن سابقه کاری
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
75
ui/forms/course/CourseForm.tsx
Normal file
75
ui/forms/course/CourseForm.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { withFormik, type FormikBag } from "formik";
|
||||||
|
import InnerCourseForm from "./InnerCourseForm";
|
||||||
|
|
||||||
|
export interface CourseItem {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
institution: string;
|
||||||
|
year: number | string;
|
||||||
|
duration: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseFormValues {
|
||||||
|
courses: CourseItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** این بخش را با WizardFormData واقعی پروژهات هماهنگ کن */
|
||||||
|
export interface WizardFormData {
|
||||||
|
courses: CourseItem[];
|
||||||
|
// ... other steps
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CourseFormProps = {
|
||||||
|
step: number;
|
||||||
|
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
data: WizardFormData;
|
||||||
|
update: (patch: Partial<WizardFormData>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COURSE_EMPTY_ITEM: CourseItem = {
|
||||||
|
id: "",
|
||||||
|
title: "",
|
||||||
|
institution: "",
|
||||||
|
year: "",
|
||||||
|
duration: "",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COURSE_EMPTY_VALUES: CourseFormValues = {
|
||||||
|
courses: [COURSE_EMPTY_ITEM],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CourseForm = withFormik<CourseFormProps, CourseFormValues>({
|
||||||
|
displayName: "CourseForm",
|
||||||
|
|
||||||
|
enableReinitialize: true,
|
||||||
|
|
||||||
|
mapPropsToValues: (props) => {
|
||||||
|
return {
|
||||||
|
courses:
|
||||||
|
props.data?.courses?.length > 0
|
||||||
|
? props.data.courses
|
||||||
|
: COURSE_EMPTY_VALUES.courses,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// validationSchema: CourseValidationSchema,
|
||||||
|
|
||||||
|
handleSubmit: async (
|
||||||
|
values,
|
||||||
|
bag: FormikBag<CourseFormProps, CourseFormValues>,
|
||||||
|
) => {
|
||||||
|
const { props, setSubmitting } = bag;
|
||||||
|
|
||||||
|
props.update({ courses: values.courses });
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
})(InnerCourseForm);
|
||||||
|
|
||||||
|
export default CourseForm;
|
||||||
207
ui/forms/course/InnerCourseForm.tsx
Normal file
207
ui/forms/course/InnerCourseForm.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import { FieldArray, Form, getIn, type FormikProps } from "formik";
|
||||||
|
import { COURSE_EMPTY_ITEM, CourseFormProps, CourseFormValues, CourseItem } from "./CourseForm";
|
||||||
|
|
||||||
|
|
||||||
|
type Props = FormikProps<CourseFormValues> & CourseFormProps;
|
||||||
|
|
||||||
|
export default function InnerCourseForm(props: Props) {
|
||||||
|
const { values, errors, touched, handleChange, setFieldValue } = props;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
props.update({ courses: props.values.courses });
|
||||||
|
props.setStep(props.step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<FieldArray name="courses">
|
||||||
|
{({ push, remove }) => (
|
||||||
|
<>
|
||||||
|
{values.courses.map((course: CourseItem, index: number) => {
|
||||||
|
const itemErrors = getIn(errors, `courses.${index}`) || {};
|
||||||
|
const itemTouched = getIn(touched, `courses.${index}`) || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={index}
|
||||||
|
sx={{
|
||||||
|
mb: 3,
|
||||||
|
position: "relative",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: "16px",
|
||||||
|
p: 2,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{values.courses.length > 1 && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
color="error"
|
||||||
|
sx={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}
|
||||||
|
aria-label="remove-course"
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlinedIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
mb: 2,
|
||||||
|
color: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
دوره {index + 1}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="عنوان دوره"
|
||||||
|
name={`courses.${index}.title`}
|
||||||
|
value={course.title}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!itemTouched.title && !!itemErrors.title}
|
||||||
|
helperText={itemTouched.title ? itemErrors.title : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="موسسه برگزار کننده"
|
||||||
|
name={`courses.${index}.institution`}
|
||||||
|
value={course.institution}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!itemTouched.institution && !!itemErrors.institution
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.institution ? itemErrors.institution : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
label="سال برگزاری"
|
||||||
|
name={`courses.${index}.year`}
|
||||||
|
value={course.year}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFieldValue(
|
||||||
|
`courses.${index}.year`,
|
||||||
|
e.target.value === "" ? "" : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
error={!!itemTouched.year && !!itemErrors.year}
|
||||||
|
helperText={itemTouched.year ? itemErrors.year : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="مدت دوره"
|
||||||
|
name={`courses.${index}.duration`}
|
||||||
|
value={course.duration}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="مثلاً 40 ساعت"
|
||||||
|
error={!!itemTouched.duration && !!itemErrors.duration}
|
||||||
|
helperText={itemTouched.duration ? itemErrors.duration : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
label="توضیحات"
|
||||||
|
name={`courses.${index}.description`}
|
||||||
|
value={course.description || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!itemTouched.description && !!itemErrors.description
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.description ? itemErrors.description : ""
|
||||||
|
}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
push({
|
||||||
|
...COURSE_EMPTY_ITEM,
|
||||||
|
id: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
mb: 3,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
افزودن دوره
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
ui/forms/course/constant/index.ts
Normal file
14
ui/forms/course/constant/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { CourseFormValues, CourseItem } from "../CourseForm";
|
||||||
|
|
||||||
|
export const COURSE_EMPTY_ITEM: CourseItem = {
|
||||||
|
id: "",
|
||||||
|
title: "",
|
||||||
|
institution: "",
|
||||||
|
year: "",
|
||||||
|
duration: "",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COURSE_EMPTY_VALUES: CourseFormValues = {
|
||||||
|
courses: [COURSE_EMPTY_ITEM],
|
||||||
|
};
|
||||||
18
ui/forms/course/validation/index.ts
Normal file
18
ui/forms/course/validation/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
export const CourseValidationSchema = Yup.object({
|
||||||
|
courses: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
id: Yup.mixed(),
|
||||||
|
title: Yup.string().required("عنوان دوره الزامی است"),
|
||||||
|
institution: Yup.string().required("موسسه برگزار کننده الزامی است"),
|
||||||
|
year: Yup.number()
|
||||||
|
.typeError("سال برگزاری معتبر نیست")
|
||||||
|
.required("سال برگزاری الزامی است"),
|
||||||
|
duration: Yup.string().required("مدت دوره الزامی است"),
|
||||||
|
description: Yup.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, "حداقل یک دوره باید ثبت شود"),
|
||||||
|
});
|
||||||
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, "حداقل یک سابقه تحصیلی باید وارد شود"),
|
||||||
|
});
|
||||||
@@ -31,10 +31,8 @@ const IdentityFormValidationSchema = yup.object({
|
|||||||
lastName: yup.string().trim().required("نام خانوادگی الزامی است").min(2).max(50),
|
lastName: yup.string().trim().required("نام خانوادگی الزامی است").min(2).max(50),
|
||||||
birthDate: yup
|
birthDate: yup
|
||||||
.string()
|
.string()
|
||||||
.required("تاریخ تولد الزامی است")
|
.required("تاریخ تولد الزامی است"),
|
||||||
.matches(/^\d{4}\/\d{2}\/\d{2}$/, "فرمت تاریخ تولد باید به شکل ۱۴۰۳/۰۱/۲۰ باشد"),
|
|
||||||
birthPlace: yup.string().trim().required("محل تولد الزامی است").min(2).max(80),
|
birthPlace: yup.string().trim().required("محل تولد الزامی است").min(2).max(80),
|
||||||
fatherName: yup.string().trim().required("نام پدر الزامی است").min(2).max(50),
|
|
||||||
gender: yup
|
gender: yup
|
||||||
.string()
|
.string()
|
||||||
.required("جنسیت الزامی است")
|
.required("جنسیت الزامی است")
|
||||||
@@ -71,6 +69,7 @@ const IdentityForm = withFormik<IdentityFormProps, IdentityFormValues>({
|
|||||||
validationSchema: IdentityFormValidationSchema,
|
validationSchema: IdentityFormValidationSchema,
|
||||||
|
|
||||||
handleSubmit: (values, { props }) => {
|
handleSubmit: (values, { props }) => {
|
||||||
|
console.log('submitted identity')
|
||||||
props.update({
|
props.update({
|
||||||
identity: values,
|
identity: values,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
"use client";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
IconButton,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -9,47 +14,162 @@ import {
|
|||||||
import { ErrorMessage, Form, FormikProps } from "formik";
|
import { ErrorMessage, Form, FormikProps } from "formik";
|
||||||
import { IdentityFormValues } from "@/core/types";
|
import { IdentityFormValues } from "@/core/types";
|
||||||
import { IdentityFormProps } from "./IdentityForm";
|
import { IdentityFormProps } from "./IdentityForm";
|
||||||
import { UploadFile } from "@mui/icons-material";
|
import { CheckCircle, Close, Person, UploadFile } from "@mui/icons-material";
|
||||||
import { genderOptions, religionOptions } from "@/core/constant";
|
import { genderOptions, religionOptions } from "@/core/constant";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useSendIdentityForm } from "@/hooks/identity.hook";
|
||||||
|
import callAPI from "@/core/caller";
|
||||||
export default function InnerIdentityForm(
|
export default function InnerIdentityForm(
|
||||||
props: FormikProps<IdentityFormValues> & IdentityFormProps,
|
props: FormikProps<IdentityFormValues> & IdentityFormProps,
|
||||||
) {
|
) {
|
||||||
console.log(props.data)
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
// قبل از رفتن به عقب، مقادیر فعلی فرم را در استیت والد ذخیره کن
|
// قبل از رفتن به عقب، مقادیر فعلی فرم را در استیت والد ذخیره کن
|
||||||
props.update({ identity: props.values });
|
props.update({ identity: props.values });
|
||||||
props.setStep(props.step - 1);
|
props.setStep(props.step - 1);
|
||||||
};
|
};
|
||||||
const [profilePhoto, setProfilePhoto] = useState<File | null>(null);
|
const { mutateAsync, isPending } = useSendIdentityForm();
|
||||||
const [profilePhotoError, setProfilePhotoError] = useState<string>("");
|
const [profileUploading, setProfileUploading] = useState<boolean>(false);
|
||||||
|
const [profileUploadProgress, setProfileUploadProgress] = useState<number>(0);
|
||||||
|
const [profileUploadError, setProfileUploadError] = useState<string>("");
|
||||||
|
const [profilePreview, setProfilePreview] = useState<string>("");
|
||||||
|
const [uploadedFileId, setUploadedFileId] = useState<string>("");
|
||||||
|
|
||||||
const handleProfilePhotoChange = (
|
const [selectedProfileFile, setSelectedProfileFile] = useState<File | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// برای کنسل کردن آپلود
|
||||||
|
const profileAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// تابع کمکی اعتبارسنجی (میتوانید همان تابع قبلی را استفاده کنید)
|
||||||
|
const validateImageFile = (file: File) => {
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
return "فقط فایل تصویری مجاز است";
|
||||||
|
}
|
||||||
|
const maxSize = 500 * 1024; // 500KB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return "حجم عکس باید حداکثر ۵۰۰ کیلوبایت باشد";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfilePhotoChange = async (
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
) => {
|
) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (!file.type.startsWith("image/")) {
|
// ۱. پیشنمایش موقت با استفاده از خودِ فایل (قبل از آپلود)
|
||||||
setProfilePhoto(null);
|
// این کار باعث میشود کاربر فوراً عکس را ببیند و خطای URL نگیرید
|
||||||
setProfilePhotoError("فقط فایل تصویری مجاز است");
|
const localPreview = URL.createObjectURL(file);
|
||||||
return;
|
setProfilePreview(localPreview);
|
||||||
|
|
||||||
|
setProfileUploading(true);
|
||||||
|
setProfileUploadError("");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await callAPI.post("/files/upload", formData, {
|
||||||
|
onUploadProgress: (p) => {
|
||||||
|
setProfileUploadProgress(
|
||||||
|
Math.round((p.loaded * 100) / (p.total || 100)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(response);
|
||||||
|
// ۲. بررسی دقیق پاسخ بکند
|
||||||
|
// فرض میکنیم بکند شما { id: "...", url: "..." } برمیگرداند
|
||||||
|
const { id, url } = response.data;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
props.setFieldValue("profilePhotoId", id);
|
||||||
|
// اگر بکند URL کامل فرستاده، آن را جایگزین پیشنمایش موقت میکنیم
|
||||||
|
if (url) setProfilePreview(url);
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid Response");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Upload Error:", error);
|
||||||
|
setProfileUploadError(
|
||||||
|
error.response?.data?.message || "خطا در آپلود فایل",
|
||||||
|
);
|
||||||
|
setProfilePreview(""); // پاک کردن پیشنمایش در صورت خطا
|
||||||
|
props.setFieldValue("profilePhotoId", "");
|
||||||
|
} finally {
|
||||||
|
setProfileUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelUpload = () => {
|
||||||
|
if (profileAbortControllerRef.current) {
|
||||||
|
profileAbortControllerRef.current.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 500 * 1024; // 500KB
|
setProfileUploading(false);
|
||||||
if (file.size > maxSize) {
|
setProfileUploadProgress(0);
|
||||||
setProfilePhoto(null);
|
};
|
||||||
setProfilePhotoError("حجم عکس باید حداکثر ۵۰۰ کیلوبایت باشد");
|
|
||||||
return;
|
const handleRemoveProfilePhoto = async () => {
|
||||||
|
// اگر نیاز بود به بکاند درخواست حذف بزنید (اختیاری)
|
||||||
|
await callAPI.post("/files/delete", {
|
||||||
|
fileId: props.values.profilePhotoId,
|
||||||
|
});
|
||||||
|
props.setFieldValue("profilePhotoId", null);
|
||||||
|
setProfilePreview("");
|
||||||
|
setSelectedProfileFile(null);
|
||||||
|
};
|
||||||
|
const handleNext = async () => {
|
||||||
|
// اعتبارسنجی دستی کل فرم
|
||||||
|
const errors = await props.validateForm();
|
||||||
|
props.update({
|
||||||
|
identity: props.values,
|
||||||
|
});
|
||||||
|
|
||||||
|
// اگر در گام فعلی خطایی وجود ندارد، برو مرحله بعد
|
||||||
|
if (Object.keys(errors).length === 0) {
|
||||||
|
if (props.step === 12) {
|
||||||
|
props.submitForm(); // ثبت نهایی
|
||||||
|
} else {
|
||||||
|
props.setStep(props.step + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setProfilePhoto(file);
|
try {
|
||||||
setProfilePhotoError("");
|
const applicant = await mutateAsync(props.values);
|
||||||
|
|
||||||
|
console.log(applicant);
|
||||||
|
props.update({
|
||||||
|
identity: props.values,
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
"applicationDraft",
|
||||||
|
JSON.stringify({
|
||||||
|
applicantId: applicant.id,
|
||||||
|
registrationCenter: props.values,
|
||||||
|
formStep: applicant.formStep,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error(error?.message || "خطا در ثبت مرکز");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
<Alert severity="warning" sx={{ mb: 3, borderRadius: "12px" }}>
|
||||||
|
<AlertTitle sx={{ fontWeight: 800 }}>توجه</AlertTitle>
|
||||||
|
پس از تكميل اين گام ، كدملي شما براي ادامه مراحل ذخيره خواهد شد
|
||||||
|
</Alert>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -80,16 +200,22 @@ export default function InnerIdentityForm(
|
|||||||
<ErrorMessage component={"div"} name="firstName" />
|
<ErrorMessage component={"div"} name="firstName" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="نام خانوادگی"
|
label="نام خانوادگی"
|
||||||
value={props.values.lastName}
|
value={props.values.lastName}
|
||||||
onChange={(e) => props.setFieldValue("lastName", e.target.value)}
|
onChange={(e) =>
|
||||||
|
props.setFieldValue("lastName", e.target.value)
|
||||||
|
}
|
||||||
error={!!props.errors.lastName}
|
error={!!props.errors.lastName}
|
||||||
helperText={props.errors.lastName}
|
helperText={props.errors.lastName}
|
||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage component={"div"} name="lastName" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="نام پدر"
|
label="نام پدر"
|
||||||
value={props.values.fatherName}
|
value={props.values.fatherName}
|
||||||
@@ -98,7 +224,10 @@ export default function InnerIdentityForm(
|
|||||||
}
|
}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage component={"div"} name="fatherName" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="کد ملی"
|
label="کد ملی"
|
||||||
value={props.values.nationalCode}
|
value={props.values.nationalCode}
|
||||||
@@ -110,13 +239,18 @@ export default function InnerIdentityForm(
|
|||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage component={"div"} name="nationalCode" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* <DatePicker
|
<DatePicker
|
||||||
label="تاریخ تولد"
|
label="تاریخ تولد"
|
||||||
value={props.values.birthDate}
|
value={
|
||||||
|
props.values.birthDate ? new Date(props.values.birthDate) : null
|
||||||
|
}
|
||||||
onChange={(newValue) =>
|
onChange={(newValue) =>
|
||||||
props.setFieldValue("birthDate", newValue)
|
props.setFieldValue("birthDate", newValue)
|
||||||
}
|
}
|
||||||
|
maxDate={new Date()}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
textField: {
|
textField: {
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
@@ -124,8 +258,9 @@ export default function InnerIdentityForm(
|
|||||||
helperText: props.errors.birthDate,
|
helperText: props.errors.birthDate,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/> */}
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="محل تولد"
|
label="محل تولد"
|
||||||
value={props.values.birthPlace}
|
value={props.values.birthPlace}
|
||||||
@@ -133,8 +268,14 @@ export default function InnerIdentityForm(
|
|||||||
props.setFieldValue("birthPlace", e.target.value)
|
props.setFieldValue("birthPlace", e.target.value)
|
||||||
}
|
}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
required
|
||||||
|
error={!!props.errors.birthPlace}
|
||||||
|
helperText={props.errors.birthPlace}
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage component={"div"} name="birthPlace" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
label="جنسیت"
|
label="جنسیت"
|
||||||
@@ -151,13 +292,21 @@ export default function InnerIdentityForm(
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
<ErrorMessage component={"div"} name="gender" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
label="دین"
|
label="دین"
|
||||||
value={props.values.religion}
|
value={props.values.religion}
|
||||||
onChange={(e) => props.setFieldValue("religion", e.target.value)}
|
onChange={(e) =>
|
||||||
|
props.setFieldValue("religion", e.target.value)
|
||||||
|
}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
required
|
||||||
|
error={!!props.errors.religion}
|
||||||
|
helperText={props.errors.religion}
|
||||||
>
|
>
|
||||||
{religionOptions.map((item) => (
|
{religionOptions.map((item) => (
|
||||||
<MenuItem key={item} value={item}>
|
<MenuItem key={item} value={item}>
|
||||||
@@ -165,7 +314,9 @@ export default function InnerIdentityForm(
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
<ErrorMessage component={"div"} name="religion" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
label="ملیت"
|
label="ملیت"
|
||||||
value={props.values.nationality}
|
value={props.values.nationality}
|
||||||
@@ -177,11 +328,12 @@ export default function InnerIdentityForm(
|
|||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage component={"div"} name="nationality" />
|
||||||
|
</div>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
border: profilePhotoError
|
border: profileUploadError
|
||||||
? "1px solid #ef4444"
|
? "1px solid #ef4444"
|
||||||
: "1px dashed #cbd5e1",
|
: "1px dashed #cbd5e1",
|
||||||
borderRadius: "18px",
|
borderRadius: "18px",
|
||||||
@@ -190,7 +342,7 @@ export default function InnerIdentityForm(
|
|||||||
minHeight: "100%",
|
minHeight: "100%",
|
||||||
transition: "all 0.2s ease",
|
transition: "all 0.2s ease",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
borderColor: profilePhotoError ? "#ef4444" : "#2563eb",
|
borderColor: profileUploadError ? "#ef4444" : "#2563eb",
|
||||||
backgroundColor: "#f8fbff",
|
backgroundColor: "#f8fbff",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -206,6 +358,54 @@ export default function InnerIdentityForm(
|
|||||||
عکس پرسنلی
|
عکس پرسنلی
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 3,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* بخش پیشنمایش عکس */}
|
||||||
|
<Box sx={{ position: "relative" }}>
|
||||||
|
<Avatar
|
||||||
|
src={profilePreview || ""} // این استیت را باید در هندلر آپلود ست کنید
|
||||||
|
variant="rounded"
|
||||||
|
sx={{
|
||||||
|
width: 100,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: "12px",
|
||||||
|
bgcolor: "#e2e8f0",
|
||||||
|
border: "2px solid #fff",
|
||||||
|
boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!profilePreview && (
|
||||||
|
<Person sx={{ fontSize: 40, color: "#94a3b8" }} />
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
{/* دکمه حذف عکس (فقط اگر عکسی وجود داشت) */}
|
||||||
|
{profilePreview && !profileUploading && (
|
||||||
|
<IconButton
|
||||||
|
onClick={handleRemoveProfilePhoto}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -10,
|
||||||
|
right: -10,
|
||||||
|
backgroundColor: "#ef4444",
|
||||||
|
color: "white",
|
||||||
|
"&:hover": { backgroundColor: "#dc2626" },
|
||||||
|
boxShadow: "0 2px 4px rgba(0,0,0,0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Close fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
color: "#64748b",
|
color: "#64748b",
|
||||||
@@ -214,13 +414,23 @@ export default function InnerIdentityForm(
|
|||||||
lineHeight: 1.8,
|
lineHeight: 1.8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت باشد.
|
فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت
|
||||||
|
باشد.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1.5,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
component="label"
|
component="label"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<UploadFile />}
|
startIcon={<UploadFile />}
|
||||||
|
disabled={profileUploading}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
borderColor: "#cbd5e1",
|
borderColor: "#cbd5e1",
|
||||||
@@ -231,9 +441,14 @@ export default function InnerIdentityForm(
|
|||||||
borderColor: "#2563eb",
|
borderColor: "#2563eb",
|
||||||
backgroundColor: "#eff6ff",
|
backgroundColor: "#eff6ff",
|
||||||
},
|
},
|
||||||
|
"&.Mui-disabled": {
|
||||||
|
borderColor: "#cbd5e1",
|
||||||
|
color: "#94a3b8",
|
||||||
|
backgroundColor: "#f1f5f9",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
انتخاب عکس
|
{profileUploading ? "در حال آپلود..." : "انتخاب عکس"}
|
||||||
<input
|
<input
|
||||||
hidden
|
hidden
|
||||||
type="file"
|
type="file"
|
||||||
@@ -242,7 +457,26 @@ export default function InnerIdentityForm(
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{profilePhoto && (
|
{profileUploading && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
onClick={handleCancelUpload}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
borderRadius: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
لغو آپلود
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* نمایش نام فایل در حال آپلود */}
|
||||||
|
{selectedProfileFile && profileUploading && (
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
mt: 1.5,
|
mt: 1.5,
|
||||||
@@ -251,11 +485,66 @@ export default function InnerIdentityForm(
|
|||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
فایل انتخابشده: {profilePhoto.name}
|
فایل انتخابشده: {selectedProfileFile.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{profilePhotoError && (
|
{/* نوار پیشرفت */}
|
||||||
|
{profileUploading && (
|
||||||
|
<Box sx={{ mt: 1.5 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mb: 0.8,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#475569",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
پیشرفت آپلود: {profileUploadProgress}%
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "8px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
backgroundColor: "#e2e8f0",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: `${profileUploadProgress}%`,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#2563eb",
|
||||||
|
transition: "width 0.3s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* پیام موفقیت */}
|
||||||
|
{!profileUploading &&
|
||||||
|
props.values.profilePhotoId &&
|
||||||
|
!profileUploadError && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
color: "#16a34a",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle fontSize="small" /> عکس با موفقیت بارگذاری شد.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* پیام خطا */}
|
||||||
|
{profileUploadError && (
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
mt: 1.5,
|
mt: 1.5,
|
||||||
@@ -264,9 +553,15 @@ export default function InnerIdentityForm(
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{profilePhotoError}
|
{profileUploadError}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ErrorMessage
|
||||||
|
component="div"
|
||||||
|
name="profilePhotoId"
|
||||||
|
className="text-red-700 text-sm font-semibold mt-4"
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
<Box
|
<Box
|
||||||
@@ -291,7 +586,8 @@ export default function InnerIdentityForm(
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
type="submit"
|
type="button" // به جای submit از button استفاده کردیم تا با تابع خودمان چک شود
|
||||||
|
onClick={handleNext}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
px: 4,
|
px: 4,
|
||||||
|
|||||||
207
ui/forms/jobInfo/InnerJobInfoForm.tsx
Normal file
207
ui/forms/jobInfo/InnerJobInfoForm.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Button,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers";
|
||||||
|
import { Form, type FormikProps } from "formik";
|
||||||
|
import { format, parseISO, isValid } from "date-fns-jalali";
|
||||||
|
|
||||||
|
import type { JobInfoFormProps, JobInfoFormValues } from "./types";
|
||||||
|
import { retirementOptions } from "./constant";
|
||||||
|
|
||||||
|
type Props = FormikProps<JobInfoFormValues> & JobInfoFormProps;
|
||||||
|
|
||||||
|
export default function InnerJobInfoForm(props: Props) {
|
||||||
|
const {
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
handleChange,
|
||||||
|
setFieldValue,
|
||||||
|
isSubmitting,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
props.update({ jobInfo: values });
|
||||||
|
props.setStep(props.step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = (date: Date | null) => {
|
||||||
|
if (date && isValid(date)) {
|
||||||
|
const formattedDate = format(date, "yyyy-MM-dd");
|
||||||
|
setFieldValue("readyToWorkDate", formattedDate);
|
||||||
|
} else {
|
||||||
|
setFieldValue("readyToWorkDate", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper برای تبدیل رشته تاریخ به شیء Date جهت نمایش در DatePicker
|
||||||
|
const getDateValue = () => {
|
||||||
|
if (!values.readyToWorkDate) return null;
|
||||||
|
const parsed = parseISO(values.readyToWorkDate);
|
||||||
|
return isValid(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2, md: 3 },
|
||||||
|
borderRadius: "24px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ mb: 3, fontWeight: "bold" }}>
|
||||||
|
اطلاعات شغلی و بیمه
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* تاریخ آمادگی */}
|
||||||
|
<DatePicker
|
||||||
|
label="تاریخ آمادگی برای شروع کار*"
|
||||||
|
value={getDateValue()}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
fullWidth: true,
|
||||||
|
error: !!errors.readyToWorkDate && !!touched.readyToWorkDate,
|
||||||
|
helperText: touched.readyToWorkDate ? errors.readyToWorkDate : "",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* وضعیت بازنشستگی */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="وضعیت بازنشستگی*"
|
||||||
|
name="retirementStatus"
|
||||||
|
value={values.retirementStatus}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!errors.retirementStatus && !!touched.retirementStatus}
|
||||||
|
helperText={touched.retirementStatus ? errors.retirementStatus : ""}
|
||||||
|
>
|
||||||
|
{retirementOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* سوییچها */}
|
||||||
|
{[
|
||||||
|
{ name: "isCurrentEmployee", label: "از پرسنل حال حاضر هستم" },
|
||||||
|
{ name: "hasPastCooperation", label: "سابقه همکاری در گذشته دارم" },
|
||||||
|
{ name: "isCurrentlyEmployed", label: "در حال حاضر مشغول به کار هستم" },
|
||||||
|
{ name: "dualJobInterest", label: "تمایل به شغل دوم دارم" },
|
||||||
|
{ name: "isMilitary", label: "نظامی هستم" },
|
||||||
|
].map((sw) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={sw.name}
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={(values as any)[sw.name]}
|
||||||
|
onChange={(e) => setFieldValue(sw.name, e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={sw.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={values.hasInsurance}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setFieldValue("hasInsurance", checked);
|
||||||
|
if (!checked) {
|
||||||
|
setFieldValue("insuranceType", "");
|
||||||
|
setFieldValue("totalInsuranceYears", "0");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="دارای سابقه بیمه هستم"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* فیلدهای شرطی بیمه */}
|
||||||
|
{values.hasInsurance && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="نوع بیمه*"
|
||||||
|
name="insuranceType"
|
||||||
|
value={values.insuranceType}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!errors.insuranceType && !!touched.insuranceType}
|
||||||
|
helperText={touched.insuranceType ? errors.insuranceType : ""}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="جمع سالهای سابقه بیمه*"
|
||||||
|
name="totalInsuranceYears"
|
||||||
|
value={values.totalInsuranceYears}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!errors.totalInsuranceYears && !!touched.totalInsuranceYears}
|
||||||
|
helperText={
|
||||||
|
touched.totalInsuranceYears ? errors.totalInsuranceYears : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mt: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={props.step === 1 || isSubmitting}
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{ borderRadius: "12px", color: "#64748b", fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
بازگشت
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: props.step === 12 ? "green" : "#2563eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.step === 12 ? "اتمام و ثبت نهایی" : "گام بعدی"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
ui/forms/jobInfo/JobInfoForm.tsx
Normal file
32
ui/forms/jobInfo/JobInfoForm.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { withFormik, type FormikBag } from "formik";
|
||||||
|
import type { JobInfoFormProps, JobInfoFormValues } from "./types";
|
||||||
|
import { INITIAL_JOB_INFO_VALUES } from "./constant";
|
||||||
|
import { JobInfoValidationSchema } from "./validation";
|
||||||
|
import InnerJobInfoForm from "./InnerJobInfoForm";
|
||||||
|
|
||||||
|
const JobInfoForm = withFormik<JobInfoFormProps, JobInfoFormValues>({
|
||||||
|
displayName: "JobInfoForm",
|
||||||
|
|
||||||
|
enableReinitialize: true,
|
||||||
|
|
||||||
|
mapPropsToValues: (props) => {
|
||||||
|
return props.data?.jobInfo || INITIAL_JOB_INFO_VALUES;
|
||||||
|
},
|
||||||
|
|
||||||
|
validationSchema: JobInfoValidationSchema,
|
||||||
|
|
||||||
|
handleSubmit: async (
|
||||||
|
values,
|
||||||
|
bag: FormikBag<JobInfoFormProps, JobInfoFormValues>
|
||||||
|
) => {
|
||||||
|
const { props, setSubmitting } = bag;
|
||||||
|
|
||||||
|
props.update({ jobInfo: values });
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
})(InnerJobInfoForm);
|
||||||
|
|
||||||
|
export default JobInfoForm;
|
||||||
20
ui/forms/jobInfo/constant/index.ts
Normal file
20
ui/forms/jobInfo/constant/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { JobInfoFormData } from "../types";
|
||||||
|
|
||||||
|
export const INITIAL_JOB_INFO_VALUES: JobInfoFormData = {
|
||||||
|
readyToWorkDate: "",
|
||||||
|
isCurrentEmployee: false,
|
||||||
|
hasPastCooperation: false,
|
||||||
|
isCurrentlyEmployed: false,
|
||||||
|
dualJobInterest: false,
|
||||||
|
retirementStatus: "None",
|
||||||
|
isMilitary: false,
|
||||||
|
hasInsurance: false,
|
||||||
|
insuranceType: "",
|
||||||
|
totalInsuranceYears: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const retirementOptions = [
|
||||||
|
{ value: "None", label: "هیچکدام" },
|
||||||
|
{ value: "Retired", label: "بازنشسته" },
|
||||||
|
{ value: "Redeemed", label: "بازخرید" },
|
||||||
|
];
|
||||||
31
ui/forms/jobInfo/types/index.ts
Normal file
31
ui/forms/jobInfo/types/index.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
export type RetirementStatus = "None" | "Retired" | "Redeemed" | "";
|
||||||
|
|
||||||
|
export interface JobInfoFormData {
|
||||||
|
readyToWorkDate: string; // YYYY-MM-DD
|
||||||
|
isCurrentEmployee: boolean;
|
||||||
|
hasPastCooperation: boolean;
|
||||||
|
isCurrentlyEmployed: boolean;
|
||||||
|
dualJobInterest: boolean;
|
||||||
|
retirementStatus: RetirementStatus;
|
||||||
|
isMilitary: boolean;
|
||||||
|
hasInsurance: boolean;
|
||||||
|
insuranceType: string;
|
||||||
|
totalInsuranceYears: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobInfoFormValues extends JobInfoFormData {}
|
||||||
|
|
||||||
|
/** این ساختار را با کل Wizard خود تطابق دهید */
|
||||||
|
export interface WizardFormData {
|
||||||
|
jobInfo: JobInfoFormData;
|
||||||
|
// ... سایر استپها
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobInfoFormProps {
|
||||||
|
step: number;
|
||||||
|
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
data: WizardFormData;
|
||||||
|
update: (patch: Partial<WizardFormData>) => void;
|
||||||
|
}
|
||||||
26
ui/forms/jobInfo/validation/index.ts
Normal file
26
ui/forms/jobInfo/validation/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
export const JobInfoValidationSchema = Yup.object().shape({
|
||||||
|
readyToWorkDate: Yup.string().required("تاریخ آمادگی برای شروع کار الزامی است"),
|
||||||
|
retirementStatus: Yup.string().required("وضعیت بازنشستگی الزامی است"),
|
||||||
|
isCurrentEmployee: Yup.boolean(),
|
||||||
|
hasPastCooperation: Yup.boolean(),
|
||||||
|
isCurrentlyEmployed: Yup.boolean(),
|
||||||
|
dualJobInterest: Yup.boolean(),
|
||||||
|
isMilitary: Yup.boolean(),
|
||||||
|
hasInsurance: Yup.boolean(),
|
||||||
|
insuranceType: Yup.string().when("hasInsurance", {
|
||||||
|
is: true,
|
||||||
|
then: (schema) => schema.required("نوع بیمه الزامی است"),
|
||||||
|
otherwise: (schema) => schema.optional(),
|
||||||
|
}),
|
||||||
|
totalInsuranceYears: Yup.number().when("hasInsurance", {
|
||||||
|
is: true,
|
||||||
|
then: (schema) =>
|
||||||
|
schema
|
||||||
|
.typeError("باید عدد باشد")
|
||||||
|
.required("تعداد سال بیمه الزامی است")
|
||||||
|
.min(0, "نمیتواند منفی باشد"),
|
||||||
|
otherwise: (schema) => schema.optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
334
ui/forms/jobRequest/InnerJobRequestForm.tsx
Normal file
334
ui/forms/jobRequest/InnerJobRequestForm.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
} from "@mui/material";
|
||||||
|
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import { FieldArray, Form, getIn, type FormikProps } from "formik";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
JobRequestFormProps,
|
||||||
|
JobRequestFormValues,
|
||||||
|
JobRequestItem,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
JOB_REQUEST_EMPTY_ITEM,
|
||||||
|
defaultCategories,
|
||||||
|
defaultJobs,
|
||||||
|
relationTypes,
|
||||||
|
shiftTypes,
|
||||||
|
} from "./constant";
|
||||||
|
|
||||||
|
type Props = FormikProps<JobRequestFormValues> & JobRequestFormProps;
|
||||||
|
|
||||||
|
export default function InnerJobRequestForm(props: Props) {
|
||||||
|
const {
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
handleChange,
|
||||||
|
setFieldValue,
|
||||||
|
jobCategories = defaultCategories,
|
||||||
|
jobs = defaultJobs,
|
||||||
|
isSubmitting,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
props.update({
|
||||||
|
jobRequests: props.values.jobRequests,
|
||||||
|
});
|
||||||
|
props.setStep(props.step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<FieldArray name="jobRequests">
|
||||||
|
{({ push, remove }) => (
|
||||||
|
<>
|
||||||
|
{values.jobRequests.map((item: JobRequestItem, index: number) => {
|
||||||
|
const itemErrors = getIn(errors, `jobRequests.${index}`) || {};
|
||||||
|
const itemTouched = getIn(touched, `jobRequests.${index}`) || {};
|
||||||
|
|
||||||
|
const filteredJobs = jobs.filter(
|
||||||
|
(job) => job.jobCategoryId === item.jobCategoryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={item.id || index}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "24px",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
p: 3,
|
||||||
|
mb: 3,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{values.jobRequests.length > 1 && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
color="error"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
aria-label="remove-job-request"
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlinedIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
mb: 3,
|
||||||
|
color: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
درخواست شغلی {index + 1}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns:
|
||||||
|
"repeat(auto-fit, minmax(340px, 1fr))",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="رسته شغلی*"
|
||||||
|
name={`jobRequests.${index}.jobCategoryId`}
|
||||||
|
value={item.jobCategoryId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const categoryId = e.target.value;
|
||||||
|
setFieldValue(
|
||||||
|
`jobRequests.${index}.jobCategoryId`,
|
||||||
|
categoryId,
|
||||||
|
);
|
||||||
|
setFieldValue(`jobRequests.${index}.jobId`, "");
|
||||||
|
}}
|
||||||
|
error={
|
||||||
|
!!itemTouched.jobCategoryId &&
|
||||||
|
!!itemErrors.jobCategoryId
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.jobCategoryId
|
||||||
|
? itemErrors.jobCategoryId
|
||||||
|
: " "
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب...</MenuItem>
|
||||||
|
{jobCategories.map((category) => (
|
||||||
|
<MenuItem key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="شغل درخواستی*"
|
||||||
|
name={`jobRequests.${index}.jobId`}
|
||||||
|
value={item.jobId}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!itemTouched.jobId && !!itemErrors.jobId}
|
||||||
|
helperText={itemTouched.jobId ? itemErrors.jobId : " "}
|
||||||
|
disabled={!item.jobCategoryId}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب...</MenuItem>
|
||||||
|
{filteredJobs.map((job) => (
|
||||||
|
<MenuItem key={job.id} value={job.id}>
|
||||||
|
{job.title}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="توضیحات شغل درخواست"
|
||||||
|
name={`jobRequests.${index}.requestedJobDescription`}
|
||||||
|
value={item.requestedJobDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!itemTouched.requestedJobDescription &&
|
||||||
|
!!itemErrors.requestedJobDescription
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.requestedJobDescription
|
||||||
|
? itemErrors.requestedJobDescription
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="نوع رابطه کاری*"
|
||||||
|
name={`jobRequests.${index}.employmentRelationType`}
|
||||||
|
value={item.employmentRelationType}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!itemTouched.employmentRelationType &&
|
||||||
|
!!itemErrors.employmentRelationType
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.employmentRelationType
|
||||||
|
? itemErrors.employmentRelationType
|
||||||
|
: " "
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب...</MenuItem>
|
||||||
|
{relationTypes.map((relation) => (
|
||||||
|
<MenuItem key={relation} value={relation}>
|
||||||
|
{relation}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="نوع شیفت درخواستی"
|
||||||
|
name={`jobRequests.${index}.requestedShiftType`}
|
||||||
|
value={item.requestedShiftType}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!itemTouched.requestedShiftType &&
|
||||||
|
!!itemErrors.requestedShiftType
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.requestedShiftType
|
||||||
|
? itemErrors.requestedShiftType
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب...</MenuItem>
|
||||||
|
{shiftTypes.map((shift) => (
|
||||||
|
<MenuItem key={shift} value={shift}>
|
||||||
|
{shift}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="حقوق درخواستی (ریال)"
|
||||||
|
name={`jobRequests.${index}.expectedSalary`}
|
||||||
|
value={item.expectedSalary}
|
||||||
|
onChange={(e) => {
|
||||||
|
const onlyDigits = e.target.value.replace(/[^\d]/g, "");
|
||||||
|
setFieldValue(
|
||||||
|
`jobRequests.${index}.expectedSalary`,
|
||||||
|
onlyDigits,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
error={
|
||||||
|
!!itemTouched.expectedSalary &&
|
||||||
|
!!itemErrors.expectedSalary
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.expectedSalary
|
||||||
|
? itemErrors.expectedSalary
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="توضیحات"
|
||||||
|
name={`jobRequests.${index}.description`}
|
||||||
|
value={item.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
error={
|
||||||
|
!!itemTouched.description && !!itemErrors.description
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.description ? itemErrors.description : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
push({
|
||||||
|
...JOB_REQUEST_EMPTY_ITEM,
|
||||||
|
id: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
mb: 3,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
افزودن درخواست شغلی
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mt: 5,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={props.step === 1 || isSubmitting}
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
color: "#64748b",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بازگشت
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: props.step === 12 ? "green" : "#2563eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.step === 12 ? "اتمام و ثبت نهایی" : "گام بعدی"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
ui/forms/jobRequest/JobRequestForm.tsx
Normal file
42
ui/forms/jobRequest/JobRequestForm.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { withFormik, type FormikBag } from "formik";
|
||||||
|
import InnerJobRequestForm from "./InnerJobRequestForm";
|
||||||
|
import type { JobRequestFormProps, JobRequestFormValues } from "./types";
|
||||||
|
import { JobRequestValidationSchema } from "./validation";
|
||||||
|
import { JOB_REQUEST_EMPTY_VALUES } from "./constant";
|
||||||
|
|
||||||
|
const JobRequestForm = withFormik<JobRequestFormProps, JobRequestFormValues>({
|
||||||
|
displayName: "JobRequestForm",
|
||||||
|
|
||||||
|
enableReinitialize: true,
|
||||||
|
|
||||||
|
mapPropsToValues: (props) => {
|
||||||
|
return {
|
||||||
|
jobRequests:
|
||||||
|
props.data?.jobRequests?.length > 0
|
||||||
|
? props.data.jobRequests
|
||||||
|
: JOB_REQUEST_EMPTY_VALUES.jobRequests,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
validationSchema: JobRequestValidationSchema,
|
||||||
|
|
||||||
|
handleSubmit: async (
|
||||||
|
values,
|
||||||
|
bag: FormikBag<JobRequestFormProps, JobRequestFormValues>,
|
||||||
|
) => {
|
||||||
|
const { props, setSubmitting } = bag;
|
||||||
|
|
||||||
|
props.update({
|
||||||
|
jobRequests: values.jobRequests,
|
||||||
|
});
|
||||||
|
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
})(InnerJobRequestForm);
|
||||||
|
|
||||||
|
export default JobRequestForm;
|
||||||
50
ui/forms/jobRequest/constant/index.ts
Normal file
50
ui/forms/jobRequest/constant/index.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { JobCategoryOption, JobOption, JobRequestFormValues, JobRequestItem } from "../JobRequestForm";
|
||||||
|
|
||||||
|
|
||||||
|
export const relationTypes = [
|
||||||
|
"تمام وقت",
|
||||||
|
"پاره وقت",
|
||||||
|
"پروژهای",
|
||||||
|
"ساعتی",
|
||||||
|
"قراردادی",
|
||||||
|
"کارورزی",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const shiftTypes = [
|
||||||
|
"ثابت صبح",
|
||||||
|
"ثابت عصر",
|
||||||
|
"ثابت شب",
|
||||||
|
"چرخشی",
|
||||||
|
"شیفتی",
|
||||||
|
"فرقی ندارد",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const defaultCategories: JobCategoryOption[] = [
|
||||||
|
{ id: "1", name: "پاراکلینیک" },
|
||||||
|
{ id: "2", name: "اداری" },
|
||||||
|
{ id: "3", name: "درمانی" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultJobs: JobOption[] = [
|
||||||
|
{ id: "1", title: "کارشناس آزمایشگاه", jobCategoryId: "1" },
|
||||||
|
{ id: "2", title: "کارشناس رادیولوژی", jobCategoryId: "1" },
|
||||||
|
{ id: "3", title: "منشی", jobCategoryId: "2" },
|
||||||
|
{ id: "4", title: "مسئول بایگانی", jobCategoryId: "2" },
|
||||||
|
{ id: "5", title: "پرستار", jobCategoryId: "3" },
|
||||||
|
{ id: "6", title: "کمک پرستار", jobCategoryId: "3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const JOB_REQUEST_EMPTY_ITEM: JobRequestItem = {
|
||||||
|
id: "",
|
||||||
|
jobCategoryId: "",
|
||||||
|
jobId: "",
|
||||||
|
requestedJobDescription: "",
|
||||||
|
employmentRelationType: "",
|
||||||
|
description: "",
|
||||||
|
requestedShiftType: "",
|
||||||
|
expectedSalary: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JOB_REQUEST_EMPTY_VALUES: JobRequestFormValues = {
|
||||||
|
jobRequests: [JOB_REQUEST_EMPTY_ITEM],
|
||||||
|
};
|
||||||
44
ui/forms/jobRequest/types/index.ts
Normal file
44
ui/forms/jobRequest/types/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
export type JobCategoryOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobOption = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
jobCategoryId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface JobRequestItem {
|
||||||
|
id: string | number;
|
||||||
|
jobCategoryId: string;
|
||||||
|
jobId: string;
|
||||||
|
requestedJobDescription: string;
|
||||||
|
employmentRelationType: string;
|
||||||
|
description: string;
|
||||||
|
requestedShiftType: string;
|
||||||
|
expectedSalary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobRequestFormValues {
|
||||||
|
jobRequests: JobRequestItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* این type را با مدل اصلی wizard پروژهات هماهنگ کن
|
||||||
|
*/
|
||||||
|
export interface WizardFormData {
|
||||||
|
jobRequests: JobRequestItem[];
|
||||||
|
// ... سایر step ها
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobRequestFormProps {
|
||||||
|
step: number;
|
||||||
|
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
data: WizardFormData;
|
||||||
|
update: (patch: Partial<WizardFormData>) => void;
|
||||||
|
jobCategories?: JobCategoryOption[];
|
||||||
|
jobs?: JobOption[];
|
||||||
|
}
|
||||||
35
ui/forms/jobRequest/validation/index.ts
Normal file
35
ui/forms/jobRequest/validation/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
export const JobRequestValidationSchema = Yup.object({
|
||||||
|
jobRequests: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
id: Yup.mixed().optional(),
|
||||||
|
|
||||||
|
jobCategoryId: Yup.string().required("رسته شغلی الزامی است"),
|
||||||
|
|
||||||
|
jobId: Yup.string().required("شغل درخواستی الزامی است"),
|
||||||
|
|
||||||
|
requestedJobDescription: Yup.string()
|
||||||
|
.max(1000, "توضیحات شغل درخواستی نباید بیشتر از 1000 کاراکتر باشد")
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
employmentRelationType: Yup.string().required(
|
||||||
|
"نوع رابطه کاری الزامی است",
|
||||||
|
),
|
||||||
|
|
||||||
|
description: Yup.string()
|
||||||
|
.max(2000, "توضیحات نباید بیشتر از 2000 کاراکتر باشد")
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
requestedShiftType: Yup.string().optional(),
|
||||||
|
|
||||||
|
expectedSalary: Yup.string()
|
||||||
|
.matches(/^\d*$/, "حقوق درخواستی فقط باید شامل عدد باشد")
|
||||||
|
.max(15, "حقوق درخواستی بیش از حد طولانی است")
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, "حداقل یک درخواست شغلی باید ثبت شود")
|
||||||
|
.required("ثبت درخواست شغلی الزامی است"),
|
||||||
|
});
|
||||||
326
ui/forms/personal/InnerPersonalInfoForm.tsx
Normal file
326
ui/forms/personal/InnerPersonalInfoForm.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
// InnerPersonalInfoForm.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Box, Button, MenuItem, TextField } from "@mui/material";
|
||||||
|
import { Form, type FormikProps } from "formik";
|
||||||
|
import { MilitaryStatus, PersonalInfoFormValues } from "./types";
|
||||||
|
import {
|
||||||
|
EDUCATION_OPTIONS,
|
||||||
|
HOUSING_OPTIONS,
|
||||||
|
MILITARY_OPTIONS,
|
||||||
|
} from "./constants";
|
||||||
|
import { PersonalInfoFormProps } from "./PersonalInfoForm";
|
||||||
|
import { handleBack } from "@/core/utils";
|
||||||
|
|
||||||
|
type Props = FormikProps<PersonalInfoFormValues> & PersonalInfoFormProps;
|
||||||
|
|
||||||
|
export default function InnerPersonalInfoForm(props: Props) {
|
||||||
|
const { values, errors, touched, setFieldValue, handleChange } = props;
|
||||||
|
|
||||||
|
const showSpouseFields = values.maritalStatus === "متاهل";
|
||||||
|
const showChildrenCount = ["متاهل", "متارکه", "فوت همسر"].includes(
|
||||||
|
values.maritalStatus,
|
||||||
|
);
|
||||||
|
const isPermanentExempt = values.militaryStatus === "معافیت دائم";
|
||||||
|
|
||||||
|
const handleMaritalStatusChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const status = e.target.value;
|
||||||
|
|
||||||
|
setFieldValue("maritalStatus", status);
|
||||||
|
|
||||||
|
// پاکسازی شرطیها
|
||||||
|
const isMarried = status === "متاهل";
|
||||||
|
const hasChildren = ["متاهل", "متارکه", "فوت همسر"].includes(status);
|
||||||
|
|
||||||
|
if (!isMarried) {
|
||||||
|
setFieldValue("spouseName", "");
|
||||||
|
setFieldValue("spouseEducation", "");
|
||||||
|
setFieldValue("spouseJob", "");
|
||||||
|
setFieldValue("spouseWorkplace", "");
|
||||||
|
}
|
||||||
|
if (!hasChildren) {
|
||||||
|
setFieldValue("childrenCount", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMilitaryStatusChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const ms = e.target.value as MilitaryStatus;
|
||||||
|
setFieldValue("militaryStatus", ms);
|
||||||
|
|
||||||
|
if (ms !== "معافیت دائم") {
|
||||||
|
setFieldValue("permanentExemptionReason", "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tf = <K extends keyof PersonalInfoFormValues>(name: K) => ({
|
||||||
|
name: String(name),
|
||||||
|
value: values[name] as any,
|
||||||
|
onChange: handleChange,
|
||||||
|
fullWidth: true,
|
||||||
|
error: !!(touched as any)[name] && !!(errors as any)[name],
|
||||||
|
helperText: (touched as any)[name] ? ((errors as any)[name] as string) : "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* وضعیت تاهل */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="وضعیت تاهل"
|
||||||
|
name="maritalStatus"
|
||||||
|
value={values.maritalStatus}
|
||||||
|
onChange={handleMaritalStatusChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.maritalStatus && !!errors.maritalStatus}
|
||||||
|
helperText={
|
||||||
|
touched.maritalStatus ? (errors.maritalStatus as string) : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
<MenuItem value="مجرد">مجرد</MenuItem>
|
||||||
|
<MenuItem value="متاهل">متاهل</MenuItem>
|
||||||
|
<MenuItem value="متارکه">متارکه</MenuItem>
|
||||||
|
<MenuItem value="فوت همسر">فوت همسر</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
{/* همسر (شرطی) */}
|
||||||
|
{showSpouseFields && (
|
||||||
|
<>
|
||||||
|
<TextField label="نام و نام خانوادگی همسر" {...tf("spouseName")} />
|
||||||
|
<TextField label="تحصیلات همسر" {...tf("spouseEducation")} />
|
||||||
|
<TextField label="شغل همسر" {...tf("spouseJob")} />
|
||||||
|
<TextField label="محل کار همسر" {...tf("spouseWorkplace")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* تعداد فرزند (شرطی) */}
|
||||||
|
{showChildrenCount && (
|
||||||
|
<TextField
|
||||||
|
label="تعداد فرزند"
|
||||||
|
name="childrenCount"
|
||||||
|
type="number"
|
||||||
|
value={values.childrenCount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFieldValue(
|
||||||
|
"childrenCount",
|
||||||
|
e.target.value === "" ? "" : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.childrenCount && !!errors.childrenCount}
|
||||||
|
helperText={
|
||||||
|
touched.childrenCount ? (errors.childrenCount as string) : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* وضعیت نظام وظیفه */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="وضعیت نظام وظیفه"
|
||||||
|
name="militaryStatus"
|
||||||
|
value={values.militaryStatus}
|
||||||
|
onChange={handleMilitaryStatusChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.militaryStatus && !!errors.militaryStatus}
|
||||||
|
helperText={
|
||||||
|
touched.militaryStatus ? (errors.militaryStatus as string) : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{MILITARY_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{/* علت معافیت دائم */}
|
||||||
|
{isPermanentExempt && (
|
||||||
|
<TextField
|
||||||
|
label="علت معافیت دائم"
|
||||||
|
{...tf("permanentExemptionReason")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* تحصیلات پدر/مادر */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="تحصیلات پدر"
|
||||||
|
name="fatherEducation"
|
||||||
|
value={values.fatherEducation}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.fatherEducation && !!errors.fatherEducation}
|
||||||
|
helperText={
|
||||||
|
touched.fatherEducation ? (errors.fatherEducation as string) : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{EDUCATION_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField label="شغل پدر" {...tf("fatherJob")} />
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="تحصیلات مادر"
|
||||||
|
name="motherEducation"
|
||||||
|
value={values.motherEducation}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.motherEducation && !!errors.motherEducation}
|
||||||
|
helperText={
|
||||||
|
touched.motherEducation ? (errors.motherEducation as string) : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{EDUCATION_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField label="شغل مادر" {...tf("motherJob")} />
|
||||||
|
{/* وضعیت مسکن / شهر / آدرس */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="وضعیت مسکن"
|
||||||
|
name="housingStatus"
|
||||||
|
value={values.housingStatus}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.housingStatus && !!errors.housingStatus}
|
||||||
|
helperText={
|
||||||
|
touched.housingStatus ? (errors.housingStatus as string) : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{HOUSING_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>{" "}
|
||||||
|
<TextField label="شهر" {...tf("city")} />
|
||||||
|
<Box sx={{ gridColumn: { md: "span 2" } }}>
|
||||||
|
<TextField label="آدرس" {...tf("address")} multiline minRows={2} />
|
||||||
|
</Box>
|
||||||
|
{/* تلفنها */}
|
||||||
|
<TextField label="تلفن منزل" {...tf("homePhone")} />
|
||||||
|
<TextField label="تلفن همراه" {...tf("mobilePhone")} />
|
||||||
|
<TextField label="تلفن ضروری" {...tf("emergencyPhone")} />
|
||||||
|
<TextField label="ایمیل" {...tf("email")} />
|
||||||
|
{/* مدت سکونت */}
|
||||||
|
<TextField
|
||||||
|
label="مدت سکونت (سال)"
|
||||||
|
name="residenceDuration"
|
||||||
|
type="number"
|
||||||
|
value={values.residenceDuration}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFieldValue(
|
||||||
|
"residenceDuration",
|
||||||
|
e.target.value === "" ? "" : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.residenceDuration && !!errors.residenceDuration}
|
||||||
|
helperText={
|
||||||
|
touched.residenceDuration
|
||||||
|
? (errors.residenceDuration as string)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* ایثارگر */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="ایثارگر"
|
||||||
|
name="isVeteran"
|
||||||
|
value={String(values.isVeteran)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFieldValue("isVeteran", e.target.value === "true")
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.isVeteran && !!errors.isVeteran}
|
||||||
|
helperText={touched.isVeteran ? (errors.isVeteran as string) : ""}
|
||||||
|
>
|
||||||
|
<MenuItem value="false">خیر</MenuItem>
|
||||||
|
<MenuItem value="true">بله</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
{/* سوءپیشینه */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="سابقه کیفری"
|
||||||
|
name="hasCriminalRecord"
|
||||||
|
value={String(values.hasCriminalRecord)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value === "true";
|
||||||
|
setFieldValue("hasCriminalRecord", next);
|
||||||
|
if (!next) setFieldValue("criminalDescription", "");
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.hasCriminalRecord && !!errors.hasCriminalRecord}
|
||||||
|
helperText={
|
||||||
|
touched.hasCriminalRecord
|
||||||
|
? (errors.hasCriminalRecord as string)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="false">خیر</MenuItem>
|
||||||
|
<MenuItem value="true">بله</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
{values.hasCriminalRecord && (
|
||||||
|
<TextField
|
||||||
|
label="توضیحات سوء پیشینه"
|
||||||
|
{...tf("criminalDescription")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mt: 5,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={props.step === 1}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBack(props, "personalInfo")}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
ui/forms/personal/PersonalInfoForm.tsx
Normal file
49
ui/forms/personal/PersonalInfoForm.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// PersonalInfoForm.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { withFormik, type FormikBag } from "formik";
|
||||||
|
|
||||||
|
import InnerPersonalInfoForm from "./InnerPersonalInfoForm";
|
||||||
|
import { PersonalInfoFormValues } from "./types";
|
||||||
|
import { PERSONAL_INFO_EMPTY_VALUES } from "./constants";
|
||||||
|
import { PersonalInfoValidationSchema } from "./validation/PersonalInfoFormValidation";
|
||||||
|
|
||||||
|
|
||||||
|
/** اینا رو با Wizard خودت هماهنگ کن */
|
||||||
|
export interface WizardFormData {
|
||||||
|
personalInfo: PersonalInfoFormValues;
|
||||||
|
// ... بقیه step ها
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonalInfoFormProps = {
|
||||||
|
step: number;
|
||||||
|
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
data: WizardFormData;
|
||||||
|
update: (patch: Partial<WizardFormData>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PersonalInfoForm = withFormik<PersonalInfoFormProps, PersonalInfoFormValues>({
|
||||||
|
displayName: "PersonalInfoForm",
|
||||||
|
|
||||||
|
enableReinitialize: true,
|
||||||
|
|
||||||
|
mapPropsToValues: (props) => {
|
||||||
|
return props.data?.personalInfo ?? PERSONAL_INFO_EMPTY_VALUES;
|
||||||
|
},
|
||||||
|
|
||||||
|
// validationSchema: PersonalInfoValidationSchema,
|
||||||
|
|
||||||
|
handleSubmit: async (values, bag: FormikBag<PersonalInfoFormProps, PersonalInfoFormValues>) => {
|
||||||
|
const { props, setSubmitting } = bag;
|
||||||
|
|
||||||
|
props.update({ personalInfo: values });
|
||||||
|
|
||||||
|
// برو مرحله بعد
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
})(InnerPersonalInfoForm);
|
||||||
|
|
||||||
|
export default PersonalInfoForm;
|
||||||
61
ui/forms/personal/constants/index.ts
Normal file
61
ui/forms/personal/constants/index.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
EducationLevel,
|
||||||
|
HousingStatus,
|
||||||
|
MilitaryStatus,
|
||||||
|
PersonalInfoFormValues,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export const PERSONAL_INFO_EMPTY_VALUES: PersonalInfoFormValues = {
|
||||||
|
maritalStatus: "",
|
||||||
|
|
||||||
|
militaryStatus: "",
|
||||||
|
permanentExemptionReason: "",
|
||||||
|
|
||||||
|
fatherEducation: "",
|
||||||
|
fatherJob: "",
|
||||||
|
motherEducation: "",
|
||||||
|
motherJob: "",
|
||||||
|
|
||||||
|
housingStatus: "",
|
||||||
|
city: "",
|
||||||
|
address: "",
|
||||||
|
homePhone: "",
|
||||||
|
mobilePhone: "",
|
||||||
|
emergencyPhone: "",
|
||||||
|
email: "",
|
||||||
|
residenceDuration: "",
|
||||||
|
isVeteran: false,
|
||||||
|
|
||||||
|
hasCriminalRecord: false,
|
||||||
|
criminalDescription: "",
|
||||||
|
|
||||||
|
spouseName: "",
|
||||||
|
spouseEducation: "",
|
||||||
|
spouseJob: "",
|
||||||
|
spouseWorkplace: "",
|
||||||
|
childrenCount: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MILITARY_OPTIONS: Exclude<MilitaryStatus, "">[] = [
|
||||||
|
"کارت پایان خدمت",
|
||||||
|
"در حال خدمت",
|
||||||
|
"معافیت تحصیلی",
|
||||||
|
"معافیت دائم",
|
||||||
|
"انجام نشده",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EDUCATION_OPTIONS: Exclude<EducationLevel, "">[] = [
|
||||||
|
"زیر دیپلم",
|
||||||
|
"دیپلم",
|
||||||
|
"دانشجو",
|
||||||
|
"کاردانی",
|
||||||
|
"کارشناسی",
|
||||||
|
"کارشناسی ارشد",
|
||||||
|
"دکترا",
|
||||||
|
];
|
||||||
|
export const HOUSING_OPTIONS: Exclude<HousingStatus, "">[] = [
|
||||||
|
"منزل شخصی",
|
||||||
|
"منزل والدین",
|
||||||
|
"منزل استیجاری",
|
||||||
|
"سایر",
|
||||||
|
];
|
||||||
57
ui/forms/personal/types/index.ts
Normal file
57
ui/forms/personal/types/index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// personal-info.types.ts
|
||||||
|
|
||||||
|
export type MilitaryStatus =
|
||||||
|
| ""
|
||||||
|
| "کارت پایان خدمت"
|
||||||
|
| "در حال خدمت"
|
||||||
|
| "معافیت تحصیلی"
|
||||||
|
| "معافیت دائم"
|
||||||
|
| "انجام نشده";
|
||||||
|
|
||||||
|
export type EducationLevel =
|
||||||
|
| ""
|
||||||
|
| "زیر دیپلم"
|
||||||
|
| "دیپلم"
|
||||||
|
| "دانشجو"
|
||||||
|
| "کاردانی"
|
||||||
|
| "کارشناسی"
|
||||||
|
| "کارشناسی ارشد"
|
||||||
|
| "دکترا";
|
||||||
|
|
||||||
|
export type HousingStatus =
|
||||||
|
| ""
|
||||||
|
| "منزل شخصی"
|
||||||
|
| "منزل والدین"
|
||||||
|
| "منزل استیجاری"
|
||||||
|
| "سایر";
|
||||||
|
|
||||||
|
export interface PersonalInfoFormValues {
|
||||||
|
maritalStatus: string;
|
||||||
|
|
||||||
|
militaryStatus: MilitaryStatus;
|
||||||
|
permanentExemptionReason: string;
|
||||||
|
|
||||||
|
fatherEducation: EducationLevel;
|
||||||
|
fatherJob: string;
|
||||||
|
motherEducation: EducationLevel;
|
||||||
|
motherJob: string;
|
||||||
|
|
||||||
|
housingStatus: HousingStatus;
|
||||||
|
city: string;
|
||||||
|
address: string;
|
||||||
|
homePhone: string;
|
||||||
|
mobilePhone: string;
|
||||||
|
emergencyPhone: string;
|
||||||
|
email: string;
|
||||||
|
residenceDuration: number | "";
|
||||||
|
isVeteran: boolean;
|
||||||
|
|
||||||
|
hasCriminalRecord: boolean;
|
||||||
|
criminalDescription: string;
|
||||||
|
|
||||||
|
spouseName: string;
|
||||||
|
spouseEducation: string;
|
||||||
|
spouseJob: string;
|
||||||
|
spouseWorkplace: string;
|
||||||
|
childrenCount: number | "";
|
||||||
|
}
|
||||||
112
ui/forms/personal/validation/PersonalInfoFormValidation.tsx
Normal file
112
ui/forms/personal/validation/PersonalInfoFormValidation.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// PersonalInfoForm.validation.ts
|
||||||
|
import * as yup from "yup";
|
||||||
|
import { PersonalInfoFormValues } from "../types";
|
||||||
|
|
||||||
|
export const PersonalInfoValidationSchema = yup
|
||||||
|
.object({
|
||||||
|
maritalStatus: yup.string().trim().required("وضعیت تاهل را انتخاب کنید"),
|
||||||
|
|
||||||
|
militaryStatus: yup
|
||||||
|
.mixed<PersonalInfoFormValues["militaryStatus"]>()
|
||||||
|
.oneOf([
|
||||||
|
"",
|
||||||
|
"کارت پایان خدمت",
|
||||||
|
"در حال خدمت",
|
||||||
|
"معافیت تحصیلی",
|
||||||
|
"معافیت دائم",
|
||||||
|
"انجام نشده",
|
||||||
|
])
|
||||||
|
.required("وضعیت نظام وظیفه را انتخاب کنید"),
|
||||||
|
|
||||||
|
permanentExemptionReason: yup
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.when("militaryStatus", {
|
||||||
|
is: "معافیت دائم",
|
||||||
|
then: (s) => s.required("علت معافیت دائم الزامی است"),
|
||||||
|
otherwise: (s) => s.notRequired(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
fatherEducation: yup.string().required("تحصیلات پدر را انتخاب کنید"),
|
||||||
|
fatherJob: yup.string().trim().required("شغل پدر الزامی است"),
|
||||||
|
motherEducation: yup.string().required("تحصیلات مادر را انتخاب کنید"),
|
||||||
|
motherJob: yup.string().trim().required("شغل مادر الزامی است"),
|
||||||
|
|
||||||
|
housingStatus: yup
|
||||||
|
.mixed<PersonalInfoFormValues["housingStatus"]>()
|
||||||
|
.oneOf(["", "منزل شخصی", "منزل والدین", "منزل استیجاری", "سایر"])
|
||||||
|
.required("وضعیت مسکن را انتخاب کنید"),
|
||||||
|
city: yup.string().trim().required("شهر الزامی است"),
|
||||||
|
address: yup.string().trim().required("آدرس الزامی است"),
|
||||||
|
|
||||||
|
homePhone: yup.string().trim().notRequired(),
|
||||||
|
mobilePhone: yup.string().trim().required("تلفن همراه الزامی است"),
|
||||||
|
emergencyPhone: yup.string().trim().notRequired(),
|
||||||
|
email: yup.string().trim().email("ایمیل نامعتبر است").notRequired(),
|
||||||
|
|
||||||
|
residenceDuration: yup
|
||||||
|
.mixed<number | "">()
|
||||||
|
.test(
|
||||||
|
"residenceDuration",
|
||||||
|
"مدت سکونت نامعتبر است",
|
||||||
|
(v) => v === "" || (typeof v === "number" && v >= 0),
|
||||||
|
)
|
||||||
|
.notRequired(),
|
||||||
|
|
||||||
|
isVeteran: yup.boolean().required(),
|
||||||
|
|
||||||
|
hasCriminalRecord: yup.boolean().required(),
|
||||||
|
criminalDescription: yup
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.when("hasCriminalRecord", {
|
||||||
|
is: true,
|
||||||
|
then: (s) => s.required("توضیحات سوء پیشینه الزامی است"),
|
||||||
|
otherwise: (s) => s.notRequired(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
spouseName: yup
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.when("maritalStatus", {
|
||||||
|
is: "متاهل",
|
||||||
|
then: (s) => s.required("نام همسر الزامی است"),
|
||||||
|
otherwise: (s) => s.notRequired(),
|
||||||
|
}),
|
||||||
|
spouseEducation: yup
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.when("maritalStatus", {
|
||||||
|
is: "متاهل",
|
||||||
|
then: (s) => s.required("تحصیلات همسر الزامی است"),
|
||||||
|
otherwise: (s) => s.notRequired(),
|
||||||
|
}),
|
||||||
|
spouseJob: yup
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.when("maritalStatus", {
|
||||||
|
is: "متاهل",
|
||||||
|
then: (s) => s.required("شغل همسر الزامی است"),
|
||||||
|
otherwise: (s) => s.notRequired(),
|
||||||
|
}),
|
||||||
|
spouseWorkplace: yup
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.when("maritalStatus", {
|
||||||
|
is: "متاهل",
|
||||||
|
then: (s) => s.required("محل کار همسر الزامی است"),
|
||||||
|
otherwise: (s) => s.notRequired(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
childrenCount: yup.mixed<number | "">().when("maritalStatus", {
|
||||||
|
is: (ms: string) => ["متاهل", "متارکه", "فوت همسر"].includes(ms),
|
||||||
|
then: (s) =>
|
||||||
|
s.test(
|
||||||
|
"childrenCount",
|
||||||
|
"تعداد فرزند نامعتبر است",
|
||||||
|
(v) => v === "" || (typeof v === "number" && v >= 0),
|
||||||
|
),
|
||||||
|
otherwise: (s) => s.notRequired(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.required();
|
||||||
297
ui/forms/physicalInfo/InnerPhysicalInfoForm.tsx
Normal file
297
ui/forms/physicalInfo/InnerPhysicalInfoForm.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
// InnerPhysicalInfoForm.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo } from "react";
|
||||||
|
import { Box, Button, MenuItem, TextField } from "@mui/material";
|
||||||
|
import { Form, type FormikProps } from "formik";
|
||||||
|
import { PhysicalInfoFormValues } from "./types";
|
||||||
|
import { PhysicalInfoFormProps } from "./PhysicalInfoForm";
|
||||||
|
import { BLOOD_TYPE_OPTIONS } from "./constants";
|
||||||
|
import { handleBack } from "@/core/utils";
|
||||||
|
|
||||||
|
type Props = FormikProps<PhysicalInfoFormValues> & PhysicalInfoFormProps;
|
||||||
|
|
||||||
|
function round1(n: number) {
|
||||||
|
return Math.round(n * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InnerPhysicalInfoForm(props: Props) {
|
||||||
|
const { values, errors, touched, setFieldValue, handleChange } = props;
|
||||||
|
|
||||||
|
const computedBmi = useMemo(() => {
|
||||||
|
if (values.height === "" || values.weight === "") return "";
|
||||||
|
const hMeters = Number(values.height) / 100;
|
||||||
|
if (!hMeters || hMeters <= 0) return "";
|
||||||
|
const bmi = Number(values.weight) / (hMeters * hMeters);
|
||||||
|
return Number.isFinite(bmi) ? round1(bmi) : "";
|
||||||
|
}, [values.height, values.weight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (values.bmi !== computedBmi) {
|
||||||
|
setFieldValue("bmi", computedBmi, false);
|
||||||
|
}
|
||||||
|
}, [computedBmi, setFieldValue, values.bmi]);
|
||||||
|
|
||||||
|
const tf = <K extends keyof PhysicalInfoFormValues>(name: K) => ({
|
||||||
|
name: String(name),
|
||||||
|
value: values[name] as any,
|
||||||
|
onChange: handleChange,
|
||||||
|
fullWidth: true,
|
||||||
|
error: !!(touched as any)[name] && !!(errors as any)[name],
|
||||||
|
helperText: (touched as any)[name] ? ((errors as any)[name] as string) : "",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(props.errors)
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" },
|
||||||
|
gap: 2,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
{/* bloodType */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="گروه خونی"
|
||||||
|
name="bloodType"
|
||||||
|
value={values.bloodType}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.bloodType && !!errors.bloodType}
|
||||||
|
helperText={touched.bloodType ? (errors.bloodType as string) : ""}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{BLOOD_TYPE_OPTIONS.map((bt) => (
|
||||||
|
<MenuItem key={bt} value={bt}>
|
||||||
|
{bt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* height */}
|
||||||
|
<TextField
|
||||||
|
label="قد (سانتیمتر)"
|
||||||
|
name="height"
|
||||||
|
type="number"
|
||||||
|
value={values.height}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFieldValue(
|
||||||
|
"height",
|
||||||
|
e.target.value === "" ? "" : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.height && !!errors.height}
|
||||||
|
helperText={touched.height ? (errors.height as string) : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* weight */}
|
||||||
|
<TextField
|
||||||
|
label="وزن (کیلوگرم)"
|
||||||
|
name="weight"
|
||||||
|
type="number"
|
||||||
|
value={values.weight}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFieldValue(
|
||||||
|
"weight",
|
||||||
|
e.target.value === "" ? "" : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.weight && !!errors.weight}
|
||||||
|
helperText={touched.weight ? (errors.weight as string) : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* bmi */}
|
||||||
|
<TextField
|
||||||
|
label="BMI"
|
||||||
|
name="bmi"
|
||||||
|
type="number"
|
||||||
|
value={values.bmi}
|
||||||
|
fullWidth
|
||||||
|
disabled
|
||||||
|
error={!!touched.bmi && !!errors.bmi}
|
||||||
|
helperText={
|
||||||
|
touched.bmi && errors.bmi
|
||||||
|
? (errors.bmi as string)
|
||||||
|
: values.height !== "" && values.weight !== ""
|
||||||
|
? "بهصورت خودکار از قد و وزن محاسبه میشود"
|
||||||
|
: "برای محاسبه BMI، قد و وزن را وارد کنید"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* specialMark */}
|
||||||
|
<TextField label="علامت مشخصه" {...tf("specialMark")} />
|
||||||
|
|
||||||
|
{/* hasDisability */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="معلولیت دارد؟"
|
||||||
|
name="hasDisability"
|
||||||
|
value={String(values.hasDisability)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value === "true";
|
||||||
|
setFieldValue("hasDisability", next);
|
||||||
|
if (!next) {
|
||||||
|
setFieldValue("disabilityDescription", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.hasDisability && !!errors.hasDisability}
|
||||||
|
helperText={
|
||||||
|
touched.hasDisability ? (errors.hasDisability as string) : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="false">خیر</MenuItem>
|
||||||
|
<MenuItem value="true">بله</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* hasChronicDisease */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="بیماری مزمن دارد؟"
|
||||||
|
name="hasChronicDisease"
|
||||||
|
value={String(values.hasChronicDisease)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value === "true";
|
||||||
|
setFieldValue("hasChronicDisease", next);
|
||||||
|
if (!next) {
|
||||||
|
setFieldValue("chronicDiseaseDescription", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
error={!!touched.hasChronicDisease && !!errors.hasChronicDisease}
|
||||||
|
helperText={
|
||||||
|
touched.hasChronicDisease
|
||||||
|
? (errors.hasChronicDisease as string)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="false">خیر</MenuItem>
|
||||||
|
<MenuItem value="true">بله</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* disabilityDescription */}
|
||||||
|
{values.hasDisability && (
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
label="توضیحات معلولیت"
|
||||||
|
name="disabilityDescription"
|
||||||
|
value={values.disabilityDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
error={
|
||||||
|
!!touched.disabilityDescription &&
|
||||||
|
!!errors.disabilityDescription
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
touched.disabilityDescription
|
||||||
|
? (errors.disabilityDescription as string)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* chronicDiseaseDescription */}
|
||||||
|
{values.hasChronicDisease && (
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
label="توضیحات بیماری مزمن"
|
||||||
|
name="chronicDiseaseDescription"
|
||||||
|
value={values.chronicDiseaseDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
error={
|
||||||
|
!!touched.chronicDiseaseDescription &&
|
||||||
|
!!errors.chronicDiseaseDescription
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
touched.chronicDiseaseDescription
|
||||||
|
? (errors.chronicDiseaseDescription as string)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* surgeryHistory */}
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
label="سابقه جراحی"
|
||||||
|
name="surgeryHistory"
|
||||||
|
value={values.surgeryHistory}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
error={!!touched.surgeryHistory && !!errors.surgeryHistory}
|
||||||
|
helperText={
|
||||||
|
touched.surgeryHistory ? (errors.surgeryHistory as string) : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* medications */}
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
label="داروهای مصرفی"
|
||||||
|
name="medications"
|
||||||
|
value={values.medications}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
error={!!touched.medications && !!errors.medications}
|
||||||
|
helperText={
|
||||||
|
touched.medications ? (errors.medications as string) : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mt: 5,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={props.step === 1}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBack(props,"physicalInfo")}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
ui/forms/physicalInfo/PhysicalInfoForm.tsx
Normal file
52
ui/forms/physicalInfo/PhysicalInfoForm.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// PhysicalInfoForm.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { withFormik, type FormikBag } from "formik";
|
||||||
|
|
||||||
|
import InnerPhysicalInfoForm from "./InnerPhysicalInfoForm";
|
||||||
|
import { PhysicalInfoFormValues } from "./types";
|
||||||
|
import { PHYSICAL_INFO_EMPTY_VALUES } from "./constants";
|
||||||
|
import { PhysicalInfoValidationSchema } from "./validation";
|
||||||
|
|
||||||
|
/** این بخش را با ساختار اصلی WizardFormData پروژه خودت هماهنگ کن */
|
||||||
|
export interface WizardFormData {
|
||||||
|
physicalInfo: PhysicalInfoFormValues;
|
||||||
|
// ... بقیه step ها
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PhysicalInfoFormProps = {
|
||||||
|
step: number;
|
||||||
|
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
data: WizardFormData;
|
||||||
|
update: (patch: Partial<WizardFormData>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PhysicalInfoForm = withFormik<
|
||||||
|
PhysicalInfoFormProps,
|
||||||
|
PhysicalInfoFormValues
|
||||||
|
>({
|
||||||
|
displayName: "PhysicalInfoForm",
|
||||||
|
|
||||||
|
enableReinitialize: true,
|
||||||
|
|
||||||
|
mapPropsToValues: (props) => {
|
||||||
|
return props.data?.physicalInfo ?? PHYSICAL_INFO_EMPTY_VALUES;
|
||||||
|
},
|
||||||
|
|
||||||
|
// validationSchema: PhysicalInfoValidationSchema,
|
||||||
|
|
||||||
|
handleSubmit: async (
|
||||||
|
values,
|
||||||
|
bag: FormikBag<PhysicalInfoFormProps, PhysicalInfoFormValues>,
|
||||||
|
) => {
|
||||||
|
const { props, setSubmitting } = bag;
|
||||||
|
|
||||||
|
props.update({ physicalInfo: values });
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
})(InnerPhysicalInfoForm);
|
||||||
|
|
||||||
|
export default PhysicalInfoForm;
|
||||||
35
ui/forms/physicalInfo/constants/index.ts
Normal file
35
ui/forms/physicalInfo/constants/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// physical-info.constants.ts
|
||||||
|
|
||||||
|
import { BloodType, PhysicalInfoFormValues } from "../types";
|
||||||
|
|
||||||
|
|
||||||
|
export const PHYSICAL_INFO_EMPTY_VALUES: PhysicalInfoFormValues = {
|
||||||
|
applicantId: "",
|
||||||
|
|
||||||
|
bloodType: "",
|
||||||
|
height: "",
|
||||||
|
weight: "",
|
||||||
|
bmi: "",
|
||||||
|
|
||||||
|
hasDisability: false,
|
||||||
|
disabilityDescription: "",
|
||||||
|
|
||||||
|
hasChronicDisease: false,
|
||||||
|
chronicDiseaseDescription: "",
|
||||||
|
|
||||||
|
surgeryHistory: "",
|
||||||
|
medications: "",
|
||||||
|
|
||||||
|
specialMark: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLOOD_TYPE_OPTIONS: Exclude<BloodType, "">[] = [
|
||||||
|
"A+",
|
||||||
|
"A-",
|
||||||
|
"B+",
|
||||||
|
"B-",
|
||||||
|
"AB+",
|
||||||
|
"AB-",
|
||||||
|
"O+",
|
||||||
|
"O-",
|
||||||
|
];
|
||||||
32
ui/forms/physicalInfo/types/index.ts
Normal file
32
ui/forms/physicalInfo/types/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// physical-info.types.ts
|
||||||
|
|
||||||
|
export type BloodType =
|
||||||
|
| ""
|
||||||
|
| "A+"
|
||||||
|
| "A-"
|
||||||
|
| "B+"
|
||||||
|
| "B-"
|
||||||
|
| "AB+"
|
||||||
|
| "AB-"
|
||||||
|
| "O+"
|
||||||
|
| "O-";
|
||||||
|
|
||||||
|
export interface PhysicalInfoFormValues {
|
||||||
|
applicantId: string;
|
||||||
|
|
||||||
|
bloodType: BloodType;
|
||||||
|
height: number | ""; // cm
|
||||||
|
weight: number | ""; // kg
|
||||||
|
bmi: number | ""; // auto calculated
|
||||||
|
|
||||||
|
hasDisability: boolean;
|
||||||
|
disabilityDescription: string;
|
||||||
|
|
||||||
|
hasChronicDisease: boolean;
|
||||||
|
chronicDiseaseDescription: string;
|
||||||
|
|
||||||
|
surgeryHistory: string;
|
||||||
|
medications: string;
|
||||||
|
|
||||||
|
specialMark: string;
|
||||||
|
}
|
||||||
68
ui/forms/physicalInfo/validation/index.ts
Normal file
68
ui/forms/physicalInfo/validation/index.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// PhysicalInfoForm.validation.ts
|
||||||
|
import * as yup from "yup";
|
||||||
|
import { PhysicalInfoFormValues } from "../types";
|
||||||
|
|
||||||
|
export const PhysicalInfoValidationSchema =
|
||||||
|
yup
|
||||||
|
.object({
|
||||||
|
// applicantId: yup.string().trim().required("کد متقاضی الزامی است"),
|
||||||
|
|
||||||
|
bloodType: yup
|
||||||
|
.mixed<PhysicalInfoFormValues["bloodType"]>()
|
||||||
|
.oneOf(["", "A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"])
|
||||||
|
.required("گروه خونی را انتخاب کنید"),
|
||||||
|
|
||||||
|
height: yup
|
||||||
|
.mixed<number | "">()
|
||||||
|
.test(
|
||||||
|
"height-valid",
|
||||||
|
"قد نامعتبر است",
|
||||||
|
(v) => v === "" || (typeof v === "number" && v > 0 && v <= 300),
|
||||||
|
)
|
||||||
|
.required("قد الزامی است"),
|
||||||
|
|
||||||
|
weight: yup
|
||||||
|
.mixed<number | "">()
|
||||||
|
.test(
|
||||||
|
"weight-valid",
|
||||||
|
"وزن نامعتبر است",
|
||||||
|
(v) => v === "" || (typeof v === "number" && v > 0 && v <= 500),
|
||||||
|
)
|
||||||
|
.required("وزن الزامی است"),
|
||||||
|
|
||||||
|
bmi: yup
|
||||||
|
.mixed<number | "">()
|
||||||
|
.test(
|
||||||
|
"bmi-valid",
|
||||||
|
"BMI نامعتبر است يا قد و وزن اشتباه است",
|
||||||
|
(v) => v === "" || (typeof v === "number" && v > 0 && v <= 100),
|
||||||
|
)
|
||||||
|
.notRequired(),
|
||||||
|
|
||||||
|
hasDisability: yup.boolean().required(),
|
||||||
|
|
||||||
|
disabilityDescription: yup
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.when("hasDisability", {
|
||||||
|
is: true,
|
||||||
|
then: (s) => s.required("توضیحات معلولیت الزامی است"),
|
||||||
|
otherwise: (s) => s.notRequired(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
hasChronicDisease: yup.boolean().required(),
|
||||||
|
|
||||||
|
chronicDiseaseDescription: yup
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.when("hasChronicDisease", {
|
||||||
|
is: true,
|
||||||
|
then: (s) => s.required("توضیحات بیماری مزمن الزامی است"),
|
||||||
|
otherwise: (s) => s.notRequired(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
surgeryHistory: yup.string().trim().notRequired(),
|
||||||
|
medications: yup.string().trim().notRequired(),
|
||||||
|
specialMark: yup.string().trim().notRequired(),
|
||||||
|
})
|
||||||
|
.required();
|
||||||
320
ui/forms/referral/InnerReferralForm.tsx
Normal file
320
ui/forms/referral/InnerReferralForm.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Button,
|
||||||
|
MenuItem,
|
||||||
|
Divider,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import { DeleteOutlineOutlined } from "@mui/icons-material";
|
||||||
|
import { FieldArray, Form, getIn, type FormikProps } from "formik";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ReferralFormProps,
|
||||||
|
ReferralFormValues,
|
||||||
|
ReferralItem,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
REFERRAL_EMPTY_ITEM,
|
||||||
|
REFERRAL_MIN_ITEMS,
|
||||||
|
} from "./constant";
|
||||||
|
|
||||||
|
type Props = FormikProps<ReferralFormValues> & ReferralFormProps;
|
||||||
|
|
||||||
|
function ReferralItemForm({
|
||||||
|
index,
|
||||||
|
item,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
handleChange,
|
||||||
|
setFieldValue,
|
||||||
|
onRemove,
|
||||||
|
disableRemove,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
item: ReferralItem;
|
||||||
|
errors: any;
|
||||||
|
touched: any;
|
||||||
|
handleChange: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>;
|
||||||
|
setFieldValue: (field: string, value: any, shouldValidate?: boolean) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
disableRemove: boolean;
|
||||||
|
}) {
|
||||||
|
const itemErrors = getIn(errors, `referrals.${index}`) || {};
|
||||||
|
const itemTouched = getIn(touched, `referrals.${index}`) || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2, md: 2.5 },
|
||||||
|
borderRadius: "20px",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 1.5,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontWeight: 700 }}>معرف {index + 1}</Typography>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={disableRemove}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
aria-label="حذف معرف"
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="نام"
|
||||||
|
name={`referrals.${index}.firstName`}
|
||||||
|
value={item.firstName}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!itemTouched.firstName && !!itemErrors.firstName}
|
||||||
|
helperText={itemTouched.firstName ? itemErrors.firstName : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نام خانوادگی"
|
||||||
|
name={`referrals.${index}.lastName`}
|
||||||
|
value={item.lastName}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!itemTouched.lastName && !!itemErrors.lastName}
|
||||||
|
helperText={itemTouched.lastName ? itemErrors.lastName : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نسبت / رابطه"
|
||||||
|
name={`referrals.${index}.relationship`}
|
||||||
|
value={item.relationship}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
placeholder="مثلاً: دوست، همکار، فامیل..."
|
||||||
|
error={!!itemTouched.relationship && !!itemErrors.relationship}
|
||||||
|
helperText={itemTouched.relationship ? itemErrors.relationship : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="مدت زمان آشنایی"
|
||||||
|
name={`referrals.${index}.acquaintanceDuration`}
|
||||||
|
value={item.acquaintanceDuration}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
placeholder="مثلاً: ۵ سال"
|
||||||
|
error={
|
||||||
|
!!itemTouched.acquaintanceDuration &&
|
||||||
|
!!itemErrors.acquaintanceDuration
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.acquaintanceDuration
|
||||||
|
? itemErrors.acquaintanceDuration
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="نوع آشنایی"
|
||||||
|
name={`referrals.${index}.acquaintanceType`}
|
||||||
|
value={item.acquaintanceType}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={
|
||||||
|
!!itemTouched.acquaintanceType &&
|
||||||
|
!!itemErrors.acquaintanceType
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.acquaintanceType ? itemErrors.acquaintanceType : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="Direct">مستقیم</MenuItem>
|
||||||
|
<MenuItem value="Indirect">غیرمستقیم</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="تلفن تماس"
|
||||||
|
name={`referrals.${index}.phoneNumber`}
|
||||||
|
value={item.phoneNumber}
|
||||||
|
onChange={(e) => {
|
||||||
|
const onlyDigits = e.target.value.replace(/[^\d]/g, "").slice(0, 11);
|
||||||
|
setFieldValue(`referrals.${index}.phoneNumber`, onlyDigits);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
placeholder="مثلاً: 0912xxxxxxx"
|
||||||
|
error={!!itemTouched.phoneNumber && !!itemErrors.phoneNumber}
|
||||||
|
helperText={itemTouched.phoneNumber ? itemErrors.phoneNumber : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="شغل معرف"
|
||||||
|
name={`referrals.${index}.jobTitle`}
|
||||||
|
value={item.jobTitle}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!itemTouched.jobTitle && !!itemErrors.jobTitle}
|
||||||
|
helperText={itemTouched.jobTitle ? itemErrors.jobTitle : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نام محل کار معرف"
|
||||||
|
name={`referrals.${index}.workplaceName`}
|
||||||
|
value={item.workplaceName}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!itemTouched.workplaceName && !!itemErrors.workplaceName}
|
||||||
|
helperText={itemTouched.workplaceName ? itemErrors.workplaceName : ""}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InnerReferralForm(props: Props) {
|
||||||
|
const {
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
handleChange,
|
||||||
|
setFieldValue,
|
||||||
|
isSubmitting,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
props.update({
|
||||||
|
referrals: values.referrals,
|
||||||
|
});
|
||||||
|
props.setStep(props.step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<FieldArray name="referrals">
|
||||||
|
{({ push, remove }) => (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2, md: 3 },
|
||||||
|
borderRadius: "24px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 2,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||||
|
معرفها
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
push({
|
||||||
|
...REFERRAL_EMPTY_ITEM,
|
||||||
|
id: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
sx={{ borderRadius: "12px", fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
افزودن معرف جدید
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gap: 2 }}>
|
||||||
|
{values.referrals.map((item, idx) => (
|
||||||
|
<ReferralItemForm
|
||||||
|
key={item.id || idx}
|
||||||
|
index={idx}
|
||||||
|
item={item}
|
||||||
|
errors={errors}
|
||||||
|
touched={touched}
|
||||||
|
handleChange={handleChange}
|
||||||
|
setFieldValue={setFieldValue}
|
||||||
|
onRemove={() => remove(idx)}
|
||||||
|
disableRemove={values.referrals.length <= REFERRAL_MIN_ITEMS}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{typeof errors.referrals === "string" && (
|
||||||
|
<Typography color="error" sx={{ mt: 2 }}>
|
||||||
|
{errors.referrals}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mt: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={props.step === 1 || isSubmitting}
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
color: "#64748b",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بازگشت
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: props.step === 12 ? "green" : "#2563eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.step === 12 ? "اتمام و ثبت نهایی" : "گام بعدی"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
ui/forms/referral/ReferralForm.tsx
Normal file
40
ui/forms/referral/ReferralForm.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { withFormik, type FormikBag } from "formik";
|
||||||
|
import type { ReferralFormProps, ReferralFormValues } from "./types";
|
||||||
|
import { REFERRAL_EMPTY_VALUES } from "./constant";
|
||||||
|
import { ReferralValidationSchema } from "./validation";
|
||||||
|
import InnerReferralForm from "./InnerReferralForm";
|
||||||
|
|
||||||
|
const ReferralForm = withFormik<ReferralFormProps, ReferralFormValues>({
|
||||||
|
displayName: "ReferralForm",
|
||||||
|
|
||||||
|
enableReinitialize: true,
|
||||||
|
|
||||||
|
mapPropsToValues: (props) => {
|
||||||
|
return {
|
||||||
|
referrals:
|
||||||
|
props.data?.referrals?.length > 0
|
||||||
|
? props.data.referrals
|
||||||
|
: REFERRAL_EMPTY_VALUES.referrals,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
validationSchema: ReferralValidationSchema,
|
||||||
|
|
||||||
|
handleSubmit: async (
|
||||||
|
values,
|
||||||
|
bag: FormikBag<ReferralFormProps, ReferralFormValues>,
|
||||||
|
) => {
|
||||||
|
const { props, setSubmitting } = bag;
|
||||||
|
|
||||||
|
props.update({
|
||||||
|
referrals: values.referrals,
|
||||||
|
});
|
||||||
|
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
})(InnerReferralForm);
|
||||||
|
|
||||||
|
export default ReferralForm;
|
||||||
19
ui/forms/referral/constant/index.ts
Normal file
19
ui/forms/referral/constant/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReferralFormValues, ReferralItem } from "../types";
|
||||||
|
|
||||||
|
export const REFERRAL_EMPTY_ITEM: ReferralItem = {
|
||||||
|
id: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
relationship: "",
|
||||||
|
acquaintanceDuration: "",
|
||||||
|
acquaintanceType: "Direct",
|
||||||
|
jobTitle: "",
|
||||||
|
workplaceName: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REFERRAL_EMPTY_VALUES: ReferralFormValues = {
|
||||||
|
referrals: [{ ...REFERRAL_EMPTY_ITEM }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REFERRAL_MIN_ITEMS = 1;
|
||||||
32
ui/forms/referral/types/index.ts
Normal file
32
ui/forms/referral/types/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
export type AcquaintanceType = "Direct" | "Indirect";
|
||||||
|
|
||||||
|
export interface ReferralItem {
|
||||||
|
id?: string | number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
relationship: string;
|
||||||
|
acquaintanceDuration: string;
|
||||||
|
acquaintanceType: AcquaintanceType;
|
||||||
|
jobTitle: string;
|
||||||
|
workplaceName: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferralFormValues {
|
||||||
|
referrals: ReferralItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** با مدل اصلی wizard پروژه خودت هماهنگش کن */
|
||||||
|
export interface WizardFormData {
|
||||||
|
referrals: ReferralItem[];
|
||||||
|
// ... سایر stepها
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferralFormProps {
|
||||||
|
step: number;
|
||||||
|
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
data: WizardFormData;
|
||||||
|
update: (patch: Partial<WizardFormData>) => void;
|
||||||
|
}
|
||||||
24
ui/forms/referral/validation/index.ts
Normal file
24
ui/forms/referral/validation/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
export const ReferralValidationSchema = Yup.object({
|
||||||
|
referrals: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
id: Yup.mixed().optional(),
|
||||||
|
firstName: Yup.string().required("نام الزامی است"),
|
||||||
|
lastName: Yup.string().required("نام خانوادگی الزامی است"),
|
||||||
|
relationship: Yup.string().required("نسبت / رابطه الزامی است"),
|
||||||
|
acquaintanceDuration: Yup.string().optional(),
|
||||||
|
acquaintanceType: Yup.mixed<"Direct" | "Indirect">()
|
||||||
|
.oneOf(["Direct", "Indirect"], "نوع آشنایی نامعتبر است")
|
||||||
|
.required("نوع آشنایی الزامی است"),
|
||||||
|
jobTitle: Yup.string().optional(),
|
||||||
|
workplaceName: Yup.string().optional(),
|
||||||
|
phoneNumber: Yup.string()
|
||||||
|
.required("تلفن تماس الزامی است")
|
||||||
|
.matches(/^09\d{9}$/, "شماره تماس باید با 09 شروع شده و 11 رقم باشد"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, "حداقل یک معرف باید ثبت شود")
|
||||||
|
.required("ثبت معرف الزامی است"),
|
||||||
|
});
|
||||||
@@ -9,16 +9,19 @@ import {
|
|||||||
FormHelperText,
|
FormHelperText,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
import { Form, FormikProps } from "formik";
|
import { ErrorMessage, Form, FormikProps } from "formik";
|
||||||
import BusinessIcon from "@mui/icons-material/Business";
|
import BusinessIcon from "@mui/icons-material/Business";
|
||||||
import LocationOnIcon from "@mui/icons-material/LocationOn";
|
import LocationOnIcon from "@mui/icons-material/LocationOn";
|
||||||
import LocalHospitalIcon from "@mui/icons-material/LocalHospital";
|
import LocalHospitalIcon from "@mui/icons-material/LocalHospital";
|
||||||
import { useGetAllCenters } from "@/hooks/center.hook";
|
import { useGetAllCenters, useSelectCenter } from "@/hooks/center.hook";
|
||||||
import {
|
import {
|
||||||
RegistrationCenterFormProps,
|
RegistrationCenterFormProps,
|
||||||
RegistrationCenterFormValues,
|
RegistrationCenterFormValues,
|
||||||
} from "./RegistrationCenterForm";
|
} from "./RegistrationCenterForm";
|
||||||
import { CenterItem } from "@/core/types";
|
import { CenterItem } from "@/core/types";
|
||||||
|
import { Warning } from "@mui/icons-material";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
// تعریف اینترفیس برای تمیزی بیشتر
|
// تعریف اینترفیس برای تمیزی بیشتر
|
||||||
interface InnerFormProps
|
interface InnerFormProps
|
||||||
@@ -27,33 +30,26 @@ interface InnerFormProps
|
|||||||
RegistrationCenterFormProps {}
|
RegistrationCenterFormProps {}
|
||||||
|
|
||||||
export default function InnerRegistrationCenterForm(props: InnerFormProps) {
|
export default function InnerRegistrationCenterForm(props: InnerFormProps) {
|
||||||
const { data } = useGetAllCenters();
|
const { data,error } = useGetAllCenters();
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = useSelectCenter();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
props.update({ registrationCenter: props.values});
|
props.update({ registrationCenter: props.values });
|
||||||
props.setStep((prev) => Math.max(1, prev - 1));
|
props.setStep((prev) => Math.max(1, prev - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
// منطق نمایش خطا
|
|
||||||
const isSelectedCenterError =
|
|
||||||
(props.touched.selectedCenter || props.submitCount > 0) &&
|
|
||||||
!!props.errors.selectedCenter;
|
|
||||||
|
|
||||||
const handleCenterSelect = (center: CenterItem) => {
|
const handleCenterSelect = (center: CenterItem) => {
|
||||||
props.setFieldValue("selectedCenter", center);
|
props.setFieldValue("selectedCenter", center);
|
||||||
props.setFieldTouched("selectedCenter", true, true); // فعال کردن حالت لمس شده
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
// اگر مرحله اول است، فیلد را لمس کن تا اگر خالی بود خطا نشان دهد
|
|
||||||
if (props.step === 1) {
|
|
||||||
props.setFieldTouched("selectedCenter", true, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// اعتبارسنجی دستی کل فرم
|
// اعتبارسنجی دستی کل فرم
|
||||||
const errors = await props.validateForm();
|
const errors = await props.validateForm();
|
||||||
|
props.update({
|
||||||
|
registrationCenter: props.values,
|
||||||
|
});
|
||||||
// اگر در گام فعلی خطایی وجود ندارد، برو مرحله بعد
|
// اگر در گام فعلی خطایی وجود ندارد، برو مرحله بعد
|
||||||
if (Object.keys(errors).length === 0) {
|
if (Object.keys(errors).length === 0) {
|
||||||
if (props.step === 12) {
|
if (props.step === 12) {
|
||||||
@@ -62,8 +58,32 @@ export default function InnerRegistrationCenterForm(props: InnerFormProps) {
|
|||||||
props.setStep(props.step + 1);
|
props.setStep(props.step + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const applicant = await mutateAsync(props.values.selectedCenter?.id!);
|
||||||
|
|
||||||
|
// props.update({
|
||||||
|
// applicantId: applicant.id,
|
||||||
|
// registrationCenter: props.values,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// localStorage.setItem(
|
||||||
|
// "applicationDraft",
|
||||||
|
// JSON.stringify({
|
||||||
|
// applicantId: applicant.id,
|
||||||
|
// registrationCenter: props.values,
|
||||||
|
// formStep: applicant.formStep,
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// props.setStep(2);
|
||||||
|
// } catch (error: any) {
|
||||||
|
// console.log(error)
|
||||||
|
// toast.error(error?.message || "خطا در ثبت مرکز");
|
||||||
|
// }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const renderCenterList = () => (
|
const renderCenterList = () => (
|
||||||
<Box sx={{ width: "100%", gridColumn: "1 / -1" }}>
|
<Box sx={{ width: "100%", gridColumn: "1 / -1" }}>
|
||||||
<div className="w-full grid grid-cols-2 gap-4">
|
<div className="w-full grid grid-cols-2 gap-4">
|
||||||
@@ -78,7 +98,7 @@ export default function InnerRegistrationCenterForm(props: InnerFormProps) {
|
|||||||
borderRadius: "18px",
|
borderRadius: "18px",
|
||||||
border: isSelected
|
border: isSelected
|
||||||
? "2px solid #2563eb"
|
? "2px solid #2563eb"
|
||||||
: isSelectedCenterError
|
: props.errors.selectedCenter
|
||||||
? "2px solid #d32f2f"
|
? "2px solid #d32f2f"
|
||||||
: "1px solid #e2e8f0",
|
: "1px solid #e2e8f0",
|
||||||
backgroundColor: isSelected ? "#eff6ff" : "#fff",
|
backgroundColor: isSelected ? "#eff6ff" : "#fff",
|
||||||
@@ -106,6 +126,27 @@ export default function InnerRegistrationCenterForm(props: InnerFormProps) {
|
|||||||
{center.address}
|
{center.address}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
{center.isUrgent && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* <Warning sx={{ color: "red", fontSize: 14 }} /> */}
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "red",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
استخدام فوري
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{isSelected && <CheckCircleIcon sx={{ color: "#2563eb" }} />}
|
{isSelected && <CheckCircleIcon sx={{ color: "#2563eb" }} />}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -115,14 +156,7 @@ export default function InnerRegistrationCenterForm(props: InnerFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* نمایش پیام خطا به صورت تمیز زیر لیست */}
|
{/* نمایش پیام خطا به صورت تمیز زیر لیست */}
|
||||||
{isSelectedCenterError && (
|
<ErrorMessage name="selectedCenter" component={"div"} />
|
||||||
<FormHelperText
|
|
||||||
error
|
|
||||||
sx={{ mt: 2, fontSize: "0.9rem", fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
{props.errors.selectedCenter as string}
|
|
||||||
</FormHelperText>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface RegistrationCenterFormValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WizardFormData {
|
export interface WizardFormData {
|
||||||
|
applicantId: string;
|
||||||
registrationCenter: {
|
registrationCenter: {
|
||||||
selectedCenter: CenterItem | null;
|
selectedCenter: CenterItem | null;
|
||||||
};
|
};
|
||||||
|
|||||||
190
ui/forms/relation/InnerRelationForm.tsx
Normal file
190
ui/forms/relation/InnerRelationForm.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
Divider,
|
||||||
|
Button,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { FieldArray, Form, getIn, type FormikProps } from "formik";
|
||||||
|
import type { RelationFormProps, RelationFormValues } from "./types";
|
||||||
|
|
||||||
|
type Props = FormikProps<RelationFormValues> & RelationFormProps;
|
||||||
|
|
||||||
|
export default function InnerRelationForm(props: Props) {
|
||||||
|
const {
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
handleChange,
|
||||||
|
setFieldValue,
|
||||||
|
isSubmitting,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
props.update({ relations: values.relations });
|
||||||
|
props.setStep(props.step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2, md: 3 },
|
||||||
|
borderRadius: "24px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert severity="warning" sx={{ mb: 3, borderRadius: "12px" }}>
|
||||||
|
<AlertTitle sx={{ fontWeight: 800 }}>توجه</AlertTitle>
|
||||||
|
مشخصات دو نفر از آشنایان را وارد کنید و از درج بستگان درجه یک (پدر،
|
||||||
|
مادر، همسر، برادر و خواهر) خودداری نمایید.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<FieldArray name="relations">
|
||||||
|
{() => (
|
||||||
|
<Box>
|
||||||
|
{values.relations.map((item, index) => {
|
||||||
|
const itemErrors = getIn(errors, `relations.${index}`) || {};
|
||||||
|
const itemTouched = getIn(touched, `relations.${index}`) || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={item.id || index} sx={{ mb: index === 0 ? 4 : 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{ fontWeight: 800, mb: 2, color: "#1e293b" }}
|
||||||
|
>
|
||||||
|
آشنای {index === 0 ? "اول" : "دوم"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||||
|
gap: 2.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="نام*"
|
||||||
|
name={`relations.${index}.firstName`}
|
||||||
|
value={item.firstName}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!itemTouched.firstName && !!itemErrors.firstName}
|
||||||
|
helperText={itemTouched.firstName ? itemErrors.firstName : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نام خانوادگی*"
|
||||||
|
name={`relations.${index}.lastName`}
|
||||||
|
value={item.lastName}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!itemTouched.lastName && !!itemErrors.lastName}
|
||||||
|
helperText={itemTouched.lastName ? itemErrors.lastName : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نسبت*"
|
||||||
|
name={`relations.${index}.relationship`}
|
||||||
|
value={item.relationship}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
placeholder="مثلاً: همکار، دوست"
|
||||||
|
error={!!itemTouched.relationship && !!itemErrors.relationship}
|
||||||
|
helperText={
|
||||||
|
itemTouched.relationship ? itemErrors.relationship : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="تلفن تماس*"
|
||||||
|
name={`relations.${index}.phoneNumber`}
|
||||||
|
value={item.phoneNumber}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value.replace(/[^\d]/g, "");
|
||||||
|
setFieldValue(`relations.${index}.phoneNumber`, val);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
inputMode="tel"
|
||||||
|
error={!!itemTouched.phoneNumber && !!itemErrors.phoneNumber}
|
||||||
|
helperText={
|
||||||
|
itemTouched.phoneNumber ? itemErrors.phoneNumber : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="شغل*"
|
||||||
|
name={`relations.${index}.jobTitle`}
|
||||||
|
value={item.jobTitle}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={!!itemTouched.jobTitle && !!itemErrors.jobErrors}
|
||||||
|
helperText={itemTouched.jobTitle ? itemErrors.jobTitle : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نام محل کار*"
|
||||||
|
name={`relations.${index}.workplaceName`}
|
||||||
|
value={item.workplaceName}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
error={
|
||||||
|
!!itemTouched.workplaceName && !!itemErrors.workplaceName
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
itemTouched.workplaceName ? itemErrors.workplaceName : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{index === 0 && <Divider sx={{ mt: 4 }} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
|
||||||
|
{/* دکمههای ناوبری */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mt: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={props.step === 1 || isSubmitting}
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{ borderRadius: "12px", color: "#64748b", fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
بازگشت
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: props.step === 12 ? "green" : "#2563eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.step === 12 ? "اتمام و ثبت نهایی" : "گام بعدی"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
ui/forms/relation/RelationForm.tsx
Normal file
36
ui/forms/relation/RelationForm.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { withFormik, type FormikBag } from "formik";
|
||||||
|
import type { RelationFormProps, RelationFormValues } from "./types";
|
||||||
|
import { RELATION_INITIAL_VALUES } from "./constant";
|
||||||
|
import { RelationValidationSchema } from "./validation";
|
||||||
|
import InnerRelationForm from "./InnerRelationForm";
|
||||||
|
|
||||||
|
const RelationForm = withFormik<RelationFormProps, RelationFormValues>({
|
||||||
|
displayName: "RelationForm",
|
||||||
|
|
||||||
|
enableReinitialize: true,
|
||||||
|
|
||||||
|
mapPropsToValues: (props) => {
|
||||||
|
// اگر دادهای از قبل بود استفاده کن، در غیر این صورت مقدار اولیه (۲تایی)
|
||||||
|
if (props.data?.relations?.length === 2) {
|
||||||
|
return { relations: props.data.relations };
|
||||||
|
}
|
||||||
|
return RELATION_INITIAL_VALUES;
|
||||||
|
},
|
||||||
|
|
||||||
|
validationSchema: RelationValidationSchema,
|
||||||
|
|
||||||
|
handleSubmit: async (
|
||||||
|
values,
|
||||||
|
bag: FormikBag<RelationFormProps, RelationFormValues>
|
||||||
|
) => {
|
||||||
|
const { props, setSubmitting } = bag;
|
||||||
|
|
||||||
|
props.update({ relations: values.relations });
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
})(InnerRelationForm);
|
||||||
|
|
||||||
|
export default RelationForm;
|
||||||
18
ui/forms/relation/constant/index.ts
Normal file
18
ui/forms/relation/constant/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ReferenceItem, RelationFormValues } from "../types";
|
||||||
|
|
||||||
|
export const EMPTY_REFERENCE_ITEM: ReferenceItem = {
|
||||||
|
id: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
relationship: "",
|
||||||
|
jobTitle: "",
|
||||||
|
workplaceName: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RELATION_INITIAL_VALUES: RelationFormValues = {
|
||||||
|
relations: [
|
||||||
|
{ ...EMPTY_REFERENCE_ITEM, id: 1 },
|
||||||
|
{ ...EMPTY_REFERENCE_ITEM, id: 2 },
|
||||||
|
],
|
||||||
|
};
|
||||||
28
ui/forms/relation/types/index.ts
Normal file
28
ui/forms/relation/types/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
export interface ReferenceItem {
|
||||||
|
id?: string | number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
relationship: string;
|
||||||
|
jobTitle: string;
|
||||||
|
workplaceName: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationFormValues {
|
||||||
|
relations: ReferenceItem[]; // طول این آرایه همیشه باید 2 باشد
|
||||||
|
}
|
||||||
|
|
||||||
|
/** هماهنگ با استیت کلی ویزارد شما */
|
||||||
|
export interface WizardFormData {
|
||||||
|
relations: ReferenceItem[];
|
||||||
|
// ... سایر مراحل
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationFormProps {
|
||||||
|
step: number;
|
||||||
|
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
data: WizardFormData;
|
||||||
|
update: (patch: Partial<WizardFormData>) => void;
|
||||||
|
}
|
||||||
27
ui/forms/relation/validation/index.ts
Normal file
27
ui/forms/relation/validation/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
export const RelationValidationSchema = Yup.object().shape({
|
||||||
|
relations: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object().shape({
|
||||||
|
firstName: Yup.string().required("نام الزامی است"),
|
||||||
|
lastName: Yup.string().required("نام خانوادگی الزامی است"),
|
||||||
|
relationship: Yup.string()
|
||||||
|
.required("نسبت الزامی است")
|
||||||
|
.test(
|
||||||
|
"no-immediate-family",
|
||||||
|
"درج بستگان درجه یک مجاز نیست",
|
||||||
|
(value) => {
|
||||||
|
const forbidden = ["پدر", "مادر", "همسر", "برادر", "خواهر"];
|
||||||
|
return !forbidden.some((f) => value?.includes(f));
|
||||||
|
}
|
||||||
|
),
|
||||||
|
jobTitle: Yup.string().required("شغل الزامی است"),
|
||||||
|
workplaceName: Yup.string().required("محل کار الزامی است"),
|
||||||
|
phoneNumber: Yup.string()
|
||||||
|
.required("تلفن الزامی است")
|
||||||
|
.matches(/^0\d{10}$/, "شماره تماس معتبر نیست (۱۱ رقم با ۰)"),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.length(2, "باید مشخصات دو نفر را وارد کنید"),
|
||||||
|
});
|
||||||
349
ui/forms/skillsForm/InnerSkillsForm.tsx
Normal file
349
ui/forms/skillsForm/InnerSkillsForm.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Button,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Form, type FormikProps } from "formik";
|
||||||
|
import type { SkillsFormProps, SkillsFormValues } from "./types";
|
||||||
|
import { certTypes, proficiencyOptions } from "./constant";
|
||||||
|
|
||||||
|
type Props = FormikProps<SkillsFormValues> & SkillsFormProps;
|
||||||
|
|
||||||
|
export default function InnerSkillsForm(props: Props) {
|
||||||
|
const { values, errors, touched, handleChange, setFieldValue, isSubmitting } = props;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
props.update({
|
||||||
|
computerSkill: values.computerSkill,
|
||||||
|
languageSkill: values.languageSkill,
|
||||||
|
});
|
||||||
|
props.setStep(props.step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const compFields = [
|
||||||
|
"pcUsage",
|
||||||
|
"word",
|
||||||
|
"excel",
|
||||||
|
"powerPoint",
|
||||||
|
"rahkaran",
|
||||||
|
"kasra",
|
||||||
|
"didgah",
|
||||||
|
"his",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<Box sx={{ display: "grid", gap: 3 }}>
|
||||||
|
|
||||||
|
{/* 1. Computer Skills Section */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2, md: 3 },
|
||||||
|
borderRadius: "24px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ mb: 3, fontWeight: "bold", color: "#0f172a" }}>
|
||||||
|
مهارتهای کامپیوتری
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
||||||
|
{compFields.map((field) => {
|
||||||
|
const hasError = !!errors.computerSkill?.[field] && !!touched.computerSkill?.[field];
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
key={field}
|
||||||
|
select
|
||||||
|
label={field.toUpperCase()}
|
||||||
|
name={`computerSkill.${field}`}
|
||||||
|
value={values.computerSkill[field]}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={hasError}
|
||||||
|
helperText={hasError ? errors.computerSkill?.[field] : ""}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{proficiencyOptions.map((o) => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="سایر نرمافزارها"
|
||||||
|
name="computerSkill.otherSoftware"
|
||||||
|
value={values.computerSkill.otherSoftware || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!errors.computerSkill?.otherSoftware && !!touched.computerSkill?.otherSoftware}
|
||||||
|
helperText={
|
||||||
|
touched.computerSkill?.otherSoftware ? errors.computerSkill?.otherSoftware : ""
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* 2. Language Skills Section */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2, md: 3 },
|
||||||
|
borderRadius: "24px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ mb: 3, fontWeight: "bold", color: "#0f172a" }}>
|
||||||
|
آشنایی با زبانهای خارجه
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
|
|
||||||
|
{/* English */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="زبان انگلیسی*"
|
||||||
|
name="languageSkill.englishLevel"
|
||||||
|
value={values.languageSkill.englishLevel}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!errors.languageSkill?.englishLevel && !!touched.languageSkill?.englishLevel}
|
||||||
|
helperText={
|
||||||
|
touched.languageSkill?.englishLevel ? errors.languageSkill?.englishLevel : " "
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{proficiencyOptions.map((o) => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={values.languageSkill.hasEnglishCertificate}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setFieldValue("languageSkill.hasEnglishCertificate", checked);
|
||||||
|
if (!checked) {
|
||||||
|
// پاکسازی فیلد نوع مدرک در صورت غیرفعال شدن سوئیچ
|
||||||
|
setFieldValue("languageSkill.englishCertificateType", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="مدرک معتبر زبان انگلیسی دارد"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{values.languageSkill.hasEnglishCertificate && (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="نوع مدرک*"
|
||||||
|
name="languageSkill.englishCertificateType"
|
||||||
|
value={values.languageSkill.englishCertificateType}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!errors.languageSkill?.englishCertificateType &&
|
||||||
|
!!touched.languageSkill?.englishCertificateType
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
touched.languageSkill?.englishCertificateType
|
||||||
|
? errors.languageSkill?.englishCertificateType
|
||||||
|
: " "
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب...</MenuItem>
|
||||||
|
{certTypes.map((t) => (
|
||||||
|
<MenuItem key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="توضیحات زبان انگلیسی"
|
||||||
|
name="languageSkill.englishDescription"
|
||||||
|
value={values.languageSkill.englishDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!errors.languageSkill?.englishDescription &&
|
||||||
|
!!touched.languageSkill?.englishDescription
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
touched.languageSkill?.englishDescription
|
||||||
|
? errors.languageSkill?.englishDescription
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ gridColumn: { xs: "1", md: "1 / -1" }, my: 1 }} />
|
||||||
|
|
||||||
|
{/* Arabic */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="زبان عربی*"
|
||||||
|
name="languageSkill.arabicLevel"
|
||||||
|
value={values.languageSkill.arabicLevel}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!errors.languageSkill?.arabicLevel && !!touched.languageSkill?.arabicLevel}
|
||||||
|
helperText={
|
||||||
|
touched.languageSkill?.arabicLevel ? errors.languageSkill?.arabicLevel : " "
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{proficiencyOptions.map((o) => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<Box />
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="توضیحات زبان عربی"
|
||||||
|
name="languageSkill.arabicDescription"
|
||||||
|
value={values.languageSkill.arabicDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!errors.languageSkill?.arabicDescription &&
|
||||||
|
!!touched.languageSkill?.arabicDescription
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
touched.languageSkill?.arabicDescription
|
||||||
|
? errors.languageSkill?.arabicDescription
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ gridColumn: { xs: "1", md: "1 / -1" }, my: 1 }} />
|
||||||
|
|
||||||
|
{/* Other Skills */}
|
||||||
|
<TextField
|
||||||
|
label="سایر زبان ها (توضیحات در مورد میزان تسلط)"
|
||||||
|
name="languageSkill.otherLanguagesDescription"
|
||||||
|
value={values.languageSkill.otherLanguagesDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!errors.languageSkill?.otherLanguagesDescription &&
|
||||||
|
!!touched.languageSkill?.otherLanguagesDescription
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
touched.languageSkill?.otherLanguagesDescription
|
||||||
|
? errors.languageSkill?.otherLanguagesDescription
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="آشنایی با گویش ها و لهجه های کشور (توضیحات در مورد میزان تسلط)"
|
||||||
|
name="languageSkill.dialectsDescription"
|
||||||
|
value={values.languageSkill.dialectsDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={
|
||||||
|
!!errors.languageSkill?.dialectsDescription &&
|
||||||
|
!!touched.languageSkill?.dialectsDescription
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
touched.languageSkill?.dialectsDescription
|
||||||
|
? errors.languageSkill?.dialectsDescription
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="سایر مهارت ها (اعم از ورزشی، هنری، فرهنگی، اجتماعی و ...)"
|
||||||
|
name="languageSkill.otherSkills"
|
||||||
|
value={values.languageSkill.otherSkills}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!errors.languageSkill?.otherSkills && !!touched.languageSkill?.otherSkills}
|
||||||
|
helperText={
|
||||||
|
touched.languageSkill?.otherSkills ? errors.languageSkill?.otherSkills : ""
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Wizard Navigation Buttons */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mt: 2,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={props.step === 1 || isSubmitting}
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
color: "#64748b",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بازگشت
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: props.step === 12 ? "green" : "#2563eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.step === 12 ? "اتمام و ثبت نهایی" : "گام بعدی"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
ui/forms/skillsForm/SkillsForm.tsx
Normal file
41
ui/forms/skillsForm/SkillsForm.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { withFormik, type FormikBag } from "formik";
|
||||||
|
import type { SkillsFormProps, SkillsFormValues } from "./types";
|
||||||
|
import { INITIAL_SKILLS_VALUES } from "./constant";
|
||||||
|
import { SkillsValidationSchema } from "./validation";
|
||||||
|
import InnerSkillsForm from "./InnerSkillsForm";
|
||||||
|
|
||||||
|
const SkillsForm = withFormik<SkillsFormProps, SkillsFormValues>({
|
||||||
|
displayName: "SkillsForm",
|
||||||
|
|
||||||
|
enableReinitialize: true,
|
||||||
|
|
||||||
|
mapPropsToValues: (props) => {
|
||||||
|
return {
|
||||||
|
computerSkill: props.data?.computerSkill || INITIAL_SKILLS_VALUES.computerSkill,
|
||||||
|
languageSkill: props.data?.languageSkill || INITIAL_SKILLS_VALUES.languageSkill,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
validationSchema: SkillsValidationSchema,
|
||||||
|
|
||||||
|
handleSubmit: async (
|
||||||
|
values,
|
||||||
|
bag: FormikBag<SkillsFormProps, SkillsFormValues>
|
||||||
|
) => {
|
||||||
|
const { props, setSubmitting } = bag;
|
||||||
|
|
||||||
|
// ثبت مقادیر در استیت اصلی Wizard کامپوننت مادر
|
||||||
|
props.update({
|
||||||
|
computerSkill: values.computerSkill,
|
||||||
|
languageSkill: values.languageSkill,
|
||||||
|
});
|
||||||
|
|
||||||
|
// رفتن به گام بعدی
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
})(InnerSkillsForm);
|
||||||
|
|
||||||
|
export default SkillsForm;
|
||||||
39
ui/forms/skillsForm/constant/index.ts
Normal file
39
ui/forms/skillsForm/constant/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ProficiencyLevel, SkillsFormValues } from "../types";
|
||||||
|
|
||||||
|
export const proficiencyOptions: { value: ProficiencyLevel; label: string }[] = [
|
||||||
|
{ value: "", label: "انتخاب ..." },
|
||||||
|
{ value: "NONE", label: "ندارد" },
|
||||||
|
{ value: "VERY_WEAK", label: "خیلی ضعیف" },
|
||||||
|
{ value: "WEAK", label: "ضعیف" },
|
||||||
|
{ value: "AVERAGE", label: "متوسط" },
|
||||||
|
{ value: "GOOD", label: "خوب" },
|
||||||
|
{ value: "VERY_GOOD", label: "خیلی خوب" },
|
||||||
|
{ value: "EXCELLENT", label: "عالی" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const certTypes = ["IELTS", "TOFEL", "TOLIMO", "MCHE"] as const;
|
||||||
|
|
||||||
|
export const INITIAL_SKILLS_VALUES: SkillsFormValues = {
|
||||||
|
computerSkill: {
|
||||||
|
pcUsage: "",
|
||||||
|
word: "",
|
||||||
|
excel: "",
|
||||||
|
powerPoint: "",
|
||||||
|
rahkaran: "",
|
||||||
|
kasra: "",
|
||||||
|
didgah: "",
|
||||||
|
his: "",
|
||||||
|
otherSoftware: "",
|
||||||
|
},
|
||||||
|
languageSkill: {
|
||||||
|
englishLevel: "",
|
||||||
|
englishDescription: "",
|
||||||
|
hasEnglishCertificate: false,
|
||||||
|
englishCertificateType: "",
|
||||||
|
arabicLevel: "",
|
||||||
|
arabicDescription: "",
|
||||||
|
otherLanguagesDescription: "",
|
||||||
|
dialectsDescription: "",
|
||||||
|
otherSkills: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
46
ui/forms/skillsForm/types/index.ts
Normal file
46
ui/forms/skillsForm/types/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
export type ProficiencyLevel = "" | "NONE" | "VERY_WEAK" | "WEAK" | "AVERAGE" | "GOOD" | "VERY_GOOD" | "EXCELLENT";
|
||||||
|
|
||||||
|
export interface ComputerSkillFormData {
|
||||||
|
pcUsage: ProficiencyLevel;
|
||||||
|
word: ProficiencyLevel;
|
||||||
|
excel: ProficiencyLevel;
|
||||||
|
powerPoint: ProficiencyLevel;
|
||||||
|
rahkaran: ProficiencyLevel;
|
||||||
|
kasra: ProficiencyLevel;
|
||||||
|
didgah: ProficiencyLevel;
|
||||||
|
his: ProficiencyLevel;
|
||||||
|
otherSoftware?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LanguageSkillsFormData {
|
||||||
|
englishLevel: ProficiencyLevel;
|
||||||
|
englishDescription: string;
|
||||||
|
hasEnglishCertificate: boolean;
|
||||||
|
englishCertificateType: string;
|
||||||
|
arabicLevel: ProficiencyLevel;
|
||||||
|
arabicDescription: string;
|
||||||
|
otherLanguagesDescription: string;
|
||||||
|
dialectsDescription: string;
|
||||||
|
otherSkills: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillsFormValues {
|
||||||
|
computerSkill: ComputerSkillFormData;
|
||||||
|
languageSkill: LanguageSkillsFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** این ساختار را با دادههای استپهای دیگر Wizard خود تطابق دهید */
|
||||||
|
export interface WizardFormData {
|
||||||
|
computerSkill: ComputerSkillFormData;
|
||||||
|
languageSkill: LanguageSkillsFormData;
|
||||||
|
// ... rest of wizard data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillsFormProps {
|
||||||
|
step: number;
|
||||||
|
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
data: WizardFormData;
|
||||||
|
update: (patch: Partial<WizardFormData>) => void;
|
||||||
|
}
|
||||||
30
ui/forms/skillsForm/validation/index.ts
Normal file
30
ui/forms/skillsForm/validation/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
export const SkillsValidationSchema = Yup.object().shape({
|
||||||
|
computerSkill: Yup.object().shape({
|
||||||
|
pcUsage: Yup.string().optional(),
|
||||||
|
word: Yup.string().optional(),
|
||||||
|
excel: Yup.string().optional(),
|
||||||
|
powerPoint: Yup.string().optional(),
|
||||||
|
rahkaran: Yup.string().optional(),
|
||||||
|
kasra: Yup.string().optional(),
|
||||||
|
didgah: Yup.string().optional(),
|
||||||
|
his: Yup.string().optional(),
|
||||||
|
otherSoftware: Yup.string().max(1000, "توضیحات نمیتواند بیش از ۱۰۰۰ کاراکتر باشد").optional(),
|
||||||
|
}),
|
||||||
|
languageSkill: Yup.object().shape({
|
||||||
|
englishLevel: Yup.string().required("سطح زبان انگلیسی الزامی است"),
|
||||||
|
englishDescription: Yup.string().optional(),
|
||||||
|
hasEnglishCertificate: Yup.boolean(),
|
||||||
|
englishCertificateType: Yup.string().when("hasEnglishCertificate", {
|
||||||
|
is: true,
|
||||||
|
then: (schema) => schema.required("انتخاب نوع مدرک زبان الزامی است"),
|
||||||
|
otherwise: (schema) => schema.optional(),
|
||||||
|
}),
|
||||||
|
arabicLevel: Yup.string().required("سطح زبان عربی الزامی است"),
|
||||||
|
arabicDescription: Yup.string().optional(),
|
||||||
|
otherLanguagesDescription: Yup.string().optional(),
|
||||||
|
dialectsDescription: Yup.string().optional(),
|
||||||
|
otherSkills: Yup.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
293
ui/forms/workExperience/InnerWorkExperienceForm.tsx
Normal file
293
ui/forms/workExperience/InnerWorkExperienceForm.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { DeleteOutlineOutlined, Add } from "@mui/icons-material";
|
||||||
|
import { FieldArray, Form, getIn, type FormikProps } from "formik";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
WorkExperienceFormProps,
|
||||||
|
WorkExperienceFormValues,
|
||||||
|
WorkExperienceFormItem,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
WORK_EXPERIENCE_EMPTY_ITEM,
|
||||||
|
WORK_EXPERIENCE_NO_EXPERIENCE_ITEM,
|
||||||
|
} from "./constant";
|
||||||
|
|
||||||
|
type Props = FormikProps<WorkExperienceFormValues> & WorkExperienceFormProps;
|
||||||
|
|
||||||
|
export default function InnerWorkExperienceForm(props: Props) {
|
||||||
|
const {
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
setFieldValue,
|
||||||
|
handleChange,
|
||||||
|
isSubmitting,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const workExperiences = values.workExperiences || [];
|
||||||
|
const hasNoExperienceMode =
|
||||||
|
workExperiences.length === 1 && workExperiences[0]?.hasNoExperience;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
props.update({
|
||||||
|
workExperiences: values.workExperiences,
|
||||||
|
});
|
||||||
|
props.setStep(props.step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<FieldArray name="workExperiences">
|
||||||
|
{({ push, remove, replace }) => (
|
||||||
|
<>
|
||||||
|
{workExperiences.map((item: WorkExperienceFormItem, index: number) => {
|
||||||
|
const itemErrors = getIn(errors, `workExperiences.${index}`) || {};
|
||||||
|
const itemTouched = getIn(touched, `workExperiences.${index}`) || {};
|
||||||
|
|
||||||
|
const setHasNoExperience = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
replace(0, {
|
||||||
|
...WORK_EXPERIENCE_NO_EXPERIENCE_ITEM,
|
||||||
|
id: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = workExperiences.length - 1; i >= 1; i--) {
|
||||||
|
remove(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFieldValue(`workExperiences.${index}.hasNoExperience`, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={item.id || index}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2, md: 3 },
|
||||||
|
borderRadius: "24px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontWeight: 700 }}>
|
||||||
|
سابقه کاری {index + 1}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
disabled={workExperiences.length === 1 || hasNoExperienceMode}
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: "1fr",
|
||||||
|
md: "repeat(2, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
sx={{ gridColumn: "1 / -1" }}
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={item.hasNoExperience}
|
||||||
|
onChange={(e) => setHasNoExperience(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="فاقد سابقه کاری هستم"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نام شرکت"
|
||||||
|
name={`workExperiences.${index}.companyName`}
|
||||||
|
value={item.companyName}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
disabled={item.hasNoExperience}
|
||||||
|
error={!!itemTouched.companyName && !!itemErrors.companyName}
|
||||||
|
helperText={
|
||||||
|
itemTouched.companyName ? itemErrors.companyName : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="آخرین سمت"
|
||||||
|
name={`workExperiences.${index}.lastPosition`}
|
||||||
|
value={item.lastPosition}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
disabled={item.hasNoExperience}
|
||||||
|
error={!!itemTouched.lastPosition && !!itemErrors.lastPosition}
|
||||||
|
helperText={
|
||||||
|
itemTouched.lastPosition ? itemErrors.lastPosition : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="سال شروع"
|
||||||
|
name={`workExperiences.${index}.startYear`}
|
||||||
|
value={item.startYear}
|
||||||
|
onChange={(e) => {
|
||||||
|
const onlyDigits = e.target.value.replace(/[^\d]/g, "");
|
||||||
|
setFieldValue(
|
||||||
|
`workExperiences.${index}.startYear`,
|
||||||
|
onlyDigits,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
disabled={item.hasNoExperience}
|
||||||
|
inputMode="numeric"
|
||||||
|
error={!!itemTouched.startYear && !!itemErrors.startYear}
|
||||||
|
helperText={itemTouched.startYear ? itemErrors.startYear : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="سال پایان"
|
||||||
|
name={`workExperiences.${index}.endYear`}
|
||||||
|
value={item.endYear}
|
||||||
|
onChange={(e) => {
|
||||||
|
const onlyDigits = e.target.value.replace(/[^\d]/g, "");
|
||||||
|
setFieldValue(
|
||||||
|
`workExperiences.${index}.endYear`,
|
||||||
|
onlyDigits,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
disabled={item.hasNoExperience}
|
||||||
|
inputMode="numeric"
|
||||||
|
error={!!itemTouched.endYear && !!itemErrors.endYear}
|
||||||
|
helperText={itemTouched.endYear ? itemErrors.endYear : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="علت ترک کار"
|
||||||
|
name={`workExperiences.${index}.leavingReason`}
|
||||||
|
value={item.leavingReason}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
disabled={item.hasNoExperience}
|
||||||
|
error={!!itemTouched.leavingReason && !!itemErrors.leavingReason}
|
||||||
|
helperText={
|
||||||
|
itemTouched.leavingReason ? itemErrors.leavingReason : ""
|
||||||
|
}
|
||||||
|
sx={{ gridColumn: { md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="توضیحات"
|
||||||
|
name={`workExperiences.${index}.description`}
|
||||||
|
value={item.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
disabled={item.hasNoExperience}
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
error={!!itemTouched.description && !!itemErrors.description}
|
||||||
|
helperText={
|
||||||
|
itemTouched.description ? itemErrors.description : ""
|
||||||
|
}
|
||||||
|
sx={{ gridColumn: { md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!hasNoExperienceMode && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() =>
|
||||||
|
push({
|
||||||
|
...WORK_EXPERIENCE_EMPTY_ITEM,
|
||||||
|
id: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
mb: 3,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
افزودن سابقه کاری
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof errors.workExperiences === "string" && (
|
||||||
|
<Typography color="error" sx={{ mb: 2 }}>
|
||||||
|
{errors.workExperiences}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mt: 4,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={props.step === 1 || isSubmitting}
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
color: "#64748b",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بازگشت
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: props.step === 12 ? "green" : "#2563eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.step === 12 ? "اتمام و ثبت نهایی" : "گام بعدی"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
ui/forms/workExperience/WorkExperienceForm.tsx
Normal file
46
ui/forms/workExperience/WorkExperienceForm.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { withFormik, type FormikBag } from "formik";
|
||||||
|
import type {
|
||||||
|
WorkExperienceFormProps,
|
||||||
|
WorkExperienceFormValues,
|
||||||
|
} from "./types";
|
||||||
|
import { WORK_EXPERIENCE_EMPTY_VALUES } from "./constant";
|
||||||
|
import { WorkExperienceValidationSchema } from "./validation";
|
||||||
|
import InnerWorkExperienceForm from "./InnerWorkExperienceForm";
|
||||||
|
|
||||||
|
const WorkExperienceForm = withFormik<
|
||||||
|
WorkExperienceFormProps,
|
||||||
|
WorkExperienceFormValues
|
||||||
|
>({
|
||||||
|
displayName: "WorkExperienceForm",
|
||||||
|
|
||||||
|
enableReinitialize: true,
|
||||||
|
|
||||||
|
mapPropsToValues: (props) => {
|
||||||
|
return {
|
||||||
|
workExperiences:
|
||||||
|
props.data?.workExperiences?.length > 0
|
||||||
|
? props.data.workExperiences
|
||||||
|
: WORK_EXPERIENCE_EMPTY_VALUES.workExperiences,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
validationSchema: WorkExperienceValidationSchema,
|
||||||
|
|
||||||
|
handleSubmit: async (
|
||||||
|
values,
|
||||||
|
bag: FormikBag<WorkExperienceFormProps, WorkExperienceFormValues>,
|
||||||
|
) => {
|
||||||
|
const { props, setSubmitting } = bag;
|
||||||
|
|
||||||
|
props.update({
|
||||||
|
workExperiences: values.workExperiences,
|
||||||
|
});
|
||||||
|
|
||||||
|
props.setStep((prev) => prev + 1);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
})(InnerWorkExperienceForm);
|
||||||
|
|
||||||
|
export default WorkExperienceForm;
|
||||||
27
ui/forms/workExperience/constant/index.ts
Normal file
27
ui/forms/workExperience/constant/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { WorkExperienceFormItem, WorkExperienceFormValues } from "../types";
|
||||||
|
|
||||||
|
export const WORK_EXPERIENCE_EMPTY_ITEM: WorkExperienceFormItem = {
|
||||||
|
id: "",
|
||||||
|
hasNoExperience: false,
|
||||||
|
companyName: "",
|
||||||
|
lastPosition: "",
|
||||||
|
startYear: "",
|
||||||
|
endYear: "",
|
||||||
|
leavingReason: "",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORK_EXPERIENCE_NO_EXPERIENCE_ITEM: WorkExperienceFormItem = {
|
||||||
|
id: "",
|
||||||
|
hasNoExperience: true,
|
||||||
|
companyName: "",
|
||||||
|
lastPosition: "",
|
||||||
|
startYear: "",
|
||||||
|
endYear: "",
|
||||||
|
leavingReason: "",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORK_EXPERIENCE_EMPTY_VALUES: WorkExperienceFormValues = {
|
||||||
|
workExperiences: [{ ...WORK_EXPERIENCE_EMPTY_ITEM }],
|
||||||
|
};
|
||||||
29
ui/forms/workExperience/types/index.ts
Normal file
29
ui/forms/workExperience/types/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
export interface WorkExperienceFormItem {
|
||||||
|
id?: string | number;
|
||||||
|
hasNoExperience: boolean;
|
||||||
|
companyName: string;
|
||||||
|
lastPosition: string;
|
||||||
|
startYear: string;
|
||||||
|
endYear: string;
|
||||||
|
leavingReason: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkExperienceFormValues {
|
||||||
|
workExperiences: WorkExperienceFormItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** با مدل اصلی ویزارد پروژهات هماهنگ شود */
|
||||||
|
export interface WizardFormData {
|
||||||
|
workExperiences: WorkExperienceFormItem[];
|
||||||
|
// ... سایر stepها
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkExperienceFormProps {
|
||||||
|
step: number;
|
||||||
|
setStep: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
data: WizardFormData;
|
||||||
|
update: (patch: Partial<WizardFormData>) => void;
|
||||||
|
}
|
||||||
61
ui/forms/workExperience/validation/index.ts
Normal file
61
ui/forms/workExperience/validation/index.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
export const WorkExperienceValidationSchema = Yup.object({
|
||||||
|
workExperiences: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
id: Yup.mixed().optional(),
|
||||||
|
hasNoExperience: Yup.boolean().required(),
|
||||||
|
|
||||||
|
companyName: Yup.string().when("hasNoExperience", {
|
||||||
|
is: false,
|
||||||
|
then: (schema) => schema.required("نام شرکت الزامی است"),
|
||||||
|
otherwise: (schema) => schema.optional(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
lastPosition: Yup.string().when("hasNoExperience", {
|
||||||
|
is: false,
|
||||||
|
then: (schema) => schema.required("آخرین سمت الزامی است"),
|
||||||
|
otherwise: (schema) => schema.optional(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
startYear: Yup.string().when("hasNoExperience", {
|
||||||
|
is: false,
|
||||||
|
then: (schema) =>
|
||||||
|
schema
|
||||||
|
.required("سال شروع الزامی است")
|
||||||
|
.matches(/^\d{4}$/, "سال شروع باید 4 رقم باشد"),
|
||||||
|
otherwise: (schema) => schema.optional(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
endYear: Yup.string().when("hasNoExperience", {
|
||||||
|
is: false,
|
||||||
|
then: (schema) =>
|
||||||
|
schema
|
||||||
|
.required("سال پایان الزامی است")
|
||||||
|
.matches(/^\d{4}$/, "سال پایان باید 4 رقم باشد"),
|
||||||
|
otherwise: (schema) => schema.optional(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
leavingReason: Yup.string().when("hasNoExperience", {
|
||||||
|
is: false,
|
||||||
|
then: (schema) => schema.required("علت ترک کار الزامی است"),
|
||||||
|
otherwise: (schema) => schema.optional(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
description: Yup.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, "حداقل یک رکورد باید وجود داشته باشد")
|
||||||
|
.test(
|
||||||
|
"single-no-experience-item",
|
||||||
|
"در صورت انتخاب فاقد سابقه، فقط یک رکورد مجاز است",
|
||||||
|
(value) => {
|
||||||
|
if (!value || value.length === 0) return false;
|
||||||
|
const hasNoExperience = value.some((item) => item.hasNoExperience);
|
||||||
|
if (!hasNoExperience) return true;
|
||||||
|
return value.length === 1 && value[0].hasNoExperience === true;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.required("ثبت سابقه کاری الزامی است"),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user