Compare commits

...

4 Commits

Author SHA1 Message Date
4c9152869f change some files 2026-06-03 16:15:30 +03:30
cfb48c5bb0 change some files 2026-06-02 17:08:52 +03:30
b8dc1d0e1b dasdasdيشيسي 2026-05-31 18:02:54 +03:30
b241d12ff5 dasdasd 2026-05-31 18:00:43 +03:30
84 changed files with 5963 additions and 3172 deletions

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

View File

@@ -9,12 +9,13 @@ import { AdapterDateFnsJalali } from "@mui/x-date-pickers/AdapterDateFnsJalali";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { Toaster } from "sonner";
import { queryClientOptionsData } from "@/core/constant";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const [queryClient] = useState(() => new QueryClient());
const [queryClient] = useState(() => new QueryClient(queryClientOptionsData));
return (
<html

View File

@@ -1,7 +1,7 @@
import axios from "axios";
const callAPISetting = axios.create({
baseURL: "http://localhost:8000/api/v1",
baseURL: "http://localhost:5000/api/v1",
withCredentials: true,
});

53
core/constant/index.ts Normal file
View File

@@ -0,0 +1,53 @@
import { QueryClientConfig } from "@tanstack/react-query";
export const queryClientOptionsData: QueryClientConfig = {
defaultOptions: {
queries: {
// مدت زمانی که داده fresh حساب می‌شود
staleTime: 1000 * 60 * 5, // 5 دقیقه
// مدت نگهداری کش در حافظه بعد از unmount
gcTime: 1000 * 60 * 30, // 30 دقیقه
// چند بار در صورت خطا retry کند
retry: (failureCount: any, error: any) => {
// برای خطاهای 4xx معمولاً retry منطقی نیست
const status = error?.response?.status;
if (status >= 400 && status < 500) return false;
return failureCount < 2;
},
// تاخیر بین retryها
retryDelay: (attemptIndex: any) =>
Math.min(1000 * 2 ** attemptIndex, 10000),
// جلوگیری از رفرش‌های اضافه
refetchOnWindowFocus: false,
refetchOnReconnect: true,
refetchOnMount: false,
},
mutations: {
retry: 0,
},
},
};
export const genderOptions = [
{
id: 1,
label: "مرد",
value: "male",
},
{
id: 2,
label: "زن",
value: "female",
},
{
id: 3,
label: "ساير",
value: "other",
},
];
export const religionOptions = ["اسلام", "مسیحیت", "یهودیت", "زرتشتی", "سایر"];

49
core/types/index.ts Normal file
View File

@@ -0,0 +1,49 @@
import { EducationFormValues } from "@/ui/forms/education/EducationForm";
export type genderType = "male" | "female" | "other";
export interface IdentityFormValues {
firstName: string;
lastName: string;
fatherName: string;
nationalCode: string;
birthDate: string;
birthPlace: string;
gender: string;
religion: string;
nationality: string;
profilePhotoId: string;
}
export type CenterItem = {
id: string;
name: string;
address: string;
isUrgent: boolean;
};
export interface WizardFormData {
registrationCenter: {
selectedCenter: CenterItem | null;
};
identity: IdentityFormValues; // برای مرحله ۲
education: EducationFormValues[]; // به جای education
}
// مقدار اولیه برای همه مراحل
export const INITIAL_WIZARD_DATA: WizardFormData = {
registrationCenter: {
selectedCenter: null,
},
identity: {
firstName: "",
lastName: "",
birthDate: "",
birthPlace: "",
fatherName: "",
gender: "",
nationalCode: "",
nationality: "",
profilePhotoId: "",
religion: "",
},
education: [],
};

61
core/utils/index.ts Normal file
View File

@@ -0,0 +1,61 @@
import axios from "axios";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
export function handleAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
// اینجا می‌دونیم که خطا از axios است
return error.response?.data?.error?.message;
} else {
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");
};

10
hooks/center.hook.ts Normal file
View File

@@ -0,0 +1,10 @@
import { getAllCenters, selectCenter } from "@/services/apis/center.api";
import { useMutation, useQuery } from "@tanstack/react-query";
export const useGetAllCenters = () =>
useQuery({
queryKey: ["get-all-centers"],
queryFn: getAllCenters,
});
export const useSelectCenter = () => useMutation({ mutationFn: selectCenter });

