From cfb48c5bb015e4148d156d29f8abfc3adfb693a1 Mon Sep 17 00:00:00 2001 From: webserver-lab Date: Tue, 2 Jun 2026 17:08:52 +0330 Subject: [PATCH] change some files --- app/api/cdn/upload/route.ts | 93 +++ core/caller/index.ts | 2 +- core/types/index.ts | 6 +- core/utils/index.ts | 53 +- hooks/center.hook.ts | 14 +- services/apis/center.api.ts | 5 + services/apis/identity.api.ts | 2 +- ui/MultiForm.tsx | 47 +- ui/forms/CourseForm.tsx | 88 --- ui/forms/CourseSection.tsx | 67 -- ui/forms/EducationForm.tsx | 336 ----------- ui/forms/EducationSection.tsx | 82 --- ui/forms/JobInfoForm.tsx | 172 ------ ui/forms/JobRequestForm.tsx | 263 -------- ui/forms/JobRequestSection.tsx | 43 -- ui/forms/LoginForm.tsx | 7 +- ui/forms/PersonalInfoForm.tsx | 283 --------- ui/forms/PhysicalInfoForm.tsx | 286 --------- ui/forms/ReferralForm.tsx | 241 -------- ui/forms/RelationForm.tsx | 141 ----- ui/forms/SkillsForm.tsx | 214 ------- ui/forms/WorkExperienceForm.tsx | 68 --- ui/forms/WorkExperienceSection.tsx | 74 --- ui/forms/course/CourseForm.tsx | 75 +++ ui/forms/course/InnerCourseForm.tsx | 207 +++++++ ui/forms/course/constant/index.ts | 14 + ui/forms/course/validation/index.ts | 18 + ui/forms/education/EducationForm.tsx | 61 ++ ui/forms/education/InnerEducationForm.tsx | 445 ++++++++++++++ ui/forms/education/constants/index.ts | 31 + ui/forms/education/types/index.ts | 37 ++ ui/forms/education/validation/index.ts | 19 + ui/forms/identity/IdentityForm.tsx | 5 +- ui/forms/identity/InnerIdentityForm.tsx | 570 +++++++++++++----- ui/forms/jobInfo/InnerJobInfoForm.tsx | 207 +++++++ ui/forms/jobInfo/JobInfoForm.tsx | 32 + ui/forms/jobInfo/constant/index.ts | 20 + ui/forms/jobInfo/types/index.ts | 31 + ui/forms/jobInfo/validation/index.ts | 26 + ui/forms/jobRequest/InnerJobRequestForm.tsx | 334 ++++++++++ ui/forms/jobRequest/JobRequestForm.tsx | 42 ++ ui/forms/jobRequest/constant/index.ts | 50 ++ ui/forms/jobRequest/types/index.ts | 44 ++ ui/forms/jobRequest/validation/index.ts | 35 ++ ui/forms/personal/InnerPersonalInfoForm.tsx | 326 ++++++++++ ui/forms/personal/PersonalInfoForm.tsx | 49 ++ ui/forms/personal/constants/index.ts | 61 ++ ui/forms/personal/types/index.ts | 57 ++ .../validation/PersonalInfoFormValidation.tsx | 112 ++++ .../physicalInfo/InnerPhysicalInfoForm.tsx | 297 +++++++++ ui/forms/physicalInfo/PhysicalInfoForm.tsx | 52 ++ ui/forms/physicalInfo/constants/index.ts | 35 ++ ui/forms/physicalInfo/types/index.ts | 32 + ui/forms/physicalInfo/validation/index.ts | 68 +++ ui/forms/referral/InnerReferralForm.tsx | 320 ++++++++++ ui/forms/referral/ReferralForm.tsx | 40 ++ ui/forms/referral/constant/index.ts | 19 + ui/forms/referral/types/index.ts | 32 + ui/forms/referral/validation/index.ts | 24 + .../InnerRegistrationCenterForm.tsx | 84 ++- .../RegistrationCenterForm.tsx | 1 + ui/forms/relation/InnerRelationForm.tsx | 190 ++++++ ui/forms/relation/RelationForm.tsx | 36 ++ ui/forms/relation/constant/index.ts | 18 + ui/forms/relation/types/index.ts | 28 + ui/forms/relation/validation/index.ts | 27 + ui/forms/skillsForm/InnerSkillsForm.tsx | 349 +++++++++++ ui/forms/skillsForm/SkillsForm.tsx | 41 ++ ui/forms/skillsForm/constant/index.ts | 39 ++ ui/forms/skillsForm/types/index.ts | 46 ++ ui/forms/skillsForm/validation/index.ts | 30 + .../InnerWorkExperienceForm.tsx | 293 +++++++++ .../workExperience/WorkExperienceForm.tsx | 46 ++ ui/forms/workExperience/constant/index.ts | 27 + ui/forms/workExperience/types/index.ts | 29 + ui/forms/workExperience/validation/index.ts | 61 ++ 76 files changed, 5204 insertions(+), 2555 deletions(-) create mode 100644 app/api/cdn/upload/route.ts delete mode 100644 ui/forms/CourseForm.tsx delete mode 100644 ui/forms/CourseSection.tsx delete mode 100644 ui/forms/EducationForm.tsx delete mode 100644 ui/forms/EducationSection.tsx delete mode 100644 ui/forms/JobInfoForm.tsx delete mode 100644 ui/forms/JobRequestForm.tsx delete mode 100644 ui/forms/JobRequestSection.tsx delete mode 100644 ui/forms/PersonalInfoForm.tsx delete mode 100644 ui/forms/PhysicalInfoForm.tsx delete mode 100644 ui/forms/ReferralForm.tsx delete mode 100644 ui/forms/RelationForm.tsx delete mode 100644 ui/forms/SkillsForm.tsx delete mode 100644 ui/forms/WorkExperienceForm.tsx delete mode 100644 ui/forms/WorkExperienceSection.tsx create mode 100644 ui/forms/course/CourseForm.tsx create mode 100644 ui/forms/course/InnerCourseForm.tsx create mode 100644 ui/forms/course/constant/index.ts create mode 100644 ui/forms/course/validation/index.ts create mode 100644 ui/forms/education/EducationForm.tsx create mode 100644 ui/forms/education/InnerEducationForm.tsx create mode 100644 ui/forms/education/constants/index.ts create mode 100644 ui/forms/education/types/index.ts create mode 100644 ui/forms/education/validation/index.ts create mode 100644 ui/forms/jobInfo/InnerJobInfoForm.tsx create mode 100644 ui/forms/jobInfo/JobInfoForm.tsx create mode 100644 ui/forms/jobInfo/constant/index.ts create mode 100644 ui/forms/jobInfo/types/index.ts create mode 100644 ui/forms/jobInfo/validation/index.ts create mode 100644 ui/forms/jobRequest/InnerJobRequestForm.tsx create mode 100644 ui/forms/jobRequest/JobRequestForm.tsx create mode 100644 ui/forms/jobRequest/constant/index.ts create mode 100644 ui/forms/jobRequest/types/index.ts create mode 100644 ui/forms/jobRequest/validation/index.ts create mode 100644 ui/forms/personal/InnerPersonalInfoForm.tsx create mode 100644 ui/forms/personal/PersonalInfoForm.tsx create mode 100644 ui/forms/personal/constants/index.ts create mode 100644 ui/forms/personal/types/index.ts create mode 100644 ui/forms/personal/validation/PersonalInfoFormValidation.tsx create mode 100644 ui/forms/physicalInfo/InnerPhysicalInfoForm.tsx create mode 100644 ui/forms/physicalInfo/PhysicalInfoForm.tsx create mode 100644 ui/forms/physicalInfo/constants/index.ts create mode 100644 ui/forms/physicalInfo/types/index.ts create mode 100644 ui/forms/physicalInfo/validation/index.ts create mode 100644 ui/forms/referral/InnerReferralForm.tsx create mode 100644 ui/forms/referral/ReferralForm.tsx create mode 100644 ui/forms/referral/constant/index.ts create mode 100644 ui/forms/referral/types/index.ts create mode 100644 ui/forms/referral/validation/index.ts create mode 100644 ui/forms/relation/InnerRelationForm.tsx create mode 100644 ui/forms/relation/RelationForm.tsx create mode 100644 ui/forms/relation/constant/index.ts create mode 100644 ui/forms/relation/types/index.ts create mode 100644 ui/forms/relation/validation/index.ts create mode 100644 ui/forms/skillsForm/InnerSkillsForm.tsx create mode 100644 ui/forms/skillsForm/SkillsForm.tsx create mode 100644 ui/forms/skillsForm/constant/index.ts create mode 100644 ui/forms/skillsForm/types/index.ts create mode 100644 ui/forms/skillsForm/validation/index.ts create mode 100644 ui/forms/workExperience/InnerWorkExperienceForm.tsx create mode 100644 ui/forms/workExperience/WorkExperienceForm.tsx create mode 100644 ui/forms/workExperience/constant/index.ts create mode 100644 ui/forms/workExperience/types/index.ts create mode 100644 ui/forms/workExperience/validation/index.ts diff --git a/app/api/cdn/upload/route.ts b/app/api/cdn/upload/route.ts new file mode 100644 index 0000000..55f1b95 --- /dev/null +++ b/app/api/cdn/upload/route.ts @@ -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 }, + ); + } +} diff --git a/core/caller/index.ts b/core/caller/index.ts index 8a86cc5..baa9ac6 100644 --- a/core/caller/index.ts +++ b/core/caller/index.ts @@ -1,7 +1,7 @@ import axios from "axios"; const callAPISetting = axios.create({ - baseURL: "http://localhost:4000/api/v1", + baseURL: "http://localhost:5000/api/v1", withCredentials: true, }); diff --git a/core/types/index.ts b/core/types/index.ts index 68f7c1e..acfc137 100644 --- a/core/types/index.ts +++ b/core/types/index.ts @@ -1,3 +1,5 @@ +import { EducationFormValues } from "@/ui/forms/education/EducationForm"; + export type genderType = "male" | "female" | "other"; export interface IdentityFormValues { firstName: string; @@ -23,12 +25,13 @@ export interface WizardFormData { selectedCenter: CenterItem | null; }; identity: IdentityFormValues; // برای مرحله ۲ + education: EducationFormValues[]; // به جای education } // مقدار اولیه برای همه مراحل export const INITIAL_WIZARD_DATA: WizardFormData = { registrationCenter: { - selectedCenter:null + selectedCenter: null, }, identity: { firstName: "", @@ -42,4 +45,5 @@ export const INITIAL_WIZARD_DATA: WizardFormData = { profilePhotoId: "", religion: "", }, + education: [], }; diff --git a/core/utils/index.ts b/core/utils/index.ts index 7ddc935..e49d40b 100644 --- a/core/utils/index.ts +++ b/core/utils/index.ts @@ -1,4 +1,5 @@ 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)) { @@ -7,4 +8,54 @@ export function handleAxiosError(error: unknown) { } else { return "Unexpected error"; } -} \ No newline at end of file +} + +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"); +}; diff --git a/hooks/center.hook.ts b/hooks/center.hook.ts index 6ccfc72..aed579f 100644 --- a/hooks/center.hook.ts +++ b/hooks/center.hook.ts @@ -1,8 +1,10 @@ -import { getAllCenters } from "@/services/apis/center.api"; -import { useQuery } from "@tanstack/react-query"; +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 useGetAllCenters = () => useQuery({ - queryKey:["get-all-centers"], - queryFn:getAllCenters -}) \ No newline at end of file +export const useSelectCenter = () => useMutation({ mutationFn: selectCenter }); diff --git a/services/apis/center.api.ts b/services/apis/center.api.ts index 8908290..db8d674 100644 --- a/services/apis/center.api.ts +++ b/services/apis/center.api.ts @@ -3,3 +3,8 @@ 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); +} diff --git a/services/apis/identity.api.ts b/services/apis/identity.api.ts index 5af06d5..a93cbc0 100644 --- a/services/apis/identity.api.ts +++ b/services/apis/identity.api.ts @@ -3,6 +3,6 @@ import { IdentityFormValues } from "@/core/types"; export async function sendIdentityForm(data: IdentityFormValues) { return await callAPI - .post(`/form/identity/create`, { data }) + .post(`/form/identity/create`, data ) .then((res) => res.data); } diff --git a/ui/MultiForm.tsx b/ui/MultiForm.tsx index fb6929a..5d4a047 100644 --- a/ui/MultiForm.tsx +++ b/ui/MultiForm.tsx @@ -11,18 +11,20 @@ import { } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import IdentityForm from "./forms/identity/IdentityForm"; -import PersonalInfoForm from "./forms/PersonalInfoForm"; -import PhysicalInfoForm from "./forms/PhysicalInfoForm"; -import EducationSection from "./forms/EducationSection"; -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 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) => ( @@ -38,13 +40,13 @@ const STEP_COMPONENTS: Record> = { 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 استفاده می‌کنند }; @@ -67,9 +69,16 @@ const STEP_LABELS = [ // --- ۳. کامپوننت اصلی استپر --- export default function MultiStepForm() { - const [activeStep, setActiveStep] = useState(1); - const [maxStepReached, setMaxStepReached] = useState(1); - const [formData, setFormData] = useState(INITIAL_WIZARD_DATA); + 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(() => { + // اگر می‌خواهی بعد از رفرش دیتا نپرد، اینجا از localStorage بخون + return INITIAL_WIZARD_DATA; + }); const updateFormData = (patch: Partial) => { setFormData((prev) => ({ ...prev, ...patch })); @@ -105,7 +114,7 @@ export default function MultiStepForm() { variant="h5" sx={{ fontWeight: 900, mb: 4, color: "#1e293b" }} > - پنل ثبت مرکز + مراحل فرم {STEP_LABELS.map((label, i) => ( 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 ( - - {isDeletable && ( - - - - )} - - - handleChange("title", e.target.value)} - /> - - handleChange("institution", e.target.value)} - /> - - handleChange("year", e.target.value)} - /> - - handleChange("duration", e.target.value)} - placeholder="مثلاً 40 ساعت" - /> - - handleChange("description", e.target.value)} - sx={{ gridColumn: { xs: "1", md: "1 / -1" } }} - /> - - - ); -} diff --git a/ui/forms/CourseSection.tsx b/ui/forms/CourseSection.tsx deleted file mode 100644 index 00634d9..0000000 --- a/ui/forms/CourseSection.tsx +++ /dev/null @@ -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 ( - - {courses.map((course) => ( - handleUpdate(course.id, data)} - onRemove={() => handleRemove(course.id)} - isDeletable={courses.length > 1} - /> - ))} - - - - ); -} diff --git a/ui/forms/EducationForm.tsx b/ui/forms/EducationForm.tsx deleted file mode 100644 index 037a280..0000000 --- a/ui/forms/EducationForm.tsx +++ /dev/null @@ -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( - value ?? { ...initialValues, applicantId: applicantId ?? "" }, - ); - 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(""); - - }; - // 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) => { - const v = e.target.value; - setNext((p) => ({ ...p, [field]: v }) as EducationFormState); - }; - - const handleNumber = - (field: keyof EducationFormState) => - (e: React.ChangeEvent) => { - 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 ( - - - انتخاب کنید - {( - [ - "زیر دیپلم", - "دیپلم", - "کاردانی", - "کارشناسی", - "کارشناسی ارشد", - "دکتری", - "حوزوی", - "سایر", - ] as const - ).map((d) => ( - - {d} - - ))} - - - - - - - - - - - - - - - تصوير مدرك تحصيلي - - - - فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت باشد. - - - - - {profilePhoto && ( - - فایل انتخاب‌شده: {profilePhoto.name} - - )} - - {profilePhotoError && ( - - {profilePhotoError} - - )} - - - - - - - ); -} - -export { initialValues as educationInitialValues }; diff --git a/ui/forms/EducationSection.tsx b/ui/forms/EducationSection.tsx deleted file mode 100644 index b43154a..0000000 --- a/ui/forms/EducationSection.tsx +++ /dev/null @@ -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([ - { ...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 ( - - - سوابق تحصیلی - - - {educations.map((ed, index) => ( - - - {/* دکمه حذف برای هر آیتم */} - {educations.length > 1 && ( - removeEducation(index)} - sx={{ position: "absolute", top: 8, right: 8, color: "#ef4444" }} - > - - - )} - - - مدرک تحصیلی {index + 1} - - - updateEducation(index, newData)} - applicantId={applicantId} - /> - - ))} - - - - ); -} diff --git a/ui/forms/JobInfoForm.tsx b/ui/forms/JobInfoForm.tsx deleted file mode 100644 index 6a07123..0000000 --- a/ui/forms/JobInfoForm.tsx +++ /dev/null @@ -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) => { - 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 ( - - - - - - {/* وضعیت بازنشستگی */} - - هیچکدام - بازنشسته - بازخرید - - - {/* Switch Buttons */} - - } - label="از پرسنل حال حاضر هستم" - /> - - } - label="سابقه همکاری در گذشته دارم" - /> - - } - label="در حال حاضر مشغول به کار هستم" - /> - - } - label="تمایل به شغل دوم دارم" - /> - - } - label="نظامی هستم" - /> - - } - label="دارای سابقه بیمه هستم" - /> - - {/* Conditional Insurance Fields */} - {value?.hasInsurance && ( - <> - - - - )} - - - ); -} diff --git a/ui/forms/JobRequestForm.tsx b/ui/forms/JobRequestForm.tsx deleted file mode 100644 index 9715377..0000000 --- a/ui/forms/JobRequestForm.tsx +++ /dev/null @@ -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( - value ?? initialValues, - ); - const [errors, setErrors] = useState>({}); - - 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) => { - 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 = {}; - - 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 ( - - - - - انتخاب... - {jobCategories.map((item) => ( - - {item.name} - - ))} - - - - انتخاب... - {filteredJobs.map((item) => ( - - {item.title} - - ))} - - - - - - انتخاب ... - {relationTypes.map((item) => ( - - {item} - - ))} - - - - - - - - - - - - ); -} diff --git a/ui/forms/JobRequestSection.tsx b/ui/forms/JobRequestSection.tsx deleted file mode 100644 index b025c81..0000000 --- a/ui/forms/JobRequestSection.tsx +++ /dev/null @@ -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 ( -
- {jobs.map((job) => ( - updateJob(job.id, newData)} - onRemove={() => removeJob(job.id)} - isDeletable={jobs.length > 1} - /> - ))} - - -
- ); -} diff --git a/ui/forms/LoginForm.tsx b/ui/forms/LoginForm.tsx index 65050c3..007a5ae 100644 --- a/ui/forms/LoginForm.tsx +++ b/ui/forms/LoginForm.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; 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 { handleAxiosError, handleLoginRedirect } from "@/core/utils"; import { useRouter } from "next/navigation"; export default function LoginLayout() { @@ -13,11 +13,12 @@ export default function LoginLayout() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { - await mutateAsync(nationalId); + const response = await mutateAsync(nationalId); + console.log("response => ",response) if (isPending) { toast.loading("در حال انتقال به فرم استخدامي"); } - router.push("/form"); + handleLoginRedirect(router, response); } catch (error) { console.log(error); toast.error(handleAxiosError(error)); diff --git a/ui/forms/PersonalInfoForm.tsx b/ui/forms/PersonalInfoForm.tsx deleted file mode 100644 index 704d172..0000000 --- a/ui/forms/PersonalInfoForm.tsx +++ /dev/null @@ -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[] = [ - "کارت پایان خدمت", - "در حال خدمت", - "معافیت تحصیلی", - "معافیت دائم", - "انجام نشده", -]; - -const EDUCATION_OPTIONS: Exclude[] = [ - "زیر دیپلم", - "دیپلم", - "دانشجو", - "کاردانی", - "کارشناسی", - "کارشناسی ارشد", - "دکترا", -]; - -export default function PersonalInfoForm({ - value, - onChange, -}: { - value?: PersonalInfoFormState; - onChange?: (val: PersonalInfoFormState) => void; -}) { - const [formData, setFormData] = useState(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 = - (field: K) => - (e: React.ChangeEvent) => { - const val = e.target.value as PersonalInfoFormState[K]; - setNext((p) => ({ ...p, [field]: val })); - }; - - const handleNumber = - (field: K) => - (e: React.ChangeEvent) => { - const v = e.target.value; - setNext((p) => ({ ...p, [field]: (v === "" ? "" : Number(v)) as PersonalInfoFormState[K] })); - }; - - // تغییر وضعیت تاهل + پاکسازی فیلدهای شرطی مربوط به همسر/فرزند - const handleMaritalStatusChange = (e: React.ChangeEvent) => { - 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) => { - const ms = e.target.value as MilitaryStatus; - setNext((p) => ({ - ...p, - militaryStatus: ms, - permanentExemptionReason: ms === "معافیت دائم" ? p.permanentExemptionReason : "", - })); - }; - - const isPermanentExempt = formData.militaryStatus === "معافیت دائم"; - - return ( - - - - {/* وضعیت تاهل */} - - انتخاب کنید - مجرد - متاهل - متارکه - فوت همسر - - - {/* همسر (شرطی) */} - {formData.maritalStatus === "متاهل" && ( - <> - - - - - - )} - - {/* تعداد فرزند (شرطی) */} - {["متاهل", "متارکه", "فوت همسر"].includes(formData.maritalStatus) && ( - - )} - - {/* وضعیت نظام وظیفه (لیستی) */} - - انتخاب کنید - {MILITARY_OPTIONS.map((opt) => ( - - {opt} - - ))} - - - {/* علت معافیت دائم (شرطی) */} - {isPermanentExempt && ( - - )} - - {/* تحصیلات پدر/مادر (لیستی) */} - - انتخاب کنید - {EDUCATION_OPTIONS.map((opt) => ( - - {opt} - - ))} - - - - - - انتخاب کنید - {EDUCATION_OPTIONS.map((opt) => ( - - {opt} - - ))} - - - - - {/* بقیه فیلدهای قبلی (نمونه، اگر لازم داری کاملش می‌کنم یا در همین فایل نگه می‌داریم) */} - - - - - - - - - - setNext((p) => ({ ...p, hasCriminalRecord: e.target.value === "true", criminalDescription: e.target.value === "true" ? p.criminalDescription : "" }))} - fullWidth - > - خیر - بله - - - {formData.hasCriminalRecord && ( - - )} - - ); -} diff --git a/ui/forms/PhysicalInfoForm.tsx b/ui/forms/PhysicalInfoForm.tsx deleted file mode 100644 index f4828e9..0000000 --- a/ui/forms/PhysicalInfoForm.tsx +++ /dev/null @@ -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( - 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) => { - const v = e.target.value; - setNext((p) => ({ ...p, [field]: v }) as PhysicalInfoFormState); - }; - - const handleNumber = - (field: keyof PhysicalInfoFormState) => - (e: React.ChangeEvent) => { - const v = toNumberOrEmpty(e.target.value); - setNext((p) => ({ ...p, [field]: v }) as PhysicalInfoFormState); - }; - - const handleBoolSelect = - (field: "hasDisability" | "hasChronicDisease") => - (e: React.ChangeEvent) => { - 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 ( - - - - {/* bloodType */} - - انتخاب کنید - {(["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"] as const).map( - (bt) => ( - - {bt} - - ), - )} - - - {/* height */} - - - {/* weight */} - - - {/* bmi */} - - - {/* specialMark */} - - - {/* hasDisability */} - - خیر - بله - - - {/* hasChronicDisease */} - - خیر - بله - - - {/* disabilityDescription */} - {formData.hasDisability && ( - - - - )} - - {/* chronicDiseaseDescription */} - {formData.hasChronicDisease && ( - - - - )} - - {/* surgeryHistory */} - - - - - {/* medications */} - - - - - ); -} diff --git a/ui/forms/ReferralForm.tsx b/ui/forms/ReferralForm.tsx deleted file mode 100644 index 541f05b..0000000 --- a/ui/forms/ReferralForm.tsx +++ /dev/null @@ -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) => { - onChange({ ...value, [key]: e.target.value }); - }; - - return ( - - - معرف {index + 1} - - - - - - - - - - - - - - - - - مستقیم - غیرمستقیم - - - - - - - - - - ); -} - -// ---------------- 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 ( - - - - - - - - - - - {items.map((item, idx) => ( - updateItem(idx, next)} - onRemove={() => removeItem(idx)} - disableRemove={items.length <= minItems} - /> - ))} - - - ); -} diff --git a/ui/forms/RelationForm.tsx b/ui/forms/RelationForm.tsx deleted file mode 100644 index 3db48bb..0000000 --- a/ui/forms/RelationForm.tsx +++ /dev/null @@ -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) => { - const next: ValueType = [ - { ...safeValue[0] }, - { ...safeValue[1] }, - ]; - - next[index] = { ...next[index], [key]: e.target.value }; - safeOnChange(next); - }; - - const renderPersonFields = (index: 0 | 1) => ( - - - آشنای {index === 0 ? "اول" : "دوم"} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - - return ( - - - - توجه - مشخصات دو نفر از آشنایان را وارد کنید و از درج بستگان درجه یک (پدر، مادر، همسر، برادر و خواهر) خودداری نمایید. - - - {renderPersonFields(0)} - - {renderPersonFields(1)} - - ); -} diff --git a/ui/forms/SkillsForm.tsx b/ui/forms/SkillsForm.tsx deleted file mode 100644 index 7d26136..0000000 --- a/ui/forms/SkillsForm.tsx +++ /dev/null @@ -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 ( - - {/* 1. Computer Skills Section */} - - مهارت‌های کامپیوتری - - {(['pcUsage', 'word', 'excel', 'powerPoint', 'rahkaran', 'kasra', 'didgah', 'his'] as const).map((field) => ( - handleComputerChange(field, e.target.value)} - fullWidth - > - {proficiencyOptions.map((o) => ( - {o.label} - ))} - - ))} - handleComputerChange('otherSoftware', e.target.value)} - fullWidth - multiline - minRows={2} - sx={{ gridColumn: { xs: "1", md: "1 / -1" } }} - /> - - - - {/* 2. Language Skills Section */} - - آشنایی با زبان‌های خارجه - - - {/* English */} - handleLanguageChange("englishLevel", e.target.value)} - fullWidth - > - {proficiencyOptions.map((o) => {o.label})} - - - handleLanguageChange("hasEnglishCertificate", e.target.checked)} - /> - } - label="مدرک معتبر زبان انگلیسی دارد" - /> - - {value?.languageSkill.hasEnglishCertificate && ( - handleLanguageChange("englishCertificateType", e.target.value)} - fullWidth - sx={{ gridColumn: { xs: "1", md: "1 / -1" } }} - > - {certTypes.map((t) => {t})} - - )} - - handleLanguageChange("englishDescription", e.target.value)} - fullWidth - multiline - sx={{ gridColumn: { xs: "1", md: "1 / -1" } }} - /> - - - - {/* Arabic */} - handleLanguageChange("arabicLevel", e.target.value)} - fullWidth - > - {proficiencyOptions.map((o) => {o.label})} - - - - - handleLanguageChange("arabicDescription", e.target.value)} - fullWidth - multiline - sx={{ gridColumn: { xs: "1", md: "1 / -1" } }} - /> - - - - {/* Other Skills */} - handleLanguageChange("otherLanguagesDescription", e.target.value)} - fullWidth - multiline - sx={{ gridColumn: { xs: "1", md: "1 / -1" } }} - /> - handleLanguageChange("dialectsDescription", e.target.value)} - fullWidth - multiline - sx={{ gridColumn: { xs: "1", md: "1 / -1" } }} - /> - handleLanguageChange("otherSkills", e.target.value)} - fullWidth - multiline - minRows={3} - sx={{ gridColumn: { xs: "1", md: "1 / -1" } }} - /> - - - - ); -} diff --git a/ui/forms/WorkExperienceForm.tsx b/ui/forms/WorkExperienceForm.tsx deleted file mode 100644 index b9fa109..0000000 --- a/ui/forms/WorkExperienceForm.tsx +++ /dev/null @@ -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) => { - 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 ( - - - - - - - setHasNoExperience(e.target.checked)} />} - label="فاقد سابقه کاری هستم" - /> - - - - onChange({ ...value, startYear: e.target.value.replace(/[^\d]/g, "") })} fullWidth disabled={value.hasNoExperience} inputMode="numeric" /> - onChange({ ...value, endYear: e.target.value.replace(/[^\d]/g, "") })} fullWidth disabled={value.hasNoExperience} inputMode="numeric" /> - - - - - ); -} diff --git a/ui/forms/WorkExperienceSection.tsx b/ui/forms/WorkExperienceSection.tsx deleted file mode 100644 index 3ee7995..0000000 --- a/ui/forms/WorkExperienceSection.tsx +++ /dev/null @@ -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 ( - - {items.map((item, idx) => ( - handleItemChange(idx, next)} - onRemove={() => handleRemoveItem(idx)} - disableRemove={items.length === 1} - /> - ))} - - - - - - ); -} diff --git a/ui/forms/course/CourseForm.tsx b/ui/forms/course/CourseForm.tsx new file mode 100644 index 0000000..8f27d23 --- /dev/null +++ b/ui/forms/course/CourseForm.tsx @@ -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>; + data: WizardFormData; + update: (patch: Partial) => 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({ + 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, + ) => { + const { props, setSubmitting } = bag; + + props.update({ courses: values.courses }); + props.setStep((prev) => prev + 1); + + setSubmitting(false); + }, +})(InnerCourseForm); + +export default CourseForm; diff --git a/ui/forms/course/InnerCourseForm.tsx b/ui/forms/course/InnerCourseForm.tsx new file mode 100644 index 0000000..4921e92 --- /dev/null +++ b/ui/forms/course/InnerCourseForm.tsx @@ -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 & 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 ( +
+ + {({ push, remove }) => ( + <> + {values.courses.map((course: CourseItem, index: number) => { + const itemErrors = getIn(errors, `courses.${index}`) || {}; + const itemTouched = getIn(touched, `courses.${index}`) || {}; + + return ( + + {values.courses.length > 1 && ( + remove(index)} + color="error" + sx={{ position: "absolute", top: 8, right: 8, zIndex: 2 }} + aria-label="remove-course" + > + + + )} + + + دوره {index + 1} + + + + + + + + + setFieldValue( + `courses.${index}.year`, + e.target.value === "" ? "" : Number(e.target.value), + ) + } + error={!!itemTouched.year && !!itemErrors.year} + helperText={itemTouched.year ? itemErrors.year : ""} + /> + + + + + + + ); + })} + + + + + + + + + + )} + +
+ ); +} diff --git a/ui/forms/course/constant/index.ts b/ui/forms/course/constant/index.ts new file mode 100644 index 0000000..ae72548 --- /dev/null +++ b/ui/forms/course/constant/index.ts @@ -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], +}; diff --git a/ui/forms/course/validation/index.ts b/ui/forms/course/validation/index.ts new file mode 100644 index 0000000..64def85 --- /dev/null +++ b/ui/forms/course/validation/index.ts @@ -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, "حداقل یک دوره باید ثبت شود"), +}); diff --git a/ui/forms/education/EducationForm.tsx b/ui/forms/education/EducationForm.tsx new file mode 100644 index 0000000..1f41c06 --- /dev/null +++ b/ui/forms/education/EducationForm.tsx @@ -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>; + data: WizardFormData; + update: (patch: Partial) => void; +}; + +const EducationForm = withFormik({ + displayName: "EducationForm", + enableReinitialize: true, + + mapPropsToValues: (props) => { + return { + education: props.data?.education ?? EDUCATION_EMPTY_VALUES, + }; + }, + + // validationSchema: EducationValidationSchema, + + handleSubmit: async ( + values, + bag: FormikBag, + ) => { + const { props, setSubmitting } = bag; + + props.update({ education: values.education }); + props.setStep((prev) => prev + 1); + + setSubmitting(false); + }, +})(InnerEducationForm); + +export default EducationForm; diff --git a/ui/forms/education/InnerEducationForm.tsx b/ui/forms/education/InnerEducationForm.tsx new file mode 100644 index 0000000..82c2dcb --- /dev/null +++ b/ui/forms/education/InnerEducationForm.tsx @@ -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 & EducationFormProps; + +export default function InnerEducationForm(props: Props) { + const { values, errors, touched, handleChange, setFieldValue } = props; + + const [uploadError, setUploadError] = useState>({}); + const [uploading, setUploading] = useState>({}); + const [uploadProgress, setUploadProgress] = useState>( + {}, + ); + const [selectedFile, setSelectedFile] = useState>( + {}, + ); + + const abortControllerRef = useRef>({}); + + 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, + 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 ( +
+ + {({ push, remove }) => ( + <> + {values?.education?.map((item, index) => { + const itemErrors = getIn(errors, `education.${index}`) || {}; + const itemTouched = getIn(touched, `education.${index}`) || {}; + + return ( + + + + سابقه تحصیلی {index + 1} + + + {values.education.length > 1 && ( + remove(index)}> + + + )} + + + + + انتخاب کنید + {DEGREE_OPTIONS.map((d) => ( + + {d} + + ))} + + + + + + + + setFieldValue( + `education.${index}.startYear`, + e.target.value === "" ? "" : Number(e.target.value), + ) + } + fullWidth + error={!!itemTouched.startYear && !!itemErrors.startYear} + helperText={ + itemTouched.startYear ? itemErrors.startYear : " " + } + /> + + + setFieldValue( + `education.${index}.endYear`, + e.target.value === "" ? "" : Number(e.target.value), + ) + } + fullWidth + error={!!itemTouched.endYear && !!itemErrors.endYear} + helperText={itemTouched.endYear ? itemErrors.endYear : " "} + /> + + + setFieldValue( + `education.${index}.gpa`, + e.target.value === "" ? "" : Number(e.target.value), + ) + } + fullWidth + error={!!itemTouched.gpa && !!itemErrors.gpa} + helperText={itemTouched.gpa ? itemErrors.gpa : " "} + /> + + + + تصویر مدرک تحصیلی + + + + + {uploading[index] && ( + + )} + + {selectedFile[index] && ( + + فایل انتخاب‌شده: {selectedFile[index]?.name} + + )} + + {uploading[index] && ( + + + + {uploadProgress[index] || 0}% در حال بارگذاری... + + + )} + + {!uploading[index] && item.certificateImageId && ( + + فایل با موفقیت بارگذاری شد + + )} + + {uploadError[index] && ( + + {uploadError[index]} + + )} + + + + + + + + {index < values.education.length - 1 && ( + + )} + + ); + })} + + + + + + + + + + )} + +
+ ); +} diff --git a/ui/forms/education/constants/index.ts b/ui/forms/education/constants/index.ts new file mode 100644 index 0000000..964a8aa --- /dev/null +++ b/ui/forms/education/constants/index.ts @@ -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[] = [ + "زیر دیپلم", + "دیپلم", + "کاردانی", + "کارشناسی", + "کارشناسی ارشد", + "دکتری", + "حوزوی", + "سایر", +]; diff --git a/ui/forms/education/types/index.ts b/ui/forms/education/types/index.ts new file mode 100644 index 0000000..ade9ee3 --- /dev/null +++ b/ui/forms/education/types/index.ts @@ -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 +// } diff --git a/ui/forms/education/validation/index.ts b/ui/forms/education/validation/index.ts new file mode 100644 index 0000000..2b25cdd --- /dev/null +++ b/ui/forms/education/validation/index.ts @@ -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, "حداقل یک سابقه تحصیلی باید وارد شود"), +}); diff --git a/ui/forms/identity/IdentityForm.tsx b/ui/forms/identity/IdentityForm.tsx index 2cd146d..4242d0a 100644 --- a/ui/forms/identity/IdentityForm.tsx +++ b/ui/forms/identity/IdentityForm.tsx @@ -31,10 +31,8 @@ const IdentityFormValidationSchema = yup.object({ lastName: yup.string().trim().required("نام خانوادگی الزامی است").min(2).max(50), birthDate: yup .string() - .required("تاریخ تولد الزامی است") - .matches(/^\d{4}\/\d{2}\/\d{2}$/, "فرمت تاریخ تولد باید به شکل ۱۴۰۳/۰۱/۲۰ باشد"), + .required("تاریخ تولد الزامی است"), birthPlace: yup.string().trim().required("محل تولد الزامی است").min(2).max(80), - fatherName: yup.string().trim().required("نام پدر الزامی است").min(2).max(50), gender: yup .string() .required("جنسیت الزامی است") @@ -71,6 +69,7 @@ const IdentityForm = withFormik({ validationSchema: IdentityFormValidationSchema, handleSubmit: (values, { props }) => { + console.log('submitted identity') props.update({ identity: values, }); diff --git a/ui/forms/identity/InnerIdentityForm.tsx b/ui/forms/identity/InnerIdentityForm.tsx index 6a76f53..cadca49 100644 --- a/ui/forms/identity/InnerIdentityForm.tsx +++ b/ui/forms/identity/InnerIdentityForm.tsx @@ -1,6 +1,11 @@ +"use client"; import { + Alert, + AlertTitle, + Avatar, Box, Button, + IconButton, MenuItem, Paper, TextField, @@ -9,47 +14,162 @@ import { import { ErrorMessage, Form, FormikProps } from "formik"; import { IdentityFormValues } from "@/core/types"; import { IdentityFormProps } from "./IdentityForm"; -import { UploadFile } from "@mui/icons-material"; +import { CheckCircle, Close, Person, UploadFile } from "@mui/icons-material"; import { genderOptions, religionOptions } from "@/core/constant"; -import { useState } from "react"; +import { useRef, useState } from "react"; +import { DatePicker } from "@mui/x-date-pickers"; +import axios from "axios"; +import { toast } from "sonner"; +import { useSendIdentityForm } from "@/hooks/identity.hook"; +import callAPI from "@/core/caller"; export default function InnerIdentityForm( 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 { mutateAsync, isPending } = useSendIdentityForm(); + const [profileUploading, setProfileUploading] = useState(false); + const [profileUploadProgress, setProfileUploadProgress] = useState(0); + const [profileUploadError, setProfileUploadError] = useState(""); + const [profilePreview, setProfilePreview] = useState(""); + const [uploadedFileId, setUploadedFileId] = useState(""); - const handleProfilePhotoChange = ( + const [selectedProfileFile, setSelectedProfileFile] = useState( + null, + ); + + // برای کنسل کردن آپلود + const profileAbortControllerRef = useRef(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, ) => { const file = event.target.files?.[0]; - if (!file) return; - if (!file.type.startsWith("image/")) { - setProfilePhoto(null); - setProfilePhotoError("فقط فایل تصویری مجاز است"); - 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(); } - const maxSize = 500 * 1024; // 500KB - if (file.size > maxSize) { - setProfilePhoto(null); - setProfilePhotoError("حجم عکس باید حداکثر ۵۰۰ کیلوبایت باشد"); - return; + 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); + } } - setProfilePhoto(file); - setProfilePhotoError(""); + 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 ( + + توجه + پس از تكميل اين گام ، كدملي شما براي ادامه مراحل ذخيره خواهد شد + - props.setFieldValue("lastName", e.target.value)} - error={!!props.errors.lastName} - helperText={props.errors.lastName} - 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("fatherName", e.target.value) + } + fullWidth + /> + +
- - props.setFieldValue("nationalCode", e.target.value) - } - error={!!props.errors.nationalCode} - helperText={props.errors.nationalCode} - fullWidth - required - /> +
+ + props.setFieldValue("nationalCode", e.target.value) + } + error={!!props.errors.nationalCode} + helperText={props.errors.nationalCode} + fullWidth + required + /> + +
- {/* - props.setFieldValue("birthDate", newValue) + value={ + props.values.birthDate ? new Date(props.values.birthDate) : null } + onChange={(newValue) => + props.setFieldValue("birthDate", newValue) + } + maxDate={new Date()} slotProps={{ textField: { fullWidth: true, @@ -124,64 +258,82 @@ export default function InnerIdentityForm( 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("birthPlace", e.target.value) + } + fullWidth + required + error={!!props.errors.birthPlace} + helperText={props.errors.birthPlace} + /> + +
- 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 - /> +
+ 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 + required + error={!!props.errors.religion} + helperText={props.errors.religion} + > + {religionOptions.map((item) => ( + + {item} + + ))} + + +
+
+ + props.setFieldValue("nationality", e.target.value) + } + error={!!props.errors.nationality} + helperText={props.errors.nationality} + fullWidth + required + /> + +
- - فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت باشد. - + {/* بخش پیش‌نمایش عکس */} + + + {!profilePreview && ( + + )} + - + {/* دکمه حذف عکس (فقط اگر عکسی وجود داشت) */} + {profilePreview && !profileUploading && ( + + + + )} + - {profilePhoto && ( + + + فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت + باشد. + + + + + + {profileUploading && ( + + )} + + + + + {/* نمایش نام فایل در حال آپلود */} + {selectedProfileFile && profileUploading && ( - فایل انتخاب‌شده: {profilePhoto.name} + فایل انتخاب‌شده: {selectedProfileFile.name} )} - {profilePhotoError && ( + {/* نوار پیشرفت */} + {profileUploading && ( + + + پیشرفت آپلود: {profileUploadProgress}% + + + + + + + )} + + {/* پیام موفقیت */} + {!profileUploading && + props.values.profilePhotoId && + !profileUploadError && ( + + عکس با موفقیت بارگذاری شد. + + )} + + {/* پیام خطا */} + {profileUploadError && ( - {profilePhotoError} + {profileUploadError} )} + + + + + +
+ + ); +} diff --git a/ui/forms/jobInfo/JobInfoForm.tsx b/ui/forms/jobInfo/JobInfoForm.tsx new file mode 100644 index 0000000..cdf2adc --- /dev/null +++ b/ui/forms/jobInfo/JobInfoForm.tsx @@ -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({ + displayName: "JobInfoForm", + + enableReinitialize: true, + + mapPropsToValues: (props) => { + return props.data?.jobInfo || INITIAL_JOB_INFO_VALUES; + }, + + validationSchema: JobInfoValidationSchema, + + handleSubmit: async ( + values, + bag: FormikBag + ) => { + const { props, setSubmitting } = bag; + + props.update({ jobInfo: values }); + props.setStep((prev) => prev + 1); + setSubmitting(false); + }, +})(InnerJobInfoForm); + +export default JobInfoForm; diff --git a/ui/forms/jobInfo/constant/index.ts b/ui/forms/jobInfo/constant/index.ts new file mode 100644 index 0000000..37e274a --- /dev/null +++ b/ui/forms/jobInfo/constant/index.ts @@ -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: "بازخرید" }, +]; diff --git a/ui/forms/jobInfo/types/index.ts b/ui/forms/jobInfo/types/index.ts new file mode 100644 index 0000000..aca644a --- /dev/null +++ b/ui/forms/jobInfo/types/index.ts @@ -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>; + data: WizardFormData; + update: (patch: Partial) => void; +} diff --git a/ui/forms/jobInfo/validation/index.ts b/ui/forms/jobInfo/validation/index.ts new file mode 100644 index 0000000..92ea1b9 --- /dev/null +++ b/ui/forms/jobInfo/validation/index.ts @@ -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(), + }), +}); diff --git a/ui/forms/jobRequest/InnerJobRequestForm.tsx b/ui/forms/jobRequest/InnerJobRequestForm.tsx new file mode 100644 index 0000000..9906087 --- /dev/null +++ b/ui/forms/jobRequest/InnerJobRequestForm.tsx @@ -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 & 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 ( +
+ + {({ 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 ( + + {values.jobRequests.length > 1 && ( + remove(index)} + color="error" + sx={{ + position: "absolute", + top: 12, + right: 12, + zIndex: 1, + }} + aria-label="remove-job-request" + > + + + )} + + + درخواست شغلی {index + 1} + + + + { + const categoryId = e.target.value; + setFieldValue( + `jobRequests.${index}.jobCategoryId`, + categoryId, + ); + setFieldValue(`jobRequests.${index}.jobId`, ""); + }} + error={ + !!itemTouched.jobCategoryId && + !!itemErrors.jobCategoryId + } + helperText={ + itemTouched.jobCategoryId + ? itemErrors.jobCategoryId + : " " + } + > + انتخاب... + {jobCategories.map((category) => ( + + {category.name} + + ))} + + + + انتخاب... + {filteredJobs.map((job) => ( + + {job.title} + + ))} + + + + + + انتخاب... + {relationTypes.map((relation) => ( + + {relation} + + ))} + + + + انتخاب... + {shiftTypes.map((shift) => ( + + {shift} + + ))} + + + { + const onlyDigits = e.target.value.replace(/[^\d]/g, ""); + setFieldValue( + `jobRequests.${index}.expectedSalary`, + onlyDigits, + ); + }} + error={ + !!itemTouched.expectedSalary && + !!itemErrors.expectedSalary + } + helperText={ + itemTouched.expectedSalary + ? itemErrors.expectedSalary + : "" + } + /> + + + + + + + ); + })} + + + + + + + + + + )} + +
+ ); +} diff --git a/ui/forms/jobRequest/JobRequestForm.tsx b/ui/forms/jobRequest/JobRequestForm.tsx new file mode 100644 index 0000000..4a7b407 --- /dev/null +++ b/ui/forms/jobRequest/JobRequestForm.tsx @@ -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({ + 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, + ) => { + const { props, setSubmitting } = bag; + + props.update({ + jobRequests: values.jobRequests, + }); + + props.setStep((prev) => prev + 1); + + setSubmitting(false); + }, +})(InnerJobRequestForm); + +export default JobRequestForm; diff --git a/ui/forms/jobRequest/constant/index.ts b/ui/forms/jobRequest/constant/index.ts new file mode 100644 index 0000000..e1b4311 --- /dev/null +++ b/ui/forms/jobRequest/constant/index.ts @@ -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], +}; diff --git a/ui/forms/jobRequest/types/index.ts b/ui/forms/jobRequest/types/index.ts new file mode 100644 index 0000000..bff250e --- /dev/null +++ b/ui/forms/jobRequest/types/index.ts @@ -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>; + data: WizardFormData; + update: (patch: Partial) => void; + jobCategories?: JobCategoryOption[]; + jobs?: JobOption[]; +} diff --git a/ui/forms/jobRequest/validation/index.ts b/ui/forms/jobRequest/validation/index.ts new file mode 100644 index 0000000..51e6416 --- /dev/null +++ b/ui/forms/jobRequest/validation/index.ts @@ -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("ثبت درخواست شغلی الزامی است"), +}); diff --git a/ui/forms/personal/InnerPersonalInfoForm.tsx b/ui/forms/personal/InnerPersonalInfoForm.tsx new file mode 100644 index 0000000..d0304a0 --- /dev/null +++ b/ui/forms/personal/InnerPersonalInfoForm.tsx @@ -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 & 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, + ) => { + 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, + ) => { + const ms = e.target.value as MilitaryStatus; + setFieldValue("militaryStatus", ms); + + if (ms !== "معافیت دائم") { + setFieldValue("permanentExemptionReason", ""); + } + }; + + const tf = (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 ( +
+ + {/* وضعیت تاهل */} + + انتخاب کنید + مجرد + متاهل + متارکه + فوت همسر + + {/* همسر (شرطی) */} + {showSpouseFields && ( + <> + + + + + + )} + {/* تعداد فرزند (شرطی) */} + {showChildrenCount && ( + + setFieldValue( + "childrenCount", + e.target.value === "" ? "" : Number(e.target.value), + ) + } + fullWidth + error={!!touched.childrenCount && !!errors.childrenCount} + helperText={ + touched.childrenCount ? (errors.childrenCount as string) : "" + } + /> + )} + {/* وضعیت نظام وظیفه */} + + انتخاب کنید + {MILITARY_OPTIONS.map((opt) => ( + + {opt} + + ))} + + {/* علت معافیت دائم */} + {isPermanentExempt && ( + + )} + {/* تحصیلات پدر/مادر */} + + انتخاب کنید + {EDUCATION_OPTIONS.map((opt) => ( + + {opt} + + ))} + + + + انتخاب کنید + {EDUCATION_OPTIONS.map((opt) => ( + + {opt} + + ))} + + + {/* وضعیت مسکن / شهر / آدرس */} + + انتخاب کنید + {HOUSING_OPTIONS.map((opt) => ( + + {opt} + + ))} + {" "} + + + + + {/* تلفن‌ها */} + + + + + {/* مدت سکونت */} + + setFieldValue( + "residenceDuration", + e.target.value === "" ? "" : Number(e.target.value), + ) + } + fullWidth + error={!!touched.residenceDuration && !!errors.residenceDuration} + helperText={ + touched.residenceDuration + ? (errors.residenceDuration as string) + : "" + } + /> + {/* ایثارگر */} + + setFieldValue("isVeteran", e.target.value === "true") + } + fullWidth + error={!!touched.isVeteran && !!errors.isVeteran} + helperText={touched.isVeteran ? (errors.isVeteran as string) : ""} + > + خیر + بله + + {/* سوءپیشینه */} + { + 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) + : "" + } + > + خیر + بله + + {values.hasCriminalRecord && ( + + )} + + + + + + + +
+ ); +} diff --git a/ui/forms/personal/PersonalInfoForm.tsx b/ui/forms/personal/PersonalInfoForm.tsx new file mode 100644 index 0000000..545b8f5 --- /dev/null +++ b/ui/forms/personal/PersonalInfoForm.tsx @@ -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>; + data: WizardFormData; + update: (patch: Partial) => void; +}; + +const PersonalInfoForm = withFormik({ + displayName: "PersonalInfoForm", + + enableReinitialize: true, + + mapPropsToValues: (props) => { + return props.data?.personalInfo ?? PERSONAL_INFO_EMPTY_VALUES; + }, + + // validationSchema: PersonalInfoValidationSchema, + + handleSubmit: async (values, bag: FormikBag) => { + const { props, setSubmitting } = bag; + + props.update({ personalInfo: values }); + + // برو مرحله بعد + props.setStep((prev) => prev + 1); + + setSubmitting(false); + }, +})(InnerPersonalInfoForm); + +export default PersonalInfoForm; diff --git a/ui/forms/personal/constants/index.ts b/ui/forms/personal/constants/index.ts new file mode 100644 index 0000000..fc2457a --- /dev/null +++ b/ui/forms/personal/constants/index.ts @@ -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[] = [ + "کارت پایان خدمت", + "در حال خدمت", + "معافیت تحصیلی", + "معافیت دائم", + "انجام نشده", +]; + +export const EDUCATION_OPTIONS: Exclude[] = [ + "زیر دیپلم", + "دیپلم", + "دانشجو", + "کاردانی", + "کارشناسی", + "کارشناسی ارشد", + "دکترا", +]; +export const HOUSING_OPTIONS: Exclude[] = [ + "منزل شخصی", + "منزل والدین", + "منزل استیجاری", + "سایر", +]; diff --git a/ui/forms/personal/types/index.ts b/ui/forms/personal/types/index.ts new file mode 100644 index 0000000..ec84197 --- /dev/null +++ b/ui/forms/personal/types/index.ts @@ -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 | ""; +} diff --git a/ui/forms/personal/validation/PersonalInfoFormValidation.tsx b/ui/forms/personal/validation/PersonalInfoFormValidation.tsx new file mode 100644 index 0000000..26eba8c --- /dev/null +++ b/ui/forms/personal/validation/PersonalInfoFormValidation.tsx @@ -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() + .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() + .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() + .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().when("maritalStatus", { + is: (ms: string) => ["متاهل", "متارکه", "فوت همسر"].includes(ms), + then: (s) => + s.test( + "childrenCount", + "تعداد فرزند نامعتبر است", + (v) => v === "" || (typeof v === "number" && v >= 0), + ), + otherwise: (s) => s.notRequired(), + }), + }) + .required(); diff --git a/ui/forms/physicalInfo/InnerPhysicalInfoForm.tsx b/ui/forms/physicalInfo/InnerPhysicalInfoForm.tsx new file mode 100644 index 0000000..1ad2b65 --- /dev/null +++ b/ui/forms/physicalInfo/InnerPhysicalInfoForm.tsx @@ -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 & 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 = (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 ( +
+ + + + {/* bloodType */} + + انتخاب کنید + {BLOOD_TYPE_OPTIONS.map((bt) => ( + + {bt} + + ))} + + + {/* height */} + + setFieldValue( + "height", + e.target.value === "" ? "" : Number(e.target.value), + ) + } + fullWidth + error={!!touched.height && !!errors.height} + helperText={touched.height ? (errors.height as string) : ""} + /> + + {/* weight */} + + setFieldValue( + "weight", + e.target.value === "" ? "" : Number(e.target.value), + ) + } + fullWidth + error={!!touched.weight && !!errors.weight} + helperText={touched.weight ? (errors.weight as string) : ""} + /> + + {/* bmi */} + + + {/* specialMark */} + + + {/* hasDisability */} + { + 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) : "" + } + > + خیر + بله + + + {/* hasChronicDisease */} + { + 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) + : "" + } + > + خیر + بله + + + {/* disabilityDescription */} + {values.hasDisability && ( + + + + )} + + {/* chronicDiseaseDescription */} + {values.hasChronicDisease && ( + + + + )} + + {/* surgeryHistory */} + + + + + {/* medications */} + + + + + + + + +
+ ); +} diff --git a/ui/forms/physicalInfo/PhysicalInfoForm.tsx b/ui/forms/physicalInfo/PhysicalInfoForm.tsx new file mode 100644 index 0000000..a2cbde1 --- /dev/null +++ b/ui/forms/physicalInfo/PhysicalInfoForm.tsx @@ -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>; + data: WizardFormData; + update: (patch: Partial) => 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, + ) => { + const { props, setSubmitting } = bag; + + props.update({ physicalInfo: values }); + props.setStep((prev) => prev + 1); + + setSubmitting(false); + }, +})(InnerPhysicalInfoForm); + +export default PhysicalInfoForm; diff --git a/ui/forms/physicalInfo/constants/index.ts b/ui/forms/physicalInfo/constants/index.ts new file mode 100644 index 0000000..a62fb0a --- /dev/null +++ b/ui/forms/physicalInfo/constants/index.ts @@ -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[] = [ + "A+", + "A-", + "B+", + "B-", + "AB+", + "AB-", + "O+", + "O-", +]; diff --git a/ui/forms/physicalInfo/types/index.ts b/ui/forms/physicalInfo/types/index.ts new file mode 100644 index 0000000..207e258 --- /dev/null +++ b/ui/forms/physicalInfo/types/index.ts @@ -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; +} diff --git a/ui/forms/physicalInfo/validation/index.ts b/ui/forms/physicalInfo/validation/index.ts new file mode 100644 index 0000000..11cc66a --- /dev/null +++ b/ui/forms/physicalInfo/validation/index.ts @@ -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() + .oneOf(["", "A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"]) + .required("گروه خونی را انتخاب کنید"), + + height: yup + .mixed() + .test( + "height-valid", + "قد نامعتبر است", + (v) => v === "" || (typeof v === "number" && v > 0 && v <= 300), + ) + .required("قد الزامی است"), + + weight: yup + .mixed() + .test( + "weight-valid", + "وزن نامعتبر است", + (v) => v === "" || (typeof v === "number" && v > 0 && v <= 500), + ) + .required("وزن الزامی است"), + + bmi: yup + .mixed() + .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(); diff --git a/ui/forms/referral/InnerReferralForm.tsx b/ui/forms/referral/InnerReferralForm.tsx new file mode 100644 index 0000000..a74a855 --- /dev/null +++ b/ui/forms/referral/InnerReferralForm.tsx @@ -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 & ReferralFormProps; + +function ReferralItemForm({ + index, + item, + errors, + touched, + handleChange, + setFieldValue, + onRemove, + disableRemove, +}: { + index: number; + item: ReferralItem; + errors: any; + touched: any; + handleChange: React.ChangeEventHandler; + 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 ( + + + معرف {index + 1} + + + + + + + + + + + + + + + + + مستقیم + غیرمستقیم + + + { + 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 : ""} + /> + + + + + + + ); +} + +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 ( +
+ + {({ push, remove }) => ( + + + + معرف‌ها + + + + + + + + + {values.referrals.map((item, idx) => ( + remove(idx)} + disableRemove={values.referrals.length <= REFERRAL_MIN_ITEMS} + /> + ))} + + + {typeof errors.referrals === "string" && ( + + {errors.referrals} + + )} + + + + + + + + )} + +
+ ); +} diff --git a/ui/forms/referral/ReferralForm.tsx b/ui/forms/referral/ReferralForm.tsx new file mode 100644 index 0000000..5acecba --- /dev/null +++ b/ui/forms/referral/ReferralForm.tsx @@ -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({ + 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, + ) => { + const { props, setSubmitting } = bag; + + props.update({ + referrals: values.referrals, + }); + + props.setStep((prev) => prev + 1); + setSubmitting(false); + }, +})(InnerReferralForm); + +export default ReferralForm; diff --git a/ui/forms/referral/constant/index.ts b/ui/forms/referral/constant/index.ts new file mode 100644 index 0000000..2449eee --- /dev/null +++ b/ui/forms/referral/constant/index.ts @@ -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; diff --git a/ui/forms/referral/types/index.ts b/ui/forms/referral/types/index.ts new file mode 100644 index 0000000..dceb0c8 --- /dev/null +++ b/ui/forms/referral/types/index.ts @@ -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>; + data: WizardFormData; + update: (patch: Partial) => void; +} diff --git a/ui/forms/referral/validation/index.ts b/ui/forms/referral/validation/index.ts new file mode 100644 index 0000000..004e5ed --- /dev/null +++ b/ui/forms/referral/validation/index.ts @@ -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("ثبت معرف الزامی است"), +}); diff --git a/ui/forms/register-center/InnerRegistrationCenterForm.tsx b/ui/forms/register-center/InnerRegistrationCenterForm.tsx index 5aa5fb4..d7656ab 100644 --- a/ui/forms/register-center/InnerRegistrationCenterForm.tsx +++ b/ui/forms/register-center/InnerRegistrationCenterForm.tsx @@ -9,16 +9,19 @@ import { FormHelperText, } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; -import { Form, FormikProps } from "formik"; +import { ErrorMessage, Form, FormikProps } from "formik"; import BusinessIcon from "@mui/icons-material/Business"; import LocationOnIcon from "@mui/icons-material/LocationOn"; import LocalHospitalIcon from "@mui/icons-material/LocalHospital"; -import { useGetAllCenters } from "@/hooks/center.hook"; +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 @@ -27,33 +30,26 @@ interface InnerFormProps RegistrationCenterFormProps {} export default function InnerRegistrationCenterForm(props: InnerFormProps) { - const { data } = useGetAllCenters(); + 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.update({ registrationCenter: props.values }); 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(); - + props.update({ + registrationCenter: props.values, + }); // اگر در گام فعلی خطایی وجود ندارد، برو مرحله بعد if (Object.keys(errors).length === 0) { if (props.step === 12) { @@ -62,8 +58,32 @@ export default function InnerRegistrationCenterForm(props: InnerFormProps) { 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 = () => (
@@ -78,7 +98,7 @@ export default function InnerRegistrationCenterForm(props: InnerFormProps) { borderRadius: "18px", border: isSelected ? "2px solid #2563eb" - : isSelectedCenterError + : props.errors.selectedCenter ? "2px solid #d32f2f" : "1px solid #e2e8f0", backgroundColor: isSelected ? "#eff6ff" : "#fff", @@ -106,6 +126,27 @@ export default function InnerRegistrationCenterForm(props: InnerFormProps) { {center.address} + {center.isUrgent && ( + + {/* */} + + استخدام فوري + + + )} {isSelected && } @@ -115,14 +156,7 @@ export default function InnerRegistrationCenterForm(props: InnerFormProps) {
{/* نمایش پیام خطا به صورت تمیز زیر لیست */} - {isSelectedCenterError && ( - - {props.errors.selectedCenter as string} - - )} +
); diff --git a/ui/forms/register-center/RegistrationCenterForm.tsx b/ui/forms/register-center/RegistrationCenterForm.tsx index 4e63c89..631c5f9 100644 --- a/ui/forms/register-center/RegistrationCenterForm.tsx +++ b/ui/forms/register-center/RegistrationCenterForm.tsx @@ -8,6 +8,7 @@ export interface RegistrationCenterFormValues { } export interface WizardFormData { + applicantId: string; registrationCenter: { selectedCenter: CenterItem | null; }; diff --git a/ui/forms/relation/InnerRelationForm.tsx b/ui/forms/relation/InnerRelationForm.tsx new file mode 100644 index 0000000..6f16a89 --- /dev/null +++ b/ui/forms/relation/InnerRelationForm.tsx @@ -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 & 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 ( +
+ + + توجه + مشخصات دو نفر از آشنایان را وارد کنید و از درج بستگان درجه یک (پدر، + مادر، همسر، برادر و خواهر) خودداری نمایید. + + + + {() => ( + + {values.relations.map((item, index) => { + const itemErrors = getIn(errors, `relations.${index}`) || {}; + const itemTouched = getIn(touched, `relations.${index}`) || {}; + + return ( + + + آشنای {index === 0 ? "اول" : "دوم"} + + + + + + + + + + { + 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 : "" + } + /> + + + + + + + {index === 0 && } + + ); + })} + + )} + + + {/* دکمه‌های ناوبری */} + + + + + + +
+ ); +} diff --git a/ui/forms/relation/RelationForm.tsx b/ui/forms/relation/RelationForm.tsx new file mode 100644 index 0000000..f34edbb --- /dev/null +++ b/ui/forms/relation/RelationForm.tsx @@ -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({ + 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 + ) => { + const { props, setSubmitting } = bag; + + props.update({ relations: values.relations }); + props.setStep((prev) => prev + 1); + setSubmitting(false); + }, +})(InnerRelationForm); + +export default RelationForm; diff --git a/ui/forms/relation/constant/index.ts b/ui/forms/relation/constant/index.ts new file mode 100644 index 0000000..7f9c3d8 --- /dev/null +++ b/ui/forms/relation/constant/index.ts @@ -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 }, + ], +}; diff --git a/ui/forms/relation/types/index.ts b/ui/forms/relation/types/index.ts new file mode 100644 index 0000000..a80b856 --- /dev/null +++ b/ui/forms/relation/types/index.ts @@ -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>; + data: WizardFormData; + update: (patch: Partial) => void; +} diff --git a/ui/forms/relation/validation/index.ts b/ui/forms/relation/validation/index.ts new file mode 100644 index 0000000..1f83ed4 --- /dev/null +++ b/ui/forms/relation/validation/index.ts @@ -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, "باید مشخصات دو نفر را وارد کنید"), +}); diff --git a/ui/forms/skillsForm/InnerSkillsForm.tsx b/ui/forms/skillsForm/InnerSkillsForm.tsx new file mode 100644 index 0000000..75c986e --- /dev/null +++ b/ui/forms/skillsForm/InnerSkillsForm.tsx @@ -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 & 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 ( +
+ + + {/* 1. Computer Skills Section */} + + + مهارت‌های کامپیوتری + + + + {compFields.map((field) => { + const hasError = !!errors.computerSkill?.[field] && !!touched.computerSkill?.[field]; + return ( + + {proficiencyOptions.map((o) => ( + + {o.label} + + ))} + + ); + })} + + + + + + {/* 2. Language Skills Section */} + + + آشنایی با زبان‌های خارجه + + + + + {/* English */} + + {proficiencyOptions.map((o) => ( + + {o.label} + + ))} + + + + { + const checked = e.target.checked; + setFieldValue("languageSkill.hasEnglishCertificate", checked); + if (!checked) { + // پاکسازی فیلد نوع مدرک در صورت غیرفعال شدن سوئیچ + setFieldValue("languageSkill.englishCertificateType", ""); + } + }} + /> + } + label="مدرک معتبر زبان انگلیسی دارد" + /> + + + {values.languageSkill.hasEnglishCertificate && ( + + انتخاب... + {certTypes.map((t) => ( + + {t} + + ))} + + )} + + + + + + {/* Arabic */} + + {proficiencyOptions.map((o) => ( + + {o.label} + + ))} + + + + + + + + + {/* Other Skills */} + + + + + + + + + {/* Wizard Navigation Buttons */} + + + + + + +
+ ); +} diff --git a/ui/forms/skillsForm/SkillsForm.tsx b/ui/forms/skillsForm/SkillsForm.tsx new file mode 100644 index 0000000..c118474 --- /dev/null +++ b/ui/forms/skillsForm/SkillsForm.tsx @@ -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({ + 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 + ) => { + const { props, setSubmitting } = bag; + + // ثبت مقادیر در استیت اصلی Wizard کامپوننت مادر + props.update({ + computerSkill: values.computerSkill, + languageSkill: values.languageSkill, + }); + + // رفتن به گام بعدی + props.setStep((prev) => prev + 1); + setSubmitting(false); + }, +})(InnerSkillsForm); + +export default SkillsForm; diff --git a/ui/forms/skillsForm/constant/index.ts b/ui/forms/skillsForm/constant/index.ts new file mode 100644 index 0000000..a8b9620 --- /dev/null +++ b/ui/forms/skillsForm/constant/index.ts @@ -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: "", + }, +}; diff --git a/ui/forms/skillsForm/types/index.ts b/ui/forms/skillsForm/types/index.ts new file mode 100644 index 0000000..f289bd1 --- /dev/null +++ b/ui/forms/skillsForm/types/index.ts @@ -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>; + data: WizardFormData; + update: (patch: Partial) => void; +} diff --git a/ui/forms/skillsForm/validation/index.ts b/ui/forms/skillsForm/validation/index.ts new file mode 100644 index 0000000..0791627 --- /dev/null +++ b/ui/forms/skillsForm/validation/index.ts @@ -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(), + }), +}); diff --git a/ui/forms/workExperience/InnerWorkExperienceForm.tsx b/ui/forms/workExperience/InnerWorkExperienceForm.tsx new file mode 100644 index 0000000..a967eec --- /dev/null +++ b/ui/forms/workExperience/InnerWorkExperienceForm.tsx @@ -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 & 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 ( +
+ + {({ 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 ( + + + + سابقه کاری {index + 1} + + + remove(index)} + disabled={workExperiences.length === 1 || hasNoExperienceMode} + size="small" + color="error" + > + + + + + + setHasNoExperience(e.target.checked)} + /> + } + label="فاقد سابقه کاری هستم" + /> + + + + + + { + 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 : ""} + /> + + { + 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 : ""} + /> + + + + + + + ); + })} + + {!hasNoExperienceMode && ( + + )} + + {typeof errors.workExperiences === "string" && ( + + {errors.workExperiences} + + )} + + + + + + + + )} + +
+ ); +} diff --git a/ui/forms/workExperience/WorkExperienceForm.tsx b/ui/forms/workExperience/WorkExperienceForm.tsx new file mode 100644 index 0000000..dba6723 --- /dev/null +++ b/ui/forms/workExperience/WorkExperienceForm.tsx @@ -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, + ) => { + const { props, setSubmitting } = bag; + + props.update({ + workExperiences: values.workExperiences, + }); + + props.setStep((prev) => prev + 1); + setSubmitting(false); + }, +})(InnerWorkExperienceForm); + +export default WorkExperienceForm; diff --git a/ui/forms/workExperience/constant/index.ts b/ui/forms/workExperience/constant/index.ts new file mode 100644 index 0000000..b956152 --- /dev/null +++ b/ui/forms/workExperience/constant/index.ts @@ -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 }], +}; diff --git a/ui/forms/workExperience/types/index.ts b/ui/forms/workExperience/types/index.ts new file mode 100644 index 0000000..0e0e3d3 --- /dev/null +++ b/ui/forms/workExperience/types/index.ts @@ -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>; + data: WizardFormData; + update: (patch: Partial) => void; +} diff --git a/ui/forms/workExperience/validation/index.ts b/ui/forms/workExperience/validation/index.ts new file mode 100644 index 0000000..9501419 --- /dev/null +++ b/ui/forms/workExperience/validation/index.ts @@ -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("ثبت سابقه کاری الزامی است"), +});