diff --git a/app/layout.tsx b/app/layout.tsx index 92c7ff2..d4429c1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( { + // برای خطاهای 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 = ["اسلام", "مسیحیت", "یهودیت", "زرتشتی", "سایر"]; diff --git a/core/types/index.ts b/core/types/index.ts new file mode 100644 index 0000000..68f7c1e --- /dev/null +++ b/core/types/index.ts @@ -0,0 +1,45 @@ +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; // برای مرحله ۲ +} + +// مقدار اولیه برای همه مراحل +export const INITIAL_WIZARD_DATA: WizardFormData = { + registrationCenter: { + selectedCenter:null + }, + identity: { + firstName: "", + lastName: "", + birthDate: "", + birthPlace: "", + fatherName: "", + gender: "", + nationalCode: "", + nationality: "", + profilePhotoId: "", + religion: "", + }, +}; diff --git a/core/utils/index.ts b/core/utils/index.ts new file mode 100644 index 0000000..7ddc935 --- /dev/null +++ b/core/utils/index.ts @@ -0,0 +1,10 @@ +import axios from "axios"; + +export function handleAxiosError(error: unknown) { + if (axios.isAxiosError(error)) { + // اینجا می‌دونیم که خطا از axios است + return error.response?.data?.error?.message; + } else { + return "Unexpected error"; + } +} \ No newline at end of file diff --git a/hooks/center.hook.ts b/hooks/center.hook.ts new file mode 100644 index 0000000..6ccfc72 --- /dev/null +++ b/hooks/center.hook.ts @@ -0,0 +1,8 @@ +import { getAllCenters } from "@/services/apis/center.api"; +import { useQuery } from "@tanstack/react-query"; + + +export const useGetAllCenters = () => useQuery({ + queryKey:["get-all-centers"], + queryFn:getAllCenters +}) \ No newline at end of file diff --git a/hooks/identity.hook.ts b/hooks/identity.hook.ts new file mode 100644 index 0000000..402099b --- /dev/null +++ b/hooks/identity.hook.ts @@ -0,0 +1,7 @@ +import { sendIdentityForm } from "@/services/apis/identity.api"; +import { useMutation } from "@tanstack/react-query"; + +export const useSendIdentityForm = () => + useMutation({ + mutationFn: sendIdentityForm, + }); diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..43efcba --- /dev/null +++ b/middleware.ts @@ -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).*)"], +}; diff --git a/package-lock.json b/package-lock.json index 126981e..1cd2f41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c3f3dd6..2f91dfb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/services/apis/auth.api.ts b/services/apis/auth.api.ts index c22af72..8da2c60 100644 --- a/services/apis/auth.api.ts +++ b/services/apis/auth.api.ts @@ -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); } diff --git a/services/apis/center.api.ts b/services/apis/center.api.ts new file mode 100644 index 0000000..8908290 --- /dev/null +++ b/services/apis/center.api.ts @@ -0,0 +1,5 @@ +import callAPI from "@/core/caller"; + +export async function getAllCenters() { + return await callAPI.get(`/center/all`).then((res) => res.data); +} diff --git a/services/apis/identity.api.ts b/services/apis/identity.api.ts new file mode 100644 index 0000000..5af06d5 --- /dev/null +++ b/services/apis/identity.api.ts @@ -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); +} diff --git a/ui/MultiForm.tsx b/ui/MultiForm.tsx index c767da2..fb6929a 100644 --- a/ui/MultiForm.tsx +++ b/ui/MultiForm.tsx @@ -10,13 +10,10 @@ import { useMediaQuery, } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; -import CenterRegistrationForm from "./forms/register-center/RegistrationCenterForm"; -import IdentityForm from "./forms/IdentityForm"; +import IdentityForm from "./forms/identity/IdentityForm"; import PersonalInfoForm from "./forms/PersonalInfoForm"; import PhysicalInfoForm from "./forms/PhysicalInfoForm"; -import EducationForm from "./forms/EducationForm"; import EducationSection from "./forms/EducationSection"; -import JobRequestForm from "./forms/JobRequestForm"; import JobRequestSection from "./forms/JobRequestSection"; import CourseSection from "./forms/CourseSection"; import SkillsForm from "./forms/SkillsForm"; @@ -24,6 +21,8 @@ import { WorkExperienceSection } from "./forms/WorkExperienceSection"; import JobInfoForm from "./forms/JobInfoForm"; import { ReferralSection } from "./forms/ReferralForm"; import RelationsForm from "./forms/RelationForm"; +import RegistrationCenterForm from "./forms/register-center/RegistrationCenterForm"; +import { INITIAL_WIZARD_DATA, WizardFormData } from "@/core/types"; // کامپوننت پیش‌فرض برای مراحلی که هنوز نساختید const PlaceholderStep = ({ step }: any) => ( @@ -34,8 +33,8 @@ const PlaceholderStep = ({ step }: any) => ( // --- ۲. نگاشت (Mapping) مراحل به کامپوننت‌ها --- -const STEP_COMPONENTS: Record> = { - 1: CenterRegistrationForm, +const STEP_COMPONENTS: Record> = { + 1: RegistrationCenterForm, 2: IdentityForm, 3: PersonalInfoForm, 4: PhysicalInfoForm, @@ -70,26 +69,15 @@ const STEP_LABELS = [ export default function MultiStepForm() { const [activeStep, setActiveStep] = useState(1); const [maxStepReached, setMaxStepReached] = useState(1); - const [formData, setFormData] = useState({ - name: "", - address: "", - isUrgent: false, - }); + const [formData, setFormData] = useState(INITIAL_WIZARD_DATA); + + const updateFormData = (patch: Partial) => { + setFormData((prev) => ({ ...prev, ...patch })); + }; const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); - const updateFormData = (newData: Partial) => { - setFormData((prev) => ({ ...prev, ...newData })); - }; - - const handleNext = () => { - if (activeStep < 12) { - setActiveStep((prev) => prev + 1); - if (activeStep + 1 > maxStepReached) setMaxStepReached(activeStep + 1); - } - }; - const ActiveStepComponent = STEP_COMPONENTS[activeStep] || PlaceholderStep; return ( @@ -160,7 +148,7 @@ export default function MultiStepForm() { {i + 1 < activeStep ? ( ) : ( - i + 1 + Number(i + 1).toLocaleString("fa-IR") )} - - - - - diff --git a/ui/forms/IdentityForm.tsx b/ui/forms/IdentityForm.tsx deleted file mode 100644 index abbe73c..0000000 --- a/ui/forms/IdentityForm.tsx +++ /dev/null @@ -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>; - -const initialForm: IdentityFormData = { - applicantId: "", - firstName: "", - lastName: "", - fatherName: "", - nationalCode: "", - birthDate: "", - birthPlace: "", - gender: "", - religion: "", - nationality: "", - profilePhotoId: "", -}; - -export default function IdentityForm() { - const [formData, setFormData] = useState(initialForm); - const [errors, setErrors] = useState({}); - const [submitted, setSubmitted] = useState(false); - const [profilePhoto, setProfilePhoto] = useState(null); - const [profilePhotoPreview, setProfilePhotoPreview] = useState(""); - const [profilePhotoError, setProfilePhotoError] = useState(""); - const [birthDateValue, setBirthDateValue] = useState(null); - - const handleProfilePhotoChange = ( - event: React.ChangeEvent, - ) => { - 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) => { - 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 ( - - - -
- - - - - - - - - { - 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, - }, - }} - /> - - - - - {genderOptions.map((item) => ( - - {item} - - ))} - - - - {religionOptions.map((item) => ( - - {item} - - ))} - - - - - - - عکس پرسنلی - - - - فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت باشد. - - - - - {profilePhoto && ( - - فایل انتخاب‌شده: {profilePhoto.name} - - )} - - {profilePhotoError && ( - - {profilePhotoError} - - )} - -
-
-
-
- ); -} diff --git a/ui/forms/LoginForm.tsx b/ui/forms/LoginForm.tsx index ed5994f..65050c3 100644 --- a/ui/forms/LoginForm.tsx +++ b/ui/forms/LoginForm.tsx @@ -1,27 +1,26 @@ import React, { useState } from "react"; -import { - Box, - Paper, - TextField, - Typography, - Button, - Container, - Stack, -} from "@mui/material"; +import { Box, TextField, Typography, Button, Container } from "@mui/material"; import { useApplicantLogin } from "@/hooks/auth.hook"; import { toast } from "sonner"; +import { handleAxiosError } from "@/core/utils"; +import { useRouter } from "next/navigation"; export default function LoginLayout() { const [nationalId, setNationalId] = useState(""); + const router = useRouter(); const { mutateAsync, isPending } = useApplicantLogin(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { - const { message } = await mutateAsync(nationalId); - toast.success(message); + await mutateAsync(nationalId); + if (isPending) { + toast.loading("در حال انتقال به فرم استخدامي"); + } + router.push("/form"); } catch (error) { - toast.error("خطا رخ داده است"); + console.log(error); + toast.error(handleAxiosError(error)); } }; @@ -60,6 +59,7 @@ export default function LoginLayout() { fullWidth variant="contained" size="large" + loading={isPending} sx={{ py: 1.5, borderRadius: 2, fontSize: "1rem" }} > ورود به سامانه diff --git a/ui/forms/identity/IdentityForm.tsx b/ui/forms/identity/IdentityForm.tsx new file mode 100644 index 0000000..2cd146d --- /dev/null +++ b/ui/forms/identity/IdentityForm.tsx @@ -0,0 +1,82 @@ +import { withFormik } from "formik"; +import InnerIdentityForm from "./InnerIdentityForm"; +import * as yup from "yup"; + +export interface IdentityFormValues { + firstName: string; + lastName: string; + birthDate: string; + birthPlace: string; + fatherName: string; + gender: string; + nationalCode: string; + nationality: string; + profilePhotoId: string; + religion: string; +} + +export interface WizardFormData { + identity: IdentityFormValues; +} + +export interface IdentityFormProps { + step: number; + setStep: React.Dispatch>; + data: WizardFormData; + update: (newData: Partial) => void; +} + +const IdentityFormValidationSchema = yup.object({ + firstName: yup.string().trim().required("نام الزامی است").min(2).max(50), + lastName: yup.string().trim().required("نام خانوادگی الزامی است").min(2).max(50), + birthDate: yup + .string() + .required("تاریخ تولد الزامی است") + .matches(/^\d{4}\/\d{2}\/\d{2}$/, "فرمت تاریخ تولد باید به شکل ۱۴۰۳/۰۱/۲۰ باشد"), + birthPlace: yup.string().trim().required("محل تولد الزامی است").min(2).max(80), + fatherName: yup.string().trim().required("نام پدر الزامی است").min(2).max(50), + gender: yup + .string() + .required("جنسیت الزامی است") + .oneOf(["male", "female", "other"], "جنسیت معتبر نیست"), + nationalCode: yup + .string() + .required("کد ملی الزامی است") + .matches(/^\d{10}$/, "کد ملی باید ۱۰ رقم باشد"), + nationality: yup.string().trim().required("تابعیت الزامی است").min(2).max(50), + profilePhotoId: yup.string().trim().required("عکس پرسنلی الزامی است"), + religion: yup.string().trim().required("دین الزامی است").min(2).max(50), +}); + +const EMPTY_IDENTITY_VALUES: IdentityFormValues = { + firstName: "", + lastName: "", + birthDate: "", + birthPlace: "", + fatherName: "", + gender: "", + nationalCode: "", + nationality: "", + profilePhotoId: "", + religion: "", +}; + +const IdentityForm = withFormik({ + enableReinitialize: true, + + mapPropsToValues: (props) => { + return props.data?.identity || EMPTY_IDENTITY_VALUES; + }, + + validationSchema: IdentityFormValidationSchema, + + handleSubmit: (values, { props }) => { + props.update({ + identity: values, + }); + + props.setStep((prev) => prev + 1); + }, +})(InnerIdentityForm); + +export default IdentityForm; diff --git a/ui/forms/identity/InnerIdentityForm.tsx b/ui/forms/identity/InnerIdentityForm.tsx new file mode 100644 index 0000000..6a76f53 --- /dev/null +++ b/ui/forms/identity/InnerIdentityForm.tsx @@ -0,0 +1,310 @@ +import { + Box, + Button, + MenuItem, + Paper, + TextField, + Typography, +} from "@mui/material"; +import { ErrorMessage, Form, FormikProps } from "formik"; +import { IdentityFormValues } from "@/core/types"; +import { IdentityFormProps } from "./IdentityForm"; +import { UploadFile } from "@mui/icons-material"; +import { genderOptions, religionOptions } from "@/core/constant"; +import { useState } from "react"; +export default function InnerIdentityForm( + props: FormikProps & IdentityFormProps, +) { + console.log(props.data) + const handleBack = () => { + // قبل از رفتن به عقب، مقادیر فعلی فرم را در استیت والد ذخیره کن + props.update({ identity: props.values }); + props.setStep(props.step - 1); + }; + const [profilePhoto, setProfilePhoto] = useState(null); + const [profilePhotoError, setProfilePhotoError] = useState(""); + + const handleProfilePhotoChange = ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + + if (!file) return; + + if (!file.type.startsWith("image/")) { + setProfilePhoto(null); + setProfilePhotoError("فقط فایل تصویری مجاز است"); + return; + } + + const maxSize = 500 * 1024; // 500KB + if (file.size > maxSize) { + setProfilePhoto(null); + setProfilePhotoError("حجم عکس باید حداکثر ۵۰۰ کیلوبایت باشد"); + return; + } + + setProfilePhoto(file); + setProfilePhotoError(""); + }; + + return ( + + +
+
+
+ + props.setFieldValue("firstName", e.target.value) + } + error={!!props.errors.firstName} + helperText={props.errors.firstName} + fullWidth + required + /> + +
+ + props.setFieldValue("lastName", e.target.value)} + error={!!props.errors.lastName} + helperText={props.errors.lastName} + fullWidth + required + /> + + + props.setFieldValue("fatherName", e.target.value) + } + fullWidth + /> + + + props.setFieldValue("nationalCode", e.target.value) + } + error={!!props.errors.nationalCode} + helperText={props.errors.nationalCode} + fullWidth + required + /> + + {/* + props.setFieldValue("birthDate", newValue) + } + slotProps={{ + textField: { + fullWidth: true, + error: !!props.errors.birthDate, + helperText: props.errors.birthDate, + }, + }} + /> */} + + + props.setFieldValue("birthPlace", e.target.value) + } + fullWidth + /> + + props.setFieldValue("gender", e.target.value)} + error={!!props.errors.gender} + helperText={props.errors.gender} + fullWidth + required + > + {genderOptions.map((item) => ( + + {item.label} + + ))} + + + props.setFieldValue("religion", e.target.value)} + fullWidth + > + {religionOptions.map((item) => ( + + {item} + + ))} + + + + props.setFieldValue("nationality", e.target.value) + } + error={!!props.errors.nationality} + helperText={props.errors.nationality} + fullWidth + required + /> + + + + عکس پرسنلی + + + + فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت باشد. + + + + + {profilePhoto && ( + + فایل انتخاب‌شده: {profilePhoto.name} + + )} + + {profilePhotoError && ( + + {profilePhotoError} + + )} + +
+ + + + +
+
+
+ ); +} diff --git a/ui/forms/register-center/InnerRegistrationCenterForm.tsx b/ui/forms/register-center/InnerRegistrationCenterForm.tsx new file mode 100644 index 0000000..e5e868d --- /dev/null +++ b/ui/forms/register-center/InnerRegistrationCenterForm.tsx @@ -0,0 +1,166 @@ +"use client"; +import { + Box, + Typography, + useTheme, + useMediaQuery, + Chip, + Button, + FormHelperText, +} from "@mui/material"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import { Form, FormikProps } from "formik"; +import BusinessIcon from "@mui/icons-material/Business"; +import LocationOnIcon from "@mui/icons-material/LocationOn"; +import LocalHospitalIcon from "@mui/icons-material/LocalHospital"; +import { useGetAllCenters } from "@/hooks/center.hook"; +import { + RegistrationCenterFormProps, + RegistrationCenterFormValues, +} from "./RegistrationCenterForm"; +import { CenterItem } from "@/core/types"; + +// تعریف اینترفیس برای تمیزی بیشتر +interface InnerFormProps + extends + FormikProps, + RegistrationCenterFormProps {} + +export default function InnerRegistrationCenterForm(props: InnerFormProps) { + const { data } = useGetAllCenters(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const handleBack = () => { + props.update({ registrationCenter: props.values.selectedCenter}); + props.setStep((prev) => Math.max(1, prev - 1)); + }; + + // منطق نمایش خطا + const isSelectedCenterError = + (props.touched.selectedCenter || props.submitCount > 0) && + !!props.errors.selectedCenter; + + const handleCenterSelect = (center: CenterItem) => { + props.setFieldValue("selectedCenter", center); + props.setFieldTouched("selectedCenter", true, true); // فعال کردن حالت لمس شده + }; + + const handleNext = async () => { + // اگر مرحله اول است، فیلد را لمس کن تا اگر خالی بود خطا نشان دهد + if (props.step === 1) { + props.setFieldTouched("selectedCenter", true, true); + } + + // اعتبارسنجی دستی کل فرم + const errors = await props.validateForm(); + + // اگر در گام فعلی خطایی وجود ندارد، برو مرحله بعد + if (Object.keys(errors).length === 0) { + if (props.step === 12) { + props.submitForm(); // ثبت نهایی + } else { + props.setStep(props.step + 1); + } + } + }; + + const renderCenterList = () => ( + +
+ {data?.data.map((center: CenterItem) => { + const isSelected = props.values.selectedCenter?.id === center.id; + return ( +
+ handleCenterSelect(center)} + sx={{ + p: 2.5, + borderRadius: "18px", + border: isSelected + ? "2px solid #2563eb" + : isSelectedCenterError + ? "2px solid #d32f2f" + : "1px solid #e2e8f0", + backgroundColor: isSelected ? "#eff6ff" : "#fff", + cursor: "pointer", + transition: "all 0.25s ease", + "&:hover": { borderColor: "#2563eb" }, + }} + > + + + + + + {center.name} + + + + {center.address} + + + {isSelected && } + + +
+ ); + })} +
+ + {/* نمایش پیام خطا به صورت تمیز زیر لیست */} + {isSelectedCenterError && ( + + {props.errors.selectedCenter as string} + + )} +
+ ); + + return ( +
+
+ {/* رندر محتوای استپ ها */} + {props.step === 1 ? ( + renderCenterList() + ) : ( + محتوای مرحله {props.step} + )} +
+ + + + + +
+ ); +} diff --git a/ui/forms/register-center/RegistrationCenterForm.tsx b/ui/forms/register-center/RegistrationCenterForm.tsx index 814dd33..f3640db 100644 --- a/ui/forms/register-center/RegistrationCenterForm.tsx +++ b/ui/forms/register-center/RegistrationCenterForm.tsx @@ -1,327 +1,56 @@ -"use client"; -import React, { useState } from "react"; -import { - Box, - Button, - Typography, - Paper, - Container, - useTheme, - useMediaQuery, - Chip, -} from "@mui/material"; -import CheckCircleIcon from "@mui/icons-material/CheckCircle"; -import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; -import BusinessIcon from "@mui/icons-material/Business"; -import LocationOnIcon from "@mui/icons-material/LocationOn"; -import LocalHospitalIcon from "@mui/icons-material/LocalHospital"; +import { withFormik } from "formik"; +import * as yup from "yup"; +import InnerRegistrationCenterForm from "./InnerRegistrationCenterForm"; +import { CenterItem, IdentityFormValues } from "@/core/types"; -const TOTAL_STEPS = 12; -const STEP_LABELS = [ - "انتخاب مرکز", - "موقعیت و آدرس", - "وضعیت فوریت", - "توضیحات تکمیلی", - "ساعات کاری", - "تصاویر مرکز", - "تجهیزات موجود", - "پرسنل", - "بیمه‌های طرف قرارداد", - "مجوزها", - "شرایط پذیرش", - "بررسی نهایی", -]; +export interface RegistrationCenterFormValues { + selectedCenter: CenterItem | null; +} -type CenterItem = { - id: string; - name: string; - address: string; - isUrgent: boolean; +export interface WizardFormData { + registrationCenter: { + selectedCenter: CenterItem | null; + }; + identity: IdentityFormValues; +} + +export interface RegistrationCenterFormProps { + step: number; + setStep: React.Dispatch>; + data: WizardFormData; + update: (patch: Partial) => void; +} + +const EMPTY_VALUES: RegistrationCenterFormValues = { + selectedCenter: null, }; -const centersMock: CenterItem[] = [ - { - id: "1", - name: "مرکز درمانی امید", - address: "تهران، خیابان ولیعصر، بالاتر از پارک ساعی، پلاک ۱۲۳", - isUrgent: true, +const RegistrationCenterFormValidationSchema = yup.object({ + selectedCenter: yup + .mixed() + .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(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 ( -
- 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)", - }, - }} - > - - - - - - {center.name} - - - - - - - {center.address} - - - - - - } - label={ - center.isUrgent - ? "استخدام فوری دارد" - : "استخدام فوری ندارد" - } - sx={{ - fontWeight: 700, - backgroundColor: center.isUrgent - ? "#fee2e2" - : "#e2e8f0", - color: center.isUrgent ? "#b91c1c" : "#475569", - "& .MuiChip-icon": { - color: center.isUrgent ? "#dc2626" : "#64748b", - }, - }} - /> - - {isSelected && ( - - )} - - - -
- ); - })} - - - ); - }; - - const renderStepContent = (step: number) => { - switch (step) { - case 1: - return renderCenterList(); - - case 2: - return selectedCenter ? ( - - - مرکز انتخاب‌شده - - - - - {selectedCenter.name} - - - {selectedCenter.address} - - - - - ) : ( - - ابتدا از مرحله قبل یک مرکز را انتخاب کنید. - - ); - - case 3: - return selectedCenter ? ( - - - - وضعیت استخدام فوری - - - {selectedCenter.isUrgent - ? "این مرکز دارای استخدام فوری است." - : "این مرکز در حال حاضر استخدام فوری ندارد."} - - - - ) : ( - - ابتدا یک مرکز انتخاب کنید. - - ); - - default: - return ( - - محتوای مرحله «{STEP_LABELS[step - 1]}»
- (در حال توسعه...) -
- ); - } - }; - - return ( - <> -
-
- {renderStepContent(activeStep)} -
-
- - ); -} +export default RegistrationCenterForm;