7
hooks/identity.hook.ts Normal file
View File

@@ -0,0 +1,7 @@
import { sendIdentityForm } from "@/services/apis/identity.api";
import { useMutation } from "@tanstack/react-query";
export const useSendIdentityForm = () =>
useMutation({
mutationFn: sendIdentityForm,
});

47
middleware.ts Normal file
View File

@@ -0,0 +1,47 @@
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const AUTH_COOKIE_KEY = "tid";
// مسیرهای عمومی
const publicPaths = ["/"]; // فرض کردیم "/" صفحه لاگین است
export function middleware(request: NextRequest) {
const token = request.cookies.get(AUTH_COOKIE_KEY)?.value;
const { pathname } = request.nextUrl;
const isPublicPath = publicPaths.includes(pathname);
// اگر کاربر لاگین کرده باشد
if (token) {
// اگر رفت صفحه لاگین، بفرستش داخل پنل
if (pathname === "/") {
const url = request.nextUrl.clone();
url.pathname = "/form";
return NextResponse.redirect(url);
}
// بقیه مسیرها مجاز
return NextResponse.next();
}
// اگر کاربر توکن نداشته باشد
if (!token) {
// فقط مسیرهای عمومی مجازند
if (isPublicPath) {
return NextResponse.next();
}
// هر مسیر دیگری => ریدایرکت به لاگین
const url = request.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

116
package-lock.json generated
View File

@@ -16,11 +16,13 @@
"@tanstack/react-query": "^5.100.14",
"axios": "^1.16.1",
"date-fns-jalali": "^4.0.0-0",
"formik": "^2.4.9",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"sonner": "^2.0.7",
"stylis-plugin-rtl": "^2.1.1"
"stylis-plugin-rtl": "^2.1.1",
"yup": "^1.7.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -2108,6 +2110,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
"license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.0"
},
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://package-mirror.liara.ir/repository/npm/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3465,6 +3479,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "2.2.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://package-mirror.liara.ir/repository/npm/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -4378,6 +4401,31 @@
"node": ">= 6"
}
},
"node_modules/formik": {
"version": "2.4.9",
"resolved": "https://package-mirror.liara.ir/repository/npm/formik/-/formik-2.4.9.tgz",
"integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==",
"funding": [
{
"type": "individual",
"url": "https://opencollective.com/formik"
}
],
"license": "Apache-2.0",
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.1",
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-fast-compare": "^2.0.1",
"tiny-warning": "^1.0.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/function-bind/-/function-bind-1.1.2.tgz",
@@ -5625,6 +5673,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6232,6 +6292,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://package-mirror.liara.ir/repository/npm/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -6293,6 +6359,12 @@
"react": "^19.2.4"
}
},
"node_modules/react-fast-compare": {
"version": "2.0.4",
"resolved": "https://package-mirror.liara.ir/repository/npm/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
"license": "MIT"
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/react-is/-/react-is-16.13.1.tgz",
@@ -6992,6 +7064,18 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://package-mirror.liara.ir/repository/npm/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -7053,6 +7137,12 @@
"node": ">=8.0"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -7111,6 +7201,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -7482,6 +7584,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.7.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/yup/-/yup-1.7.1.tgz",
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
"license": "MIT",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/zod/-/zod-4.4.3.tgz",

View File

@@ -17,11 +17,13 @@
"@tanstack/react-query": "^5.100.14",
"axios": "^1.16.1",
"date-fns-jalali": "^4.0.0-0",
"formik": "^2.4.9",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"sonner": "^2.0.7",
"stylis-plugin-rtl": "^2.1.1"
"stylis-plugin-rtl": "^2.1.1",
"yup": "^1.7.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -2,6 +2,6 @@ import callAPI from "@/core/caller";
export async function applicantLogin(nationalCode: string) {
return await callAPI
.post(`/auth/applicant/login`, nationalCode)
.post(`/auth/applicant/login`,{nationalCode})
.then((res) => res.data);
}

View File

@@ -0,0 +1,10 @@
import callAPI from "@/core/caller";
export async function getAllCenters() {
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);
}

View File

@@ -0,0 +1,8 @@
import callAPI from "@/core/caller";
import { IdentityFormValues } from "@/core/types";
export async function sendIdentityForm(data: IdentityFormValues) {
return await callAPI
.post(`/form/identity/create`, data )
.then((res) => res.data);
}

View File

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

View File

@@ -1,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>
);
}

View File

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

View File

@@ -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 };

View File

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

View File

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

View File

@@ -1,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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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],
};

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

View File

@@ -0,0 +1,61 @@
"use client";
import React from "react";
import { withFormik, type FormikBag } from "formik";
import { EDUCATION_EMPTY_VALUES } from "./constants";
import InnerEducationForm from "./InnerEducationForm";
export interface EducationItem {
degree: string;
field: string;
university: string;
startYear: number | "";
endYear: number | "";
gpa: number | "";
certificateImageId: string;
description: string;
}
export interface EducationFormValues {
education: EducationItem[];
}
/** این بخش را با WizardFormData واقعی پروژه‌ات هماهنگ کن */
export interface WizardFormData {
education: EducationItem[];
// ... other steps
}
export type EducationFormProps = {
step: number;
setStep: React.Dispatch<React.SetStateAction<number>>;
data: WizardFormData;
update: (patch: Partial<WizardFormData>) => void;
};
const EducationForm = withFormik<EducationFormProps, EducationFormValues>({
displayName: "EducationForm",
enableReinitialize: true,
mapPropsToValues: (props) => {
return {
education: props.data?.education ?? EDUCATION_EMPTY_VALUES,
};
},
// validationSchema: EducationValidationSchema,
handleSubmit: async (
values,
bag: FormikBag<EducationFormProps, EducationFormValues>,
) => {
const { props, setSubmitting } = bag;
props.update({ education: values.education });
props.setStep((prev) => prev + 1);
setSubmitting(false);
},
})(InnerEducationForm);
export default EducationForm;

View File

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

View File

@@ -0,0 +1,31 @@
// education.constants.ts
import { EducationFormValues } from "../InnerEducationForm";
import { DegreeValue} from "../types";
export const EDUCATION_EMPTY_VALUES: EducationFormValues = {
education: [
{
degree: "",
field: "",
university: "",
startYear: "",
endYear: "",
gpa: "",
certificateImageId: "",
description: "",
},
],
}
export const DEGREE_OPTIONS: Exclude<DegreeValue, "">[] = [
"زیر دیپلم",
"دیپلم",
"کاردانی",
"کارشناسی",
"کارشناسی ارشد",
"دکتری",
"حوزوی",
"سایر",
];

View File

@@ -0,0 +1,37 @@
// education.types.ts
// export const EMPTY: EducationFormValues = {
// degree: "",
// field: "",
// university: "",
// startYear: "",
// endYear: "",
// gpa: "",
// description: "",
// certificateImageId: "",
// };
export type DegreeValue =
| ""
| "زیر دیپلم"
| "دیپلم"
| "کاردانی"
| "کارشناسی"
| "کارشناسی ارشد"
| "دکتری"
| "حوزوی"
| "سایر";
// export interface EducationFormValues {
// // applicantId: string;
// degree: DegreeValue;
// field: string;
// university: string;
// startYear: number | "";
// endYear: number | "";
// gpa: number | "";
// description: string;
// certificateImageId: string; // id returned from upload api
// }

View File

@@ -0,0 +1,19 @@
// EducationForm.validation.ts
import * as yup from "yup";
export const educationItemSchema = yup.object({
degree: yup.string().required("مقطع تحصیلی الزامی است"),
field: yup.string().required("رشته تحصیلی الزامی است"),
university: yup.string().required("دانشگاه / موسسه الزامی است"),
startYear: yup.number().required("سال شروع الزامی است"),
endYear: yup.number().required("سال پایان الزامی است"),
gpa: yup.number().min(0).max(20).required("معدل الزامی است"),
certificateImageId: yup.string().required("تصویر مدرک الزامی است"),
description: yup.string(),
});
export const educationValidationSchema = yup.object({
education: yup.array()
.of(educationItemSchema)
.min(1, "حداقل یک سابقه تحصیلی باید وارد شود"),
});

View File

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

View File

@@ -0,0 +1,606 @@
"use client";
import {
Alert,
AlertTitle,
Avatar,
Box,
Button,
IconButton,
MenuItem,
Paper,
TextField,
Typography,
} from "@mui/material";
import { ErrorMessage, Form, FormikProps } from "formik";
import { IdentityFormValues } from "@/core/types";
import { IdentityFormProps } from "./IdentityForm";
import { CheckCircle, Close, Person, UploadFile } from "@mui/icons-material";
import { genderOptions, religionOptions } from "@/core/constant";
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(
props: FormikProps<IdentityFormValues> & IdentityFormProps,
) {
const handleBack = () => {
// قبل از رفتن به عقب، مقادیر فعلی فرم را در استیت والد ذخیره کن
props.update({ identity: props.values });
props.setStep(props.step - 1);
};
const { mutateAsync, isPending } = useSendIdentityForm();
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 [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>,
) => {
const file = event.target.files?.[0];
if (!file) return;
// ۱. پیش‌نمایش موقت با استفاده از خودِ فایل (قبل از آپلود)
// این کار باعث می‌شود کاربر فوراً عکس را ببیند و خطای URL نگیرید
const localPreview = URL.createObjectURL(file);
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();
}
setProfileUploading(false);
setProfileUploadProgress(0);
};
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);
}
}
try {
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 (
<Box>
<Alert severity="warning" sx={{ mb: 3, borderRadius: "12px" }}>
<AlertTitle sx={{ fontWeight: 800 }}>توجه</AlertTitle>
پس از تكميل اين گام ، كدملي شما براي ادامه مراحل ذخيره خواهد شد
</Alert>
<Paper
elevation={0}
sx={{
width: "100%",
background: "#ffffff",
}}
>
<Form>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))",
gap: "18px",
}}
>
<div>
<TextField
label="نام"
value={props.values.firstName}
onChange={(e) =>
props.setFieldValue("firstName", e.target.value)
}
error={!!props.errors.firstName}
helperText={props.errors.firstName}
fullWidth
required
/>
<ErrorMessage component={"div"} name="firstName" />
</div>
<div>
<TextField
label="نام خانوادگی"
value={props.values.lastName}
onChange={(e) =>
props.setFieldValue("lastName", e.target.value)
}
error={!!props.errors.lastName}
helperText={props.errors.lastName}
fullWidth
required
/>
<ErrorMessage component={"div"} name="lastName" />
</div>
<div>
<TextField
label="نام پدر"
value={props.values.fatherName}
onChange={(e) =>
props.setFieldValue("fatherName", e.target.value)
}
fullWidth
/>
<ErrorMessage component={"div"} name="fatherName" />
</div>
<div>
<TextField
label="کد ملی"
value={props.values.nationalCode}
onChange={(e) =>
props.setFieldValue("nationalCode", e.target.value)
}
error={!!props.errors.nationalCode}
helperText={props.errors.nationalCode}
fullWidth
required
/>
<ErrorMessage component={"div"} name="nationalCode" />
</div>
<DatePicker
label="تاریخ تولد"
value={
props.values.birthDate ? new Date(props.values.birthDate) : null
}
onChange={(newValue) =>
props.setFieldValue("birthDate", newValue)
}
maxDate={new Date()}
slotProps={{
textField: {
fullWidth: true,
error: !!props.errors.birthDate,
helperText: props.errors.birthDate,
},
}}
/>
<div>
<TextField
label="محل تولد"
value={props.values.birthPlace}
onChange={(e) =>
props.setFieldValue("birthPlace", e.target.value)
}
fullWidth
required
error={!!props.errors.birthPlace}
helperText={props.errors.birthPlace}
/>
<ErrorMessage component={"div"} name="birthPlace" />
</div>
<div>
<TextField
select
label="جنسیت"
value={props.values.gender}
onChange={(e) => props.setFieldValue("gender", e.target.value)}
error={!!props.errors.gender}
helperText={props.errors.gender}
fullWidth
required
>
{genderOptions.map((item) => (
<MenuItem key={item.id} value={item.value}>
{item.label}
</MenuItem>
))}
</TextField>
<ErrorMessage component={"div"} name="gender" />
</div>
<div>
<TextField
select
label="دین"
value={props.values.religion}
onChange={(e) =>
props.setFieldValue("religion", e.target.value)
}
fullWidth
required
error={!!props.errors.religion}
helperText={props.errors.religion}
>
{religionOptions.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
<ErrorMessage component={"div"} name="religion" />
</div>
<div>
<TextField
label="ملیت"
value={props.values.nationality}
onChange={(e) =>
props.setFieldValue("nationality", e.target.value)
}
error={!!props.errors.nationality}
helperText={props.errors.nationality}
fullWidth
required
/>
<ErrorMessage component={"div"} name="nationality" />
</div>
<Box
sx={{
width: "100%",
border: profileUploadError
? "1px solid #ef4444"
: "1px dashed #cbd5e1",
borderRadius: "18px",
backgroundColor: "#f8fafc",
p: 2,
minHeight: "100%",
transition: "all 0.2s ease",
"&:hover": {
borderColor: profileUploadError ? "#ef4444" : "#2563eb",
backgroundColor: "#f8fbff",
},
}}
>
<Typography
sx={{
fontWeight: 700,
color: "#0f172a",
mb: 1.5,
fontSize: "0.95rem",
}}
>
عکس پرسنلی
</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
sx={{
color: "#64748b",
fontSize: "0.82rem",
mb: 2,
lineHeight: 1.8,
}}
>
فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت
باشد.
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Button
component="label"
variant="outlined"
startIcon={<UploadFile />}
disabled={profileUploading}
sx={{
borderRadius: "12px",
borderColor: "#cbd5e1",
color: "#2563eb",
fontWeight: 700,
px: 2.5,
"&:hover": {
borderColor: "#2563eb",
backgroundColor: "#eff6ff",
},
"&.Mui-disabled": {
borderColor: "#cbd5e1",
color: "#94a3b8",
backgroundColor: "#f1f5f9",
},
}}
>
{profileUploading ? "در حال آپلود..." : "انتخاب عکس"}
<input
hidden
type="file"
accept="image/*"
onChange={handleProfilePhotoChange}
/>
</Button>
{profileUploading && (
<Button
type="button"
variant="text"
color="error"
onClick={handleCancelUpload}
sx={{
fontWeight: 700,
borderRadius: "12px",
}}
>
لغو آپلود
</Button>
)}
</Box>
</Box>
</Box>
{/* نمایش نام فایل در حال آپلود */}
{selectedProfileFile && profileUploading && (
<Typography
sx={{
mt: 1.5,
fontSize: "0.82rem",
color: "#475569",
wordBreak: "break-word",
}}
>
فایل انتخابشده: {selectedProfileFile.name}
</Typography>
)}
{/* نوار پیشرفت */}
{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
sx={{
mt: 1.5,
color: "#dc2626",
fontSize: "0.8rem",
fontWeight: 600,
}}
>
{profileUploadError}
</Typography>
)}
<ErrorMessage
component="div"
name="profilePhotoId"
className="text-red-700 text-sm font-semibold mt-4"
/>
</Box>
</div>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
mt: 5,
width: "100%",
}}
>
<Button
disabled={props.step === 1}
type="button"
onClick={handleBack}
sx={{
borderRadius: "12px",
color: "#64748b",
fontWeight: 700,
}}
>
بازگشت
</Button>
<Button
variant="contained"
type="button" // به جای submit از button استفاده کردیم تا با تابع خودمان چک شود
onClick={handleNext}
sx={{
borderRadius: "12px",
px: 4,
py: 1.5,
bgcolor: `${props.step === 12 ? "green" : "#2563eb"}`,
fontWeight: 700,
}}
>
{props.step === 12 ? "اتمام و ثبت نهايي" : "گام بعدی"}
</Button>
</Box>
</Form>
</Paper>
</Box>
);
}

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

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

View 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: "بازخرید" },
];

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

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

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

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

View 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],
};

View 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[];
}

View 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("ثبت درخواست شغلی الزامی است"),
});

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

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

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

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

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

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

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

View 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-",
];

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

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

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

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

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

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

View 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("ثبت معرف الزامی است"),
});

View File

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

View File

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

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

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

View 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 },
],
};

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

View 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, "باید مشخصات دو نفر را وارد کنید"),
});

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

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

View 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: "",
},
};

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

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

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

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

View 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 }],
};

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

View 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("ثبت سابقه کاری الزامی است"),
});