first commit
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
9
app/(form)/form/page.tsx
Normal file
9
app/(form)/form/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import MultiStepForm from "@/ui/MultiForm";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<MultiStepForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
app/globals.css
Normal file
26
app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* :root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* @theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* @media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-vazir);
|
||||||
|
}
|
||||||
40
app/layout.tsx
Normal file
40
app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
import "./globals.css";
|
||||||
|
import { FontVazir } from "@/config/font.config";
|
||||||
|
import ThemeRegistry from "@/ui/providers/ThemeRegitstry";
|
||||||
|
import { CacheProvider } from "@emotion/react";
|
||||||
|
import rtlCache from "@/core/theme/rtlCache";
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
|
import { AdapterDateFnsJalali } from "@mui/x-date-pickers/AdapterDateFnsJalali";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
const [queryClient] = useState(() => new QueryClient());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="fa"
|
||||||
|
dir="rtl"
|
||||||
|
className={`${FontVazir.variable} ${FontVazir.className} h-full antialiased`}
|
||||||
|
style={{ fontFamily: FontVazir.style.fontFamily }}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<CacheProvider value={rtlCache}>
|
||||||
|
<ThemeRegistry>
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFnsJalali}>
|
||||||
|
{children}
|
||||||
|
<Toaster position="bottom-right" richColors />
|
||||||
|
</LocalizationProvider>
|
||||||
|
</ThemeRegistry>
|
||||||
|
</CacheProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/page.tsx
Normal file
18
app/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
import LoginForm from "@/ui/forms/LoginForm";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
paddingTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
config/font.config.ts
Normal file
48
config/font.config.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
|
export const FontVazir = localFont({
|
||||||
|
src: [
|
||||||
|
{
|
||||||
|
path: "../fonts/vazir/Vazirmatn-Thin.woff2",
|
||||||
|
weight: "100",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "../fonts/vazir/Vazirmatn-ExtraLight.woff2",
|
||||||
|
weight: "200",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "../fonts/vazir/Vazirmatn-Light.woff2",
|
||||||
|
weight: "300",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "../fonts/vazir/Vazirmatn-Regular.woff2",
|
||||||
|
weight: "400",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "../fonts/vazir/Vazirmatn-Medium.woff2",
|
||||||
|
weight: "500",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "../fonts/vazir/Vazirmatn-SemiBold.woff2",
|
||||||
|
weight: "600",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "../fonts/vazir/Vazirmatn-Bold.woff2",
|
||||||
|
weight: "700",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "../fonts/vazir/Vazirmatn-ExtraBold.woff2",
|
||||||
|
weight: "800",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variable: "--font-vazir", // اگر خواستی متغیر CSS بسازی برای Tailwind یا CSS مدول
|
||||||
|
display: "swap", // بهترین گزینه برای نمایش فونت
|
||||||
|
});
|
||||||
25
core/caller/index.ts
Normal file
25
core/caller/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const callAPISetting = axios.create({
|
||||||
|
baseURL: "http://localhost:8000/api/v1",
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
callAPISetting.interceptors.request.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => Promise.reject(err),
|
||||||
|
);
|
||||||
|
|
||||||
|
callAPISetting.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
async (err) => Promise.reject(err),
|
||||||
|
);
|
||||||
|
|
||||||
|
const callAPI = {
|
||||||
|
post: callAPISetting.post,
|
||||||
|
get: callAPISetting.get,
|
||||||
|
put: callAPISetting.put,
|
||||||
|
delete: callAPISetting.delete,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default callAPI;
|
||||||
32
core/theme/index.ts
Normal file
32
core/theme/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
direction: "rtl",
|
||||||
|
typography: {
|
||||||
|
fontFamily: "var(--font-vazir)",
|
||||||
|
},
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: "#2563eb", // یک آبی مدرن و زنده
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: "#f8fafc", // رنگ بدنه تر و تازه (بسیار روشن)
|
||||||
|
paper: "#ffffff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 16, // گوشههای گرد برای ظاهر مدرنتر
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: "none", // فونت غیر کپسلاک
|
||||||
|
padding: "10px 24px",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export default theme;
|
||||||
10
core/theme/rtlCache.ts
Normal file
10
core/theme/rtlCache.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import createCache from "@emotion/cache";
|
||||||
|
import { prefixer } from "stylis";
|
||||||
|
import rtlPlugin from "stylis-plugin-rtl";
|
||||||
|
|
||||||
|
const rtlCache = createCache({
|
||||||
|
key: "muirtl",
|
||||||
|
stylisPlugins: [prefixer, rtlPlugin],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default rtlCache;
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
BIN
fonts/sogand/SOGAND.ttf
Normal file
BIN
fonts/sogand/SOGAND.ttf
Normal file
Binary file not shown.
BIN
fonts/vazir/Vazirmatn-Black.woff2
Normal file
BIN
fonts/vazir/Vazirmatn-Black.woff2
Normal file
Binary file not shown.
BIN
fonts/vazir/Vazirmatn-Bold.woff2
Normal file
BIN
fonts/vazir/Vazirmatn-Bold.woff2
Normal file
Binary file not shown.
BIN
fonts/vazir/Vazirmatn-ExtraBold.woff2
Normal file
BIN
fonts/vazir/Vazirmatn-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
fonts/vazir/Vazirmatn-ExtraLight.woff2
Normal file
BIN
fonts/vazir/Vazirmatn-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
fonts/vazir/Vazirmatn-Light.woff2
Normal file
BIN
fonts/vazir/Vazirmatn-Light.woff2
Normal file
Binary file not shown.
BIN
fonts/vazir/Vazirmatn-Medium.woff2
Normal file
BIN
fonts/vazir/Vazirmatn-Medium.woff2
Normal file
Binary file not shown.
BIN
fonts/vazir/Vazirmatn-Regular.woff2
Normal file
BIN
fonts/vazir/Vazirmatn-Regular.woff2
Normal file
Binary file not shown.
BIN
fonts/vazir/Vazirmatn-SemiBold.woff2
Normal file
BIN
fonts/vazir/Vazirmatn-SemiBold.woff2
Normal file
Binary file not shown.
BIN
fonts/vazir/Vazirmatn-Thin.woff2
Normal file
BIN
fonts/vazir/Vazirmatn-Thin.woff2
Normal file
Binary file not shown.
5
hooks/auth.hook.ts
Normal file
5
hooks/auth.hook.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { applicantLogin } from "@/services/apis/auth.api";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const useApplicantLogin = () =>
|
||||||
|
useMutation({ mutationFn: applicantLogin });
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7509
package-lock.json
generated
Normal file
7509
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^9.0.1",
|
||||||
|
"@mui/material": "^9.0.1",
|
||||||
|
"@mui/x-date-pickers": "^9.3.0",
|
||||||
|
"@tanstack/react-query": "^5.100.14",
|
||||||
|
"axios": "^1.16.1",
|
||||||
|
"date-fns-jalali": "^4.0.0-0",
|
||||||
|
"next": "16.2.6",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"stylis-plugin-rtl": "^2.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@types/stylis": "^4.2.7",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
7
services/apis/auth.api.ts
Normal file
7
services/apis/auth.api.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import callAPI from "@/core/caller";
|
||||||
|
|
||||||
|
export async function applicantLogin(nationalCode: string) {
|
||||||
|
return await callAPI
|
||||||
|
.post(`/auth/applicant/login`, nationalCode)
|
||||||
|
.then((res) => res.data);
|
||||||
|
}
|
||||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
249
ui/MultiForm.tsx
Normal file
249
ui/MultiForm.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Container,
|
||||||
|
useTheme,
|
||||||
|
useMediaQuery,
|
||||||
|
} from "@mui/material";
|
||||||
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
|
import CenterRegistrationForm from "./forms/register-center/RegistrationCenterForm";
|
||||||
|
import IdentityForm from "./forms/IdentityForm";
|
||||||
|
import PersonalInfoForm from "./forms/PersonalInfoForm";
|
||||||
|
import PhysicalInfoForm from "./forms/PhysicalInfoForm";
|
||||||
|
import EducationForm from "./forms/EducationForm";
|
||||||
|
import EducationSection from "./forms/EducationSection";
|
||||||
|
import JobRequestForm from "./forms/JobRequestForm";
|
||||||
|
import JobRequestSection from "./forms/JobRequestSection";
|
||||||
|
import CourseSection from "./forms/CourseSection";
|
||||||
|
import SkillsForm from "./forms/SkillsForm";
|
||||||
|
import { WorkExperienceSection } from "./forms/WorkExperienceSection";
|
||||||
|
import JobInfoForm from "./forms/JobInfoForm";
|
||||||
|
import { ReferralSection } from "./forms/ReferralForm";
|
||||||
|
import RelationsForm from "./forms/RelationForm";
|
||||||
|
|
||||||
|
// کامپوننت پیشفرض برای مراحلی که هنوز نساختید
|
||||||
|
const PlaceholderStep = ({ step }: any) => (
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
محتوای مرحله {step} در حال طراحی است...
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- ۲. نگاشت (Mapping) مراحل به کامپوننتها ---
|
||||||
|
|
||||||
|
const STEP_COMPONENTS: Record<number, React.FC<any>> = {
|
||||||
|
1: CenterRegistrationForm,
|
||||||
|
2: IdentityForm,
|
||||||
|
3: PersonalInfoForm,
|
||||||
|
4: PhysicalInfoForm,
|
||||||
|
5: EducationSection,
|
||||||
|
6: JobRequestSection,
|
||||||
|
7: CourseSection,
|
||||||
|
8: SkillsForm,
|
||||||
|
9: WorkExperienceSection,
|
||||||
|
10: JobInfoForm,
|
||||||
|
11: ReferralSection,
|
||||||
|
12: RelationsForm,
|
||||||
|
// بقیه مراحل از Placeholder استفاده میکنند
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_LABELS = [
|
||||||
|
"انتخاب مركز",
|
||||||
|
"مشخصات هويتي",
|
||||||
|
"مشخصات فردي",
|
||||||
|
"مشخصات ظاهري",
|
||||||
|
"مشخصات تحصيلي",
|
||||||
|
"شغل درخواستي",
|
||||||
|
"دوره هاي آموزشي",
|
||||||
|
"مهارت ها",
|
||||||
|
"سوابق كاري",
|
||||||
|
"اطلاعات كاري",
|
||||||
|
"معرف",
|
||||||
|
"مشخصات آشنايان",
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- ۳. کامپوننت اصلی استپر ---
|
||||||
|
|
||||||
|
export default function MultiStepForm() {
|
||||||
|
const [activeStep, setActiveStep] = useState(1);
|
||||||
|
const [maxStepReached, setMaxStepReached] = useState(1);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
address: "",
|
||||||
|
isUrgent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
|
const updateFormData = (newData: Partial<typeof formData>) => {
|
||||||
|
setFormData((prev) => ({ ...prev, ...newData }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (activeStep < 12) {
|
||||||
|
setActiveStep((prev) => prev + 1);
|
||||||
|
if (activeStep + 1 > maxStepReached) setMaxStepReached(activeStep + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActiveStepComponent = STEP_COMPONENTS[activeStep] || PlaceholderStep;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
bgcolor: "#f8fafc",
|
||||||
|
py: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container maxWidth="xl">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: isMobile ? "column" : "row",
|
||||||
|
gap: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sidebar Navigation */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div style={{ width: "200px", flexShrink: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{ fontWeight: 900, mb: 4, color: "#1e293b" }}
|
||||||
|
>
|
||||||
|
پنل ثبت مرکز
|
||||||
|
</Typography>
|
||||||
|
{STEP_LABELS.map((label, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
onClick={() =>
|
||||||
|
i + 1 <= maxStepReached && setActiveStep(i + 1)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 2,
|
||||||
|
cursor: i + 1 <= maxStepReached ? "pointer" : "not-allowed",
|
||||||
|
opacity: i + 1 <= maxStepReached ? 1 : 0.4,
|
||||||
|
color: i + 1 === activeStep ? "#2563eb" : "#64748b",
|
||||||
|
transition: "0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "10px",
|
||||||
|
mr: 2,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor:
|
||||||
|
i + 1 === activeStep
|
||||||
|
? "#2563eb"
|
||||||
|
: i + 1 < activeStep
|
||||||
|
? "#4CAF5033"
|
||||||
|
: "#f1f5f9",
|
||||||
|
color: i + 1 === activeStep ? "#fff" : "#2563eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
border:
|
||||||
|
i + 1 === activeStep ? "none" : "1px solid #e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i + 1 < activeStep ? (
|
||||||
|
<CheckCircleIcon sx={{ fontSize: 18 }} color="success" />
|
||||||
|
) : (
|
||||||
|
i + 1
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: i + 1 === activeStep ? 800 : 500,
|
||||||
|
color: i + 1 < activeStep ? "green" : "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Form Area */}
|
||||||
|
<div style={{ flexGrow: 1 }}>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: { xs: 3, md: 6 },
|
||||||
|
borderRadius: "35px",
|
||||||
|
boxShadow: "0 25px 50px -12px rgba(0,0,0,0.05)",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography
|
||||||
|
variant="overline"
|
||||||
|
sx={{ color: "#2563eb", fontWeight: 900 }}
|
||||||
|
>
|
||||||
|
مرحله {activeStep.toLocaleString("fa-IR")} از{" "}
|
||||||
|
{STEP_LABELS.length.toLocaleString("fa-IR")}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
sx={{ fontWeight: 900, color: "#0f172a" }}
|
||||||
|
>
|
||||||
|
{STEP_LABELS[activeStep - 1]}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* رندر شدن داینامیک کامپوننت مرحله فعلی */}
|
||||||
|
<div className="w-full">
|
||||||
|
<ActiveStepComponent
|
||||||
|
data={formData}
|
||||||
|
update={updateFormData}
|
||||||
|
step={activeStep}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{ display: "flex", justifyContent: "space-between", mt: 5 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={activeStep === 1}
|
||||||
|
onClick={() => setActiveStep((prev) => prev - 1)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
color: "#64748b",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بازگشت
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleNext}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: `${activeStep === 12 ? "green" : "#2563eb"}`,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeStep === 12 ? "اتمام و ثبت نهايي" : "گام بعدی"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
ui/forms/CourseForm.tsx
Normal file
88
ui/forms/CourseForm.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, TextField, IconButton } from "@mui/material";
|
||||||
|
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
|
||||||
|
interface CourseAttributes {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
institution: string;
|
||||||
|
year: number | string;
|
||||||
|
duration: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: CourseAttributes;
|
||||||
|
onChange: (data: CourseAttributes) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
isDeletable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CourseForm({ data, onChange, onRemove, isDeletable }: Props) {
|
||||||
|
const handleChange = (field: keyof CourseAttributes, value: any) => {
|
||||||
|
onChange({ ...data, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2, position: "relative" }}>
|
||||||
|
{isDeletable && (
|
||||||
|
<IconButton
|
||||||
|
onClick={onRemove}
|
||||||
|
color="error"
|
||||||
|
sx={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}
|
||||||
|
aria-label="remove-course"
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlinedIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="عنوان دوره"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(e) => handleChange("title", e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="موسسه برگزار کننده"
|
||||||
|
value={data.institution}
|
||||||
|
onChange={(e) => handleChange("institution", e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
label="سال برگزاری"
|
||||||
|
value={data.year}
|
||||||
|
onChange={(e) => handleChange("year", e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="مدت دوره"
|
||||||
|
value={data.duration}
|
||||||
|
onChange={(e) => handleChange("duration", e.target.value)}
|
||||||
|
placeholder="مثلاً 40 ساعت"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
label="توضیحات"
|
||||||
|
value={data.description || ""}
|
||||||
|
onChange={(e) => handleChange("description", e.target.value)}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
ui/forms/CourseSection.tsx
Normal file
67
ui/forms/CourseSection.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button, Box } from "@mui/material";
|
||||||
|
import CourseForm from "./CourseForm";
|
||||||
|
import { AddCircleOutlineOutlined } from "@mui/icons-material";
|
||||||
|
|
||||||
|
export default function CourseSection() {
|
||||||
|
const [courses, setCourses] = useState([
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
title: "",
|
||||||
|
institution: "",
|
||||||
|
year: "",
|
||||||
|
duration: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setCourses([
|
||||||
|
...courses,
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
title: "",
|
||||||
|
institution: "",
|
||||||
|
year: "",
|
||||||
|
duration: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = (id: number | string, updatedData: any) => {
|
||||||
|
setCourses(courses.map((c) => (c.id === id ? updatedData : c)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (id: number | string) => {
|
||||||
|
setCourses(courses.filter((c) => c.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{courses.map((course) => (
|
||||||
|
<CourseForm
|
||||||
|
key={course.id}
|
||||||
|
data={course}
|
||||||
|
onChange={(data) => handleUpdate(course.id, data)}
|
||||||
|
onRemove={() => handleRemove(course.id)}
|
||||||
|
isDeletable={courses.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddCircleOutlineOutlined />}
|
||||||
|
onClick={handleAdd}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#4caf50",
|
||||||
|
"&:hover": { backgroundColor: "#388e3c" },
|
||||||
|
borderRadius: "12px",
|
||||||
|
mt: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
افزودن دوره آموزشی جدید
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
ui/forms/EducationForm.tsx
Normal file
336
ui/forms/EducationForm.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
// EducationForm.tsx
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Box, Button, MenuItem, TextField, Typography } from "@mui/material";
|
||||||
|
import { UploadFile } from "@mui/icons-material";
|
||||||
|
|
||||||
|
type DegreeValue =
|
||||||
|
| ""
|
||||||
|
| "زیر دیپلم"
|
||||||
|
| "دیپلم"
|
||||||
|
| "کاردانی"
|
||||||
|
| "کارشناسی"
|
||||||
|
| "کارشناسی ارشد"
|
||||||
|
| "دکتری"
|
||||||
|
| "حوزوی"
|
||||||
|
| "سایر";
|
||||||
|
|
||||||
|
export interface EducationFormState {
|
||||||
|
applicantId: string;
|
||||||
|
|
||||||
|
degree: DegreeValue | string; // allow custom too
|
||||||
|
field: string;
|
||||||
|
university: string;
|
||||||
|
|
||||||
|
startYear: number | "";
|
||||||
|
endYear: number | "";
|
||||||
|
|
||||||
|
gpa: number | "";
|
||||||
|
|
||||||
|
description: string;
|
||||||
|
certificateImageId: string; // FK (uuid as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues: EducationFormState = {
|
||||||
|
applicantId: "",
|
||||||
|
|
||||||
|
degree: "",
|
||||||
|
field: "",
|
||||||
|
university: "",
|
||||||
|
|
||||||
|
startYear: "",
|
||||||
|
endYear: "",
|
||||||
|
|
||||||
|
gpa: "",
|
||||||
|
|
||||||
|
description: "",
|
||||||
|
certificateImageId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toNumberOrEmpty(v: string): number | "" {
|
||||||
|
if (v === "") return "";
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EducationForm(props: {
|
||||||
|
value?: EducationFormState;
|
||||||
|
onChange?: (next: EducationFormState) => void;
|
||||||
|
applicantId?: string;
|
||||||
|
// اگر آپلودر فایل داری، آیدی خروجیاش رو اینجا ست میکنی
|
||||||
|
// onPickCertificateId?: () => void; // (اختیاری)
|
||||||
|
}) {
|
||||||
|
const { value, onChange, applicantId } = props;
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<EducationFormState>(
|
||||||
|
value ?? { ...initialValues, applicantId: applicantId ?? "" },
|
||||||
|
);
|
||||||
|
const [profilePhoto, setProfilePhoto] = useState<File | null>(null);
|
||||||
|
const [profilePhotoError, setProfilePhotoError] = useState<string>("");
|
||||||
|
|
||||||
|
const handleProfilePhotoChange = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
setProfilePhoto(null);
|
||||||
|
setProfilePhotoError("فقط فایل تصویری مجاز است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 500 * 1024; // 500KB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
setProfilePhoto(null);
|
||||||
|
setProfilePhotoError("حجم عکس باید حداکثر ۵۰۰ کیلوبایت باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfilePhoto(file);
|
||||||
|
setProfilePhotoError("");
|
||||||
|
|
||||||
|
};
|
||||||
|
// controlled sync
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) setFormData(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// applicantId sync when uncontrolled
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value && applicantId) setFormData((p) => ({ ...p, applicantId }));
|
||||||
|
}, [applicantId, value]);
|
||||||
|
|
||||||
|
const setNext = (
|
||||||
|
updater: (prev: EducationFormState) => EducationFormState,
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
onChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleText =
|
||||||
|
(field: keyof EducationFormState) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setNext((p) => ({ ...p, [field]: v }) as EducationFormState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNumber =
|
||||||
|
(field: keyof EducationFormState) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = toNumberOrEmpty(e.target.value);
|
||||||
|
setNext((p) => ({ ...p, [field]: v }) as EducationFormState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validation helpers (optional UI only)
|
||||||
|
const startYearError =
|
||||||
|
formData.startYear !== "" &&
|
||||||
|
(formData.startYear < 1300 || formData.startYear > 1600);
|
||||||
|
|
||||||
|
const endYearError =
|
||||||
|
formData.endYear !== "" &&
|
||||||
|
(formData.endYear < 1300 || formData.endYear > 1600);
|
||||||
|
|
||||||
|
const endBeforeStart =
|
||||||
|
formData.startYear !== "" &&
|
||||||
|
formData.endYear !== "" &&
|
||||||
|
Number(formData.endYear) < Number(formData.startYear);
|
||||||
|
|
||||||
|
const gpaError =
|
||||||
|
formData.gpa !== "" &&
|
||||||
|
(Number(formData.gpa) < 0 || Number(formData.gpa) > 20);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" },
|
||||||
|
gap: 2,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="مقطع تحصیلی"
|
||||||
|
value={formData.degree}
|
||||||
|
onChange={handleText("degree")}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
"زیر دیپلم",
|
||||||
|
"دیپلم",
|
||||||
|
"کاردانی",
|
||||||
|
"کارشناسی",
|
||||||
|
"کارشناسی ارشد",
|
||||||
|
"دکتری",
|
||||||
|
"حوزوی",
|
||||||
|
"سایر",
|
||||||
|
] as const
|
||||||
|
).map((d) => (
|
||||||
|
<MenuItem key={d} value={d}>
|
||||||
|
{d}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="رشته تحصیلی"
|
||||||
|
value={formData.field}
|
||||||
|
onChange={handleText("field")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="دانشگاه / موسسه"
|
||||||
|
value={formData.university}
|
||||||
|
onChange={handleText("university")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="سال شروع"
|
||||||
|
type="number"
|
||||||
|
value={formData.startYear}
|
||||||
|
onChange={handleNumber("startYear")}
|
||||||
|
fullWidth
|
||||||
|
error={startYearError}
|
||||||
|
helperText={startYearError ? "سال شروع معتبر نیست (مثلاً ۱۳۹۵)" : " "}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="سال پایان"
|
||||||
|
type="number"
|
||||||
|
value={formData.endYear}
|
||||||
|
onChange={handleNumber("endYear")}
|
||||||
|
fullWidth
|
||||||
|
error={endYearError || endBeforeStart}
|
||||||
|
helperText={
|
||||||
|
endBeforeStart
|
||||||
|
? "سال پایان نمیتواند قبل از سال شروع باشد"
|
||||||
|
: endYearError
|
||||||
|
? "سال پایان معتبر نیست (مثلاً ۱۳۹۹)"
|
||||||
|
: " "
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="معدل (از ۲۰)"
|
||||||
|
type="number"
|
||||||
|
value={formData.gpa}
|
||||||
|
onChange={handleNumber("gpa")}
|
||||||
|
fullWidth
|
||||||
|
error={gpaError}
|
||||||
|
helperText={gpaError ? "معدل باید بین ۰ تا ۲۰ باشد" : " "}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
border: profilePhotoError
|
||||||
|
? "1px solid #ef4444"
|
||||||
|
: "1px dashed #cbd5e1",
|
||||||
|
borderRadius: "18px",
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
p: 2,
|
||||||
|
minHeight: "100%",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: profilePhotoError ? "#ef4444" : "#2563eb",
|
||||||
|
backgroundColor: "#f8fbff",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#0f172a",
|
||||||
|
mb: 1.5,
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
تصوير مدرك تحصيلي
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
mb: 2,
|
||||||
|
lineHeight: 1.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت باشد.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
component="label"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFile />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
borderColor: "#cbd5e1",
|
||||||
|
color: "#2563eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
px: 2.5,
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: "#2563eb",
|
||||||
|
backgroundColor: "#eff6ff",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
انتخاب عکس
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleProfilePhotoChange}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{profilePhoto && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
color: "#475569",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
فایل انتخابشده: {profilePhoto.name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profilePhotoError && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
color: "#dc2626",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profilePhotoError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
label="توضیحات"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleText("description")}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initialValues as educationInitialValues };
|
||||||
82
ui/forms/EducationSection.tsx
Normal file
82
ui/forms/EducationSection.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Box, Button, Divider, Typography, Paper, IconButton } from "@mui/material";
|
||||||
|
import { Add, Delete } from "@mui/icons-material";
|
||||||
|
import EducationForm, { EducationFormState, educationInitialValues } from "./EducationForm";
|
||||||
|
|
||||||
|
export default function EducationSection({
|
||||||
|
applicantId,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
applicantId: string,
|
||||||
|
onChange: (educations: EducationFormState[]) => void
|
||||||
|
}) {
|
||||||
|
const [educations, setEducations] = useState<EducationFormState[]>([
|
||||||
|
{ ...educationInitialValues, applicantId }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// افزودن یک فرم جدید
|
||||||
|
const addEducation = () => {
|
||||||
|
setEducations((prev) => [...prev, { ...educationInitialValues, applicantId }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// حذف یک فرم از لیست
|
||||||
|
const removeEducation = (index: number) => {
|
||||||
|
setEducations((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// بهروزرسانی محتوای یک فرم خاص
|
||||||
|
const updateEducation = (index: number, data: EducationFormState) => {
|
||||||
|
const nextList = [...educations];
|
||||||
|
nextList[index] = data;
|
||||||
|
setEducations(nextList);
|
||||||
|
onChange(nextList); // ارسال لیست نهایی به کامپوننت اصلی
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ color: "#1e293b", fontWeight: 700 }}>
|
||||||
|
سوابق تحصیلی
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{educations.map((ed, index) => (
|
||||||
|
<Paper key={index} sx={{ p: 3, borderRadius: "16px", border: "1px solid #e2e8f0", position: "relative" }}>
|
||||||
|
|
||||||
|
{/* دکمه حذف برای هر آیتم */}
|
||||||
|
{educations.length > 1 && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => removeEducation(index)}
|
||||||
|
sx={{ position: "absolute", top: 8, right: 8, color: "#ef4444" }}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 2, color: "#64748b" }}>
|
||||||
|
مدرک تحصیلی {index + 1}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<EducationForm
|
||||||
|
value={ed}
|
||||||
|
onChange={(newData) => updateEducation(index, newData)}
|
||||||
|
applicantId={applicantId}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={addEducation}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
backgroundColor: "#2563eb",
|
||||||
|
py: 1.5,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
افزودن مدرک تحصیلی جدید
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
405
ui/forms/IdentityForm.tsx
Normal file
405
ui/forms/IdentityForm.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { UploadFile } from "@mui/icons-material";
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
|
import { AdapterDateFnsJalali } from "@mui/x-date-pickers/AdapterDateFnsJalali";
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
|
|
||||||
|
type IdentityFormData = {
|
||||||
|
applicantId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
fatherName: string;
|
||||||
|
nationalCode: string;
|
||||||
|
birthDate: string;
|
||||||
|
birthPlace: string;
|
||||||
|
gender: string;
|
||||||
|
religion: string;
|
||||||
|
nationality: string;
|
||||||
|
profilePhotoId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IdentityFormErrors = Partial<Record<keyof IdentityFormData, string>>;
|
||||||
|
|
||||||
|
const initialForm: IdentityFormData = {
|
||||||
|
applicantId: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
fatherName: "",
|
||||||
|
nationalCode: "",
|
||||||
|
birthDate: "",
|
||||||
|
birthPlace: "",
|
||||||
|
gender: "",
|
||||||
|
religion: "",
|
||||||
|
nationality: "",
|
||||||
|
profilePhotoId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function IdentityForm() {
|
||||||
|
const [formData, setFormData] = useState<IdentityFormData>(initialForm);
|
||||||
|
const [errors, setErrors] = useState<IdentityFormErrors>({});
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [profilePhoto, setProfilePhoto] = useState<File | null>(null);
|
||||||
|
const [profilePhotoPreview, setProfilePhotoPreview] = useState<string>("");
|
||||||
|
const [profilePhotoError, setProfilePhotoError] = useState<string>("");
|
||||||
|
const [birthDateValue, setBirthDateValue] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
const handleProfilePhotoChange = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
setProfilePhoto(null);
|
||||||
|
setProfilePhotoPreview("");
|
||||||
|
setProfilePhotoError("فقط فایل تصویری مجاز است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 500 * 1024; // 500KB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
setProfilePhoto(null);
|
||||||
|
setProfilePhotoPreview("");
|
||||||
|
setProfilePhotoError("حجم عکس باید حداکثر ۵۰۰ کیلوبایت باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfilePhoto(file);
|
||||||
|
setProfilePhotoError("");
|
||||||
|
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
setProfilePhotoPreview(previewUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const genderOptions = useMemo(() => ["مرد", "زن", "سایر"], []);
|
||||||
|
const religionOptions = useMemo(
|
||||||
|
() => ["اسلام", "مسیحیت", "یهودیت", "زرتشتی", "سایر"],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange =
|
||||||
|
(field: keyof IdentityFormData) =>
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let value = event.target.value;
|
||||||
|
|
||||||
|
if (field === "nationalCode") {
|
||||||
|
value = value.replace(/\D/g, "").slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const newErrors: IdentityFormErrors = {};
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
if (!formData.applicantId.trim()) {
|
||||||
|
newErrors.applicantId = "شناسه متقاضی الزامی است";
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.firstName.trim()) {
|
||||||
|
newErrors.firstName = "نام الزامی است";
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.lastName.trim()) {
|
||||||
|
newErrors.lastName = "نام خانوادگی الزامی است";
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.nationalCode.trim()) {
|
||||||
|
newErrors.nationalCode = "کد ملی الزامی است";
|
||||||
|
hasError = true;
|
||||||
|
} else if (!/^\d{10}$/.test(formData.nationalCode)) {
|
||||||
|
newErrors.nationalCode = "کد ملی باید ۱۰ رقم باشد";
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.birthDate.trim()) {
|
||||||
|
newErrors.birthDate = "تاریخ تولد الزامی است";
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.gender.trim()) {
|
||||||
|
newErrors.gender = "جنسیت الزامی است";
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.nationality.trim()) {
|
||||||
|
newErrors.nationality = "ملیت الزامی است";
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profilePhoto) {
|
||||||
|
setProfilePhotoError("عکس پرسنلی الزامی است");
|
||||||
|
hasError = true;
|
||||||
|
} else {
|
||||||
|
setProfilePhotoError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return !hasError;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitted(false);
|
||||||
|
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
birthDate: formData.birthDate ? new Date(formData.birthDate) : null,
|
||||||
|
fatherName: formData.fatherName || null,
|
||||||
|
birthPlace: formData.birthPlace || null,
|
||||||
|
religion: formData.religion || null,
|
||||||
|
profilePhotoId: formData.profilePhotoId || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Identity Payload:", payload);
|
||||||
|
setSubmitted(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
background: "#ffffff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))",
|
||||||
|
gap: "18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="نام"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleChange("firstName")}
|
||||||
|
error={!!errors.firstName}
|
||||||
|
helperText={errors.firstName}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نام خانوادگی"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleChange("lastName")}
|
||||||
|
error={!!errors.lastName}
|
||||||
|
helperText={errors.lastName}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نام پدر"
|
||||||
|
value={formData.fatherName}
|
||||||
|
onChange={handleChange("fatherName")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="کد ملی"
|
||||||
|
value={formData.nationalCode}
|
||||||
|
onChange={handleChange("nationalCode")}
|
||||||
|
error={!!errors.nationalCode}
|
||||||
|
helperText={errors.nationalCode}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DatePicker
|
||||||
|
label="تاریخ تولد"
|
||||||
|
value={birthDateValue}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
setBirthDateValue(newValue);
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
birthDate: newValue ? newValue.toISOString() : "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (errors.birthDate) {
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
birthDate: "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
fullWidth: true,
|
||||||
|
error: !!errors.birthDate,
|
||||||
|
helperText: errors.birthDate,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="محل تولد"
|
||||||
|
value={formData.birthPlace}
|
||||||
|
onChange={handleChange("birthPlace")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="جنسیت"
|
||||||
|
value={formData.gender}
|
||||||
|
onChange={handleChange("gender")}
|
||||||
|
error={!!errors.gender}
|
||||||
|
helperText={errors.gender}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{genderOptions.map((item) => (
|
||||||
|
<MenuItem key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="دین"
|
||||||
|
value={formData.religion}
|
||||||
|
onChange={handleChange("religion")}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{religionOptions.map((item) => (
|
||||||
|
<MenuItem key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="ملیت"
|
||||||
|
value={formData.nationality}
|
||||||
|
onChange={handleChange("nationality")}
|
||||||
|
error={!!errors.nationality}
|
||||||
|
helperText={errors.nationality}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
border: profilePhotoError
|
||||||
|
? "1px solid #ef4444"
|
||||||
|
: "1px dashed #cbd5e1",
|
||||||
|
borderRadius: "18px",
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
p: 2,
|
||||||
|
minHeight: "100%",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: profilePhotoError ? "#ef4444" : "#2563eb",
|
||||||
|
backgroundColor: "#f8fbff",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#0f172a",
|
||||||
|
mb: 1.5,
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
عکس پرسنلی
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
mb: 2,
|
||||||
|
lineHeight: 1.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
فقط فایل تصویری مجاز است و حجم آن باید حداکثر ۵۰۰ کیلوبایت باشد.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
component="label"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFile />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
borderColor: "#cbd5e1",
|
||||||
|
color: "#2563eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
px: 2.5,
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: "#2563eb",
|
||||||
|
backgroundColor: "#eff6ff",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
انتخاب عکس
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleProfilePhotoChange}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{profilePhoto && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
color: "#475569",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
فایل انتخابشده: {profilePhoto.name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profilePhotoError && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
color: "#dc2626",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profilePhotoError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
ui/forms/JobInfoForm.tsx
Normal file
172
ui/forms/JobInfoForm.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
MenuItem,
|
||||||
|
Grid,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers";
|
||||||
|
import { format } from "date-fns-jalali"; // برای تبدیل تاریخ به رشته استاندارد
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
export interface JobInfoFormData {
|
||||||
|
readyToWorkDate: string; // YYYY-MM-DD
|
||||||
|
isCurrentEmployee: boolean;
|
||||||
|
hasPastCooperation: boolean;
|
||||||
|
isCurrentlyEmployed: boolean;
|
||||||
|
dualJobInterest: boolean;
|
||||||
|
retirementStatus: "None" | "Retired" | "Redeemed";
|
||||||
|
isMilitary: boolean;
|
||||||
|
hasInsurance: boolean;
|
||||||
|
insuranceType: string;
|
||||||
|
totalInsuranceYears: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: JobInfoFormData;
|
||||||
|
onChange: (next: JobInfoFormData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobInfoForm({ value, onChange }: Props) {
|
||||||
|
const setField = (key: keyof JobInfoFormData) => (e: any) => {
|
||||||
|
onChange({ ...value, [key]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSwitch =
|
||||||
|
(key: keyof JobInfoFormData) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
const next = { ...value, [key]: checked };
|
||||||
|
|
||||||
|
// پاکسازی فیلدهای وابسته بیمه در صورت غیرفعال شدن
|
||||||
|
if (key === "hasInsurance" && !checked) {
|
||||||
|
next.insuranceType = "";
|
||||||
|
next.totalInsuranceYears = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
const handleDateChange = (date: Date | null) => {
|
||||||
|
if (date) {
|
||||||
|
// تبدیل تاریخ جاوا اسکریپت به فرمت YYYY-MM-DD برای دیتابیس
|
||||||
|
const formattedDate = format(date, "yyyy-MM-dd");
|
||||||
|
onChange({ ...value, readyToWorkDate: formattedDate });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
label="تاریخ آمادگی برای شروع کار"
|
||||||
|
value={value?.readyToWorkDate ? new Date(value?.readyToWorkDate) : null}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
slotProps={{
|
||||||
|
textField: { fullWidth: true },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* وضعیت بازنشستگی */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="وضعیت بازنشستگی"
|
||||||
|
value={value?.retirementStatus}
|
||||||
|
onChange={setField("retirementStatus")}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="None">هیچکدام</MenuItem>
|
||||||
|
<MenuItem value="Retired">بازنشسته</MenuItem>
|
||||||
|
<MenuItem value="Redeemed">بازخرید</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* Switch Buttons */}
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={value?.isCurrentEmployee}
|
||||||
|
onChange={setSwitch("isCurrentEmployee")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="از پرسنل حال حاضر هستم"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={value?.hasPastCooperation}
|
||||||
|
onChange={setSwitch("hasPastCooperation")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="سابقه همکاری در گذشته دارم"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={value?.isCurrentlyEmployed}
|
||||||
|
onChange={setSwitch("isCurrentlyEmployed")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="در حال حاضر مشغول به کار هستم"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={value?.dualJobInterest}
|
||||||
|
onChange={setSwitch("dualJobInterest")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="تمایل به شغل دوم دارم"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={value?.isMilitary}
|
||||||
|
onChange={setSwitch("isMilitary")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="نظامی هستم"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={value?.hasInsurance}
|
||||||
|
onChange={setSwitch("hasInsurance")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="دارای سابقه بیمه هستم"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Conditional Insurance Fields */}
|
||||||
|
{value?.hasInsurance && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="نوع بیمه"
|
||||||
|
value={value?.insuranceType}
|
||||||
|
onChange={setField("insuranceType")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="جمع سالهای سابقه بیمه"
|
||||||
|
value={value?.totalInsuranceYears}
|
||||||
|
onChange={setField("totalInsuranceYears")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
ui/forms/JobRequestForm.tsx
Normal file
263
ui/forms/JobRequestForm.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
InputAdornment,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
|
||||||
|
type JobCategoryOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JobOption = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
jobCategoryId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface JobRequestFormData {
|
||||||
|
jobCategoryId: string;
|
||||||
|
jobId: string;
|
||||||
|
requestedJobDescription: string;
|
||||||
|
employmentRelationType: string;
|
||||||
|
description: string;
|
||||||
|
requestedShiftType: string;
|
||||||
|
expectedSalary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobRequestFormProps {
|
||||||
|
jobCategories?: JobCategoryOption[];
|
||||||
|
jobs?: JobOption[];
|
||||||
|
value?: JobRequestFormData;
|
||||||
|
onChange?: (data: JobRequestFormData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationTypes = [
|
||||||
|
"تمام وقت",
|
||||||
|
"پاره وقت",
|
||||||
|
"پروژهای",
|
||||||
|
"ساعتی",
|
||||||
|
"قراردادی",
|
||||||
|
"کارورزی",
|
||||||
|
];
|
||||||
|
|
||||||
|
const shiftTypes = [
|
||||||
|
"ثابت صبح",
|
||||||
|
"ثابت عصر",
|
||||||
|
"ثابت شب",
|
||||||
|
"چرخشی",
|
||||||
|
"شیفتی",
|
||||||
|
"فرقی ندارد",
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultCategories: JobCategoryOption[] = [
|
||||||
|
{ id: "1", name: "پاراکلینیک" },
|
||||||
|
{ id: "2", name: "اداری" },
|
||||||
|
{ id: "3", name: "درمانی" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultJobs: JobOption[] = [
|
||||||
|
{ id: "1", title: "کارشناس آزمایشگاه", jobCategoryId: "1" },
|
||||||
|
{ id: "2", title: "کارشناس رادیولوژی", jobCategoryId: "1" },
|
||||||
|
{ id: "3", title: "منشی", jobCategoryId: "2" },
|
||||||
|
{ id: "4", title: "مسئول بایگانی", jobCategoryId: "2" },
|
||||||
|
{ id: "5", title: "پرستار", jobCategoryId: "3" },
|
||||||
|
{ id: "6", title: "کمک پرستار", jobCategoryId: "3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialValues: JobRequestFormData = {
|
||||||
|
jobCategoryId: "",
|
||||||
|
jobId: "",
|
||||||
|
requestedJobDescription: "",
|
||||||
|
employmentRelationType: "",
|
||||||
|
description: "",
|
||||||
|
requestedShiftType: "",
|
||||||
|
expectedSalary: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function JobRequestForm({
|
||||||
|
jobCategories = defaultCategories,
|
||||||
|
jobs = defaultJobs,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: JobRequestFormProps) {
|
||||||
|
const [formData, setFormData] = useState<JobRequestFormData>(
|
||||||
|
value ?? initialValues,
|
||||||
|
);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
|
if (!formData.jobCategoryId) return [];
|
||||||
|
return jobs.filter((job) => job.jobCategoryId === formData.jobCategoryId);
|
||||||
|
}, [jobs, formData.jobCategoryId]);
|
||||||
|
|
||||||
|
const updateForm = (next: JobRequestFormData) => {
|
||||||
|
setFormData(next);
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange =
|
||||||
|
(field: keyof JobRequestFormData) =>
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
|
||||||
|
let next = { ...formData, [field]: value };
|
||||||
|
|
||||||
|
// اگر رسته شغلی عوض شد، شغل قبلی پاک شود
|
||||||
|
if (field === "jobCategoryId") {
|
||||||
|
next.jobId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// فقط عدد برای حقوق
|
||||||
|
if (field === "expectedSalary") {
|
||||||
|
next.expectedSalary = value.replace(/[^\d]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateForm(next);
|
||||||
|
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formData.jobCategoryId) {
|
||||||
|
newErrors.jobCategoryId = "یک گزینه را انتخاب کنید!";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.jobId) {
|
||||||
|
newErrors.jobId = "یک گزینه را انتخاب کنید!";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.employmentRelationType) {
|
||||||
|
newErrors.employmentRelationType = "یک گزینه را انتخاب کنید!";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// اگر خواستی بعداً برای submit استفاده کن
|
||||||
|
// const handleSubmit = () => {
|
||||||
|
// if (!validate()) return;
|
||||||
|
// console.log(formData);
|
||||||
|
// };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "32px",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="رسته شغلی*"
|
||||||
|
value={formData.jobCategoryId}
|
||||||
|
onChange={handleChange("jobCategoryId")}
|
||||||
|
error={!!errors.jobCategoryId}
|
||||||
|
helperText={errors.jobCategoryId || " "}
|
||||||
|
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب...</MenuItem>
|
||||||
|
{jobCategories.map((item) => (
|
||||||
|
<MenuItem key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="شغل درخواستی*"
|
||||||
|
value={formData.jobId}
|
||||||
|
onChange={handleChange("jobId")}
|
||||||
|
error={!!errors.jobId}
|
||||||
|
helperText={errors.jobId || " "}
|
||||||
|
disabled={!formData.jobCategoryId}
|
||||||
|
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب...</MenuItem>
|
||||||
|
{filteredJobs.map((item) => (
|
||||||
|
<MenuItem key={item.id} value={item.id}>
|
||||||
|
{item.title}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="توضیحات شغل درخواست"
|
||||||
|
value={formData.requestedJobDescription}
|
||||||
|
onChange={handleChange("requestedJobDescription")}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="نوع رابطه کاری*"
|
||||||
|
value={formData.employmentRelationType}
|
||||||
|
onChange={handleChange("employmentRelationType")}
|
||||||
|
error={!!errors.employmentRelationType}
|
||||||
|
helperText={errors.employmentRelationType || " "}
|
||||||
|
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب ...</MenuItem>
|
||||||
|
{relationTypes.map((item) => (
|
||||||
|
<MenuItem key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="نوع شیفت درخواستی"
|
||||||
|
value={formData.requestedShiftType}
|
||||||
|
onChange={handleChange("requestedShiftType")}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="حقوق درخواستی(ریال)"
|
||||||
|
value={formData.expectedSalary}
|
||||||
|
onChange={handleChange("expectedSalary")}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="توضیحات"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange("description")}
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
ui/forms/JobRequestSection.tsx
Normal file
43
ui/forms/JobRequestSection.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button, Box } from "@mui/material";
|
||||||
|
import JobRequestForm from "./JobRequestForm";
|
||||||
|
import { AddCircleOutlineOutlined } from "@mui/icons-material";
|
||||||
|
|
||||||
|
export default function JobRequestSection() {
|
||||||
|
const [jobs, setJobs] = useState([{ id: Date.now(), jobCategoryId: "", hasPlan: false, planStartDate: null, degree: "" }]);
|
||||||
|
|
||||||
|
const addJob = () => {
|
||||||
|
setJobs([...jobs, { id: Date.now(), jobCategoryId: "", hasPlan: false, planStartDate: null, degree: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateJob = (id, updatedData) => {
|
||||||
|
setJobs(jobs.map(j => j.id === id ? { ...updatedData, id } : j));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeJob = (id) => {
|
||||||
|
if (jobs.length > 1) setJobs(jobs.filter(j => j.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10 ">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<JobRequestForm
|
||||||
|
key={job.id}
|
||||||
|
data={job}
|
||||||
|
onChange={(newData) => updateJob(job.id, newData)}
|
||||||
|
onRemove={() => removeJob(job.id)}
|
||||||
|
isDeletable={jobs.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddCircleOutlineOutlined />}
|
||||||
|
onClick={addJob}
|
||||||
|
sx={{ mt: 1, borderColor: '#4caf50', color: '#4caf50' }}
|
||||||
|
>
|
||||||
|
افزودن شغل درخواستی جدید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
ui/forms/LanguageForm.tsx
Normal file
161
ui/forms/LanguageForm.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, MenuItem, Paper, TextField } from "@mui/material";
|
||||||
|
|
||||||
|
type ProficiencyLevel =
|
||||||
|
| ""
|
||||||
|
| "NONE"
|
||||||
|
| "VERY_WEAK"
|
||||||
|
| "WEAK"
|
||||||
|
| "AVERAGE"
|
||||||
|
| "GOOD"
|
||||||
|
| "VERY_GOOD"
|
||||||
|
| "EXCELLENT";
|
||||||
|
|
||||||
|
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 interface LanguageSkillsUIData {
|
||||||
|
englishLevel: ProficiencyLevel;
|
||||||
|
englishDescription: string;
|
||||||
|
englishCertificate: string;
|
||||||
|
|
||||||
|
arabicLevel: ProficiencyLevel;
|
||||||
|
arabicDescription: string;
|
||||||
|
|
||||||
|
otherLanguagesDescription: string;
|
||||||
|
dialectsDescription: string;
|
||||||
|
|
||||||
|
otherSkills: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: LanguageSkillsUIData;
|
||||||
|
onChange: (next: LanguageSkillsUIData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LanguageSkillsForm({ value, onChange }: Props) {
|
||||||
|
const setField = (field: keyof LanguageSkillsUIData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange({ ...value, [field]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2, md: 3 },
|
||||||
|
borderRadius: "24px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* زبان انگلیسی */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="زبان انگلیسی*"
|
||||||
|
value={value.englishLevel}
|
||||||
|
onChange={setField("englishLevel")}
|
||||||
|
>
|
||||||
|
{proficiencyOptions.map((o) => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="مدرک معتبر زبان انگلیسی"
|
||||||
|
value={value.englishCertificate}
|
||||||
|
onChange={setField("englishCertificate")}
|
||||||
|
placeholder="مثلاً IELTS / TOEFL / MSRT / ..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
label="توضیحات"
|
||||||
|
value={value.englishDescription}
|
||||||
|
onChange={setField("englishDescription")}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* زبان عربی */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="زبان عربی*"
|
||||||
|
value={value.arabicLevel}
|
||||||
|
onChange={setField("arabicLevel")}
|
||||||
|
>
|
||||||
|
{proficiencyOptions.map((o) => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<Box /> {/* برای حفظ چینش دو ستونه */}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
label="توضیحات"
|
||||||
|
value={value.arabicDescription}
|
||||||
|
onChange={setField("arabicDescription")}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* سایر زبانها */}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
label="سایر زبان ها (توضیحات در مورد میزان تسلط)"
|
||||||
|
value={value.otherLanguagesDescription}
|
||||||
|
onChange={setField("otherLanguagesDescription")}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* گویشها و لهجهها */}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
label="آشنایی با گویش ها و لهجه های کشور (توضیحات در مورد میزان تسلط)"
|
||||||
|
value={value.dialectsDescription}
|
||||||
|
onChange={setField("dialectsDescription")}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* سایر مهارتها */}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
label="سایر مهارت ها (اعم از ورزشی، هنری، فرهنگی، اجتماعی و ...)"
|
||||||
|
value={value.otherSkills}
|
||||||
|
onChange={setField("otherSkills")}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
ui/forms/LoginForm.tsx
Normal file
111
ui/forms/LoginForm.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Stack,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useApplicantLogin } from "@/hooks/auth.hook";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function LoginLayout() {
|
||||||
|
const [nationalId, setNationalId] = useState("");
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = useApplicantLogin();
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const { message } = await mutateAsync(nationalId);
|
||||||
|
toast.success(message);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("خطا رخ داده است");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", height: "100vh", width: "100%" }}>
|
||||||
|
{/* بخش فرم (سمت چپ) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container maxWidth="xs">
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800, mb: 1 }}>
|
||||||
|
خوش آمدید
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ color: "text.secondary", mb: 4 }}>
|
||||||
|
براي شروع و يا ادامه فرآيند ، كدملي خود را وارد كنيد
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="کد ملی"
|
||||||
|
value={nationalId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNationalId(e.target.value.replace(/[^0-9]/g, ""))
|
||||||
|
}
|
||||||
|
sx={{ mb: 3, textAlign: "center", fontSize: "1.5rem" }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
sx={{ py: 1.5, borderRadius: 2, fontSize: "1rem" }}
|
||||||
|
>
|
||||||
|
ورود به سامانه
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* بخش تصویری/رنگی (سمت راست) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: { xs: "none", md: "flex" }, // در موبایل مخفی میشود
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
background: "linear-gradient(135deg, #1e293b 0%, #334155 100%)",
|
||||||
|
color: "white",
|
||||||
|
p: 6,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* لوگو جایگزین */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
bgcolor: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 4,
|
||||||
|
mb: 4,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h3">لوگو</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
|
||||||
|
سامانه جامع استخدامي
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ opacity: 0.8, maxWidth: 400 }}>
|
||||||
|
با استفاده از این سامانه، اطلاعات شغلی و رزومه خود را به صورت یکپارچه
|
||||||
|
براي گروه ارسال کنید.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
ui/forms/PersonalInfoForm.tsx
Normal file
283
ui/forms/PersonalInfoForm.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Box, MenuItem, TextField } from "@mui/material";
|
||||||
|
|
||||||
|
type MilitaryStatus =
|
||||||
|
| ""
|
||||||
|
| "کارت پایان خدمت"
|
||||||
|
| "در حال خدمت"
|
||||||
|
| "معافیت تحصیلی"
|
||||||
|
| "معافیت دائم"
|
||||||
|
| "انجام نشده";
|
||||||
|
|
||||||
|
type EducationLevel =
|
||||||
|
| ""
|
||||||
|
| "زیر دیپلم"
|
||||||
|
| "دیپلم"
|
||||||
|
| "دانشجو"
|
||||||
|
| "کاردانی"
|
||||||
|
| "کارشناسی"
|
||||||
|
| "کارشناسی ارشد"
|
||||||
|
| "دکترا";
|
||||||
|
|
||||||
|
export interface PersonalInfoFormState {
|
||||||
|
applicantId: string;
|
||||||
|
|
||||||
|
maritalStatus: string;
|
||||||
|
|
||||||
|
// نظام وظیفه
|
||||||
|
militaryStatus: MilitaryStatus;
|
||||||
|
permanentExemptionReason: string; // علت معافیت دائم (شرطی)
|
||||||
|
|
||||||
|
// والدین
|
||||||
|
fatherEducation: EducationLevel;
|
||||||
|
fatherJob: string;
|
||||||
|
motherEducation: EducationLevel;
|
||||||
|
motherJob: string;
|
||||||
|
|
||||||
|
housingStatus: string;
|
||||||
|
city: string;
|
||||||
|
address: string;
|
||||||
|
homePhone: string;
|
||||||
|
mobilePhone: string;
|
||||||
|
emergencyPhone: string;
|
||||||
|
email: string;
|
||||||
|
residenceDuration: number | "";
|
||||||
|
isVeteran: boolean;
|
||||||
|
|
||||||
|
hasCriminalRecord: boolean;
|
||||||
|
criminalDescription: string;
|
||||||
|
|
||||||
|
// فیلدهای همسر/فرزند
|
||||||
|
spouseName?: string;
|
||||||
|
spouseEducation?: string;
|
||||||
|
spouseJob?: string;
|
||||||
|
spouseWorkplace?: string;
|
||||||
|
childrenCount?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues: PersonalInfoFormState = {
|
||||||
|
applicantId: "",
|
||||||
|
maritalStatus: "",
|
||||||
|
|
||||||
|
militaryStatus: "",
|
||||||
|
permanentExemptionReason: "",
|
||||||
|
|
||||||
|
fatherEducation: "",
|
||||||
|
fatherJob: "",
|
||||||
|
motherEducation: "",
|
||||||
|
motherJob: "",
|
||||||
|
|
||||||
|
housingStatus: "",
|
||||||
|
city: "",
|
||||||
|
address: "",
|
||||||
|
homePhone: "",
|
||||||
|
mobilePhone: "",
|
||||||
|
emergencyPhone: "",
|
||||||
|
email: "",
|
||||||
|
residenceDuration: "",
|
||||||
|
isVeteran: false,
|
||||||
|
|
||||||
|
hasCriminalRecord: false,
|
||||||
|
criminalDescription: "",
|
||||||
|
|
||||||
|
spouseName: "",
|
||||||
|
spouseEducation: "",
|
||||||
|
spouseJob: "",
|
||||||
|
spouseWorkplace: "",
|
||||||
|
childrenCount: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MILITARY_OPTIONS: Exclude<MilitaryStatus, "">[] = [
|
||||||
|
"کارت پایان خدمت",
|
||||||
|
"در حال خدمت",
|
||||||
|
"معافیت تحصیلی",
|
||||||
|
"معافیت دائم",
|
||||||
|
"انجام نشده",
|
||||||
|
];
|
||||||
|
|
||||||
|
const EDUCATION_OPTIONS: Exclude<EducationLevel, "">[] = [
|
||||||
|
"زیر دیپلم",
|
||||||
|
"دیپلم",
|
||||||
|
"دانشجو",
|
||||||
|
"کاردانی",
|
||||||
|
"کارشناسی",
|
||||||
|
"کارشناسی ارشد",
|
||||||
|
"دکترا",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PersonalInfoForm({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value?: PersonalInfoFormState;
|
||||||
|
onChange?: (val: PersonalInfoFormState) => void;
|
||||||
|
}) {
|
||||||
|
const [formData, setFormData] = useState<PersonalInfoFormState>(value || initialValues);
|
||||||
|
|
||||||
|
// اگر prop value از بیرون تغییر کرد، state داخلی sync شود
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) setFormData(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const setNext = (updater: (prev: PersonalInfoFormState) => PersonalInfoFormState) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
onChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange =
|
||||||
|
<K extends keyof PersonalInfoFormState>(field: K) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value as PersonalInfoFormState[K];
|
||||||
|
setNext((p) => ({ ...p, [field]: val }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNumber =
|
||||||
|
<K extends keyof PersonalInfoFormState>(field: K) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setNext((p) => ({ ...p, [field]: (v === "" ? "" : Number(v)) as PersonalInfoFormState[K] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// تغییر وضعیت تاهل + پاکسازی فیلدهای شرطی مربوط به همسر/فرزند
|
||||||
|
const handleMaritalStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const status = e.target.value;
|
||||||
|
setNext((p) => {
|
||||||
|
const isMarried = status === "متاهل";
|
||||||
|
const hasChildren = ["متاهل", "متارکه", "فوت همسر"].includes(status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
maritalStatus: status,
|
||||||
|
spouseName: isMarried ? p.spouseName : "",
|
||||||
|
spouseEducation: isMarried ? p.spouseEducation : "",
|
||||||
|
spouseJob: isMarried ? p.spouseJob : "",
|
||||||
|
spouseWorkplace: isMarried ? p.spouseWorkplace : "",
|
||||||
|
childrenCount: hasChildren ? p.childrenCount : "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// تغییر وضعیت نظام وظیفه + پاکسازی علت معافیت در صورت عدم نیاز
|
||||||
|
const handleMilitaryStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const ms = e.target.value as MilitaryStatus;
|
||||||
|
setNext((p) => ({
|
||||||
|
...p,
|
||||||
|
militaryStatus: ms,
|
||||||
|
permanentExemptionReason: ms === "معافیت دائم" ? p.permanentExemptionReason : "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPermanentExempt = formData.militaryStatus === "معافیت دائم";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" }, gap: 2 }}>
|
||||||
|
|
||||||
|
|
||||||
|
{/* وضعیت تاهل */}
|
||||||
|
<TextField select label="وضعیت تاهل" value={formData.maritalStatus} onChange={handleMaritalStatusChange} fullWidth>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
<MenuItem value="مجرد">مجرد</MenuItem>
|
||||||
|
<MenuItem value="متاهل">متاهل</MenuItem>
|
||||||
|
<MenuItem value="متارکه">متارکه</MenuItem>
|
||||||
|
<MenuItem value="فوت همسر">فوت همسر</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* همسر (شرطی) */}
|
||||||
|
{formData.maritalStatus === "متاهل" && (
|
||||||
|
<>
|
||||||
|
<TextField label="نام و نام خانوادگی همسر" value={formData.spouseName} onChange={handleChange("spouseName")} fullWidth />
|
||||||
|
<TextField label="تحصیلات همسر" value={formData.spouseEducation} onChange={handleChange("spouseEducation")} fullWidth />
|
||||||
|
<TextField label="شغل همسر" value={formData.spouseJob} onChange={handleChange("spouseJob")} fullWidth />
|
||||||
|
<TextField label="محل کار همسر" value={formData.spouseWorkplace} onChange={handleChange("spouseWorkplace")} fullWidth />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* تعداد فرزند (شرطی) */}
|
||||||
|
{["متاهل", "متارکه", "فوت همسر"].includes(formData.maritalStatus) && (
|
||||||
|
<TextField
|
||||||
|
label="تعداد فرزند"
|
||||||
|
type="number"
|
||||||
|
value={formData.childrenCount}
|
||||||
|
onChange={handleNumber("childrenCount")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* وضعیت نظام وظیفه (لیستی) */}
|
||||||
|
<TextField select label="وضعیت نظام وظیفه" value={formData.militaryStatus} onChange={handleMilitaryStatusChange} fullWidth>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{MILITARY_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* علت معافیت دائم (شرطی) */}
|
||||||
|
{isPermanentExempt && (
|
||||||
|
<TextField
|
||||||
|
label="علت معافیت دائم"
|
||||||
|
value={formData.permanentExemptionReason}
|
||||||
|
onChange={handleChange("permanentExemptionReason")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* تحصیلات پدر/مادر (لیستی) */}
|
||||||
|
<TextField select label="تحصیلات پدر" value={formData.fatherEducation} onChange={handleChange("fatherEducation")} fullWidth>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{EDUCATION_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField label="شغل پدر" value={formData.fatherJob} onChange={handleChange("fatherJob")} fullWidth />
|
||||||
|
|
||||||
|
<TextField select label="تحصیلات مادر" value={formData.motherEducation} onChange={handleChange("motherEducation")} fullWidth>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{EDUCATION_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField label="شغل مادر" value={formData.motherJob} onChange={handleChange("motherJob")} fullWidth />
|
||||||
|
|
||||||
|
{/* بقیه فیلدهای قبلی (نمونه، اگر لازم داری کاملش میکنم یا در همین فایل نگه میداریم) */}
|
||||||
|
<TextField label="شهر" value={formData.city} onChange={handleChange("city")} fullWidth />
|
||||||
|
<TextField label="تلفن منزل" value={formData.homePhone} onChange={handleChange("homePhone")} fullWidth />
|
||||||
|
<TextField label="تلفن همراه" value={formData.mobilePhone} onChange={handleChange("mobilePhone")} fullWidth />
|
||||||
|
<TextField label="ایمیل" value={formData.email} onChange={handleChange("email")} fullWidth />
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: { md: "span 2" } }}>
|
||||||
|
<TextField label="آدرس" value={formData.address} onChange={handleChange("address")} fullWidth multiline minRows={2} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="سابقه کیفری"
|
||||||
|
value={String(formData.hasCriminalRecord)}
|
||||||
|
onChange={(e) => setNext((p) => ({ ...p, hasCriminalRecord: e.target.value === "true", criminalDescription: e.target.value === "true" ? p.criminalDescription : "" }))}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="false">خیر</MenuItem>
|
||||||
|
<MenuItem value="true">بله</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{formData.hasCriminalRecord && (
|
||||||
|
<TextField
|
||||||
|
label="توضیحات سوء پیشینه"
|
||||||
|
value={formData.criminalDescription}
|
||||||
|
onChange={handleChange("criminalDescription")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
ui/forms/PhysicalInfoForm.tsx
Normal file
286
ui/forms/PhysicalInfoForm.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Box, MenuItem, TextField } from "@mui/material";
|
||||||
|
|
||||||
|
type BloodType = "" | "A+" | "A-" | "B+" | "B-" | "AB+" | "AB-" | "O+" | "O-";
|
||||||
|
|
||||||
|
export interface PhysicalInfoFormState {
|
||||||
|
applicantId: string;
|
||||||
|
|
||||||
|
bloodType: BloodType;
|
||||||
|
height: number | ""; // cm
|
||||||
|
weight: number | ""; // kg
|
||||||
|
bmi: number | ""; // auto or manual
|
||||||
|
|
||||||
|
hasDisability: boolean;
|
||||||
|
disabilityDescription: string;
|
||||||
|
|
||||||
|
hasChronicDisease: boolean;
|
||||||
|
chronicDiseaseDescription: string;
|
||||||
|
|
||||||
|
surgeryHistory: string;
|
||||||
|
medications: string;
|
||||||
|
|
||||||
|
specialMark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues: PhysicalInfoFormState = {
|
||||||
|
applicantId: "",
|
||||||
|
|
||||||
|
bloodType: "",
|
||||||
|
height: "",
|
||||||
|
weight: "",
|
||||||
|
bmi: "",
|
||||||
|
|
||||||
|
hasDisability: false,
|
||||||
|
disabilityDescription: "",
|
||||||
|
|
||||||
|
hasChronicDisease: false,
|
||||||
|
chronicDiseaseDescription: "",
|
||||||
|
|
||||||
|
surgeryHistory: "",
|
||||||
|
medications: "",
|
||||||
|
|
||||||
|
specialMark: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toNumberOrEmpty(v: string): number | "" {
|
||||||
|
if (v === "") return "";
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(n: number) {
|
||||||
|
return Math.round(n * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PhysicalInfoForm(props: {
|
||||||
|
value?: PhysicalInfoFormState;
|
||||||
|
onChange?: (next: PhysicalInfoFormState) => void;
|
||||||
|
applicantId?: string; // اگر خواستی از بیرون تزریق کنی
|
||||||
|
}) {
|
||||||
|
const { value, onChange, applicantId } = props;
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<PhysicalInfoFormState>(
|
||||||
|
value ?? { ...initialValues, applicantId: applicantId ?? "" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// اگر value کنترلشده بود، همگامسازی
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) setFormData(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// اگر applicantId از بیرون تغییر کرد
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value && applicantId) {
|
||||||
|
setFormData((p) => ({ ...p, applicantId }));
|
||||||
|
}
|
||||||
|
}, [applicantId, value]);
|
||||||
|
|
||||||
|
const setNext = (
|
||||||
|
updater: (prev: PhysicalInfoFormState) => PhysicalInfoFormState,
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
onChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleText =
|
||||||
|
(field: keyof PhysicalInfoFormState) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setNext((p) => ({ ...p, [field]: v }) as PhysicalInfoFormState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNumber =
|
||||||
|
(field: keyof PhysicalInfoFormState) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = toNumberOrEmpty(e.target.value);
|
||||||
|
setNext((p) => ({ ...p, [field]: v }) as PhysicalInfoFormState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBoolSelect =
|
||||||
|
(field: "hasDisability" | "hasChronicDisease") =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value === "true";
|
||||||
|
setNext((p) => {
|
||||||
|
// اگر false شد، توضیحات را پاک میکنیم تا داده کثیف نماند
|
||||||
|
if (field === "hasDisability" && !v) {
|
||||||
|
return { ...p, hasDisability: false, disabilityDescription: "" };
|
||||||
|
}
|
||||||
|
if (field === "hasChronicDisease" && !v) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
hasChronicDisease: false,
|
||||||
|
chronicDiseaseDescription: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...p, [field]: v };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// محاسبه BMI از روی قد و وزن (cm, kg)
|
||||||
|
const computedBmi = useMemo(() => {
|
||||||
|
if (formData.height === "" || formData.weight === "") return "";
|
||||||
|
const hMeters = Number(formData.height) / 100;
|
||||||
|
if (!hMeters || hMeters <= 0) return "";
|
||||||
|
const bmi = Number(formData.weight) / (hMeters * hMeters);
|
||||||
|
return Number.isFinite(bmi) ? round1(bmi) : "";
|
||||||
|
}, [formData.height, formData.weight]);
|
||||||
|
|
||||||
|
// sync bmi (فقط وقتی قد/وزن داریم)
|
||||||
|
useEffect(() => {
|
||||||
|
// اگر بخوای دستی BMI وارد کنی، این بخش رو حذف کن.
|
||||||
|
setNext((p) => ({ ...p, bmi: computedBmi }));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [computedBmi]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" },
|
||||||
|
gap: 2,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
{/* bloodType */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="گروه خونی"
|
||||||
|
value={formData.bloodType}
|
||||||
|
onChange={handleText("bloodType")}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="">انتخاب کنید</MenuItem>
|
||||||
|
{(["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"] as const).map(
|
||||||
|
(bt) => (
|
||||||
|
<MenuItem key={bt} value={bt}>
|
||||||
|
{bt}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* height */}
|
||||||
|
<TextField
|
||||||
|
label="قد (سانتیمتر)"
|
||||||
|
type="number"
|
||||||
|
value={formData.height}
|
||||||
|
onChange={handleNumber("height")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* weight */}
|
||||||
|
<TextField
|
||||||
|
label="وزن (کیلوگرم)"
|
||||||
|
type="number"
|
||||||
|
value={formData.weight}
|
||||||
|
onChange={handleNumber("weight")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* bmi */}
|
||||||
|
<TextField
|
||||||
|
label="BMI"
|
||||||
|
type="number"
|
||||||
|
value={formData.bmi}
|
||||||
|
onChange={handleNumber("bmi")}
|
||||||
|
fullWidth
|
||||||
|
disabled // چون خودکار محاسبه میکنیم
|
||||||
|
helperText={
|
||||||
|
formData.height !== "" && formData.weight !== ""
|
||||||
|
? "بهصورت خودکار از قد و وزن محاسبه میشود"
|
||||||
|
: "برای محاسبه BMI، قد و وزن را وارد کنید"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* specialMark */}
|
||||||
|
<TextField
|
||||||
|
label="علامت مشخصه"
|
||||||
|
value={formData.specialMark}
|
||||||
|
onChange={handleText("specialMark")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* hasDisability */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="معلولیت دارد؟"
|
||||||
|
value={String(formData.hasDisability)}
|
||||||
|
onChange={handleBoolSelect("hasDisability")}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="false">خیر</MenuItem>
|
||||||
|
<MenuItem value="true">بله</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* hasChronicDisease */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="بیماری مزمن دارد؟"
|
||||||
|
value={String(formData.hasChronicDisease)}
|
||||||
|
onChange={handleBoolSelect("hasChronicDisease")}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="false">خیر</MenuItem>
|
||||||
|
<MenuItem value="true">بله</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* disabilityDescription */}
|
||||||
|
{formData.hasDisability && (
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
label="توضیحات معلولیت"
|
||||||
|
value={formData.disabilityDescription}
|
||||||
|
onChange={handleText("disabilityDescription")}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* chronicDiseaseDescription */}
|
||||||
|
{formData.hasChronicDisease && (
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
label="توضیحات بیماری مزمن"
|
||||||
|
value={formData.chronicDiseaseDescription}
|
||||||
|
onChange={handleText("chronicDiseaseDescription")}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* surgeryHistory */}
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
label="سابقه جراحی"
|
||||||
|
value={formData.surgeryHistory}
|
||||||
|
onChange={handleText("surgeryHistory")}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* medications */}
|
||||||
|
<Box sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}>
|
||||||
|
<TextField
|
||||||
|
label="داروهای مصرفی"
|
||||||
|
value={formData.medications}
|
||||||
|
onChange={handleText("medications")}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
ui/forms/ReferralForm.tsx
Normal file
241
ui/forms/ReferralForm.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Button,
|
||||||
|
MenuItem,
|
||||||
|
Divider,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import { DeleteOutlineOutlined } from "@mui/icons-material";
|
||||||
|
|
||||||
|
// ---------------- Types ----------------
|
||||||
|
export type AcquaintanceType = "Direct" | "Indirect";
|
||||||
|
|
||||||
|
export interface ReferralFormData {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
relationship: string;
|
||||||
|
acquaintanceDuration: string; // optional in DB, but keep as string
|
||||||
|
acquaintanceType: AcquaintanceType;
|
||||||
|
jobTitle: string;
|
||||||
|
workplaceName: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReferralItemFormProps {
|
||||||
|
index: number;
|
||||||
|
value: ReferralFormData;
|
||||||
|
onChange: (next: ReferralFormData) => void;
|
||||||
|
onRemove?: () => void;
|
||||||
|
disableRemove?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Item Form ----------------
|
||||||
|
export function ReferralItemForm({
|
||||||
|
index,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
disableRemove,
|
||||||
|
}: ReferralItemFormProps) {
|
||||||
|
const setField =
|
||||||
|
(key: keyof ReferralFormData) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange({ ...value, [key]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2, md: 2.5 },
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
backgroundColor: "#fff", // بدون سبز
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 1.5,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontWeight: 700 }}>معرف {index + 1}</Typography>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={disableRemove}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
aria-label="حذف معرف"
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="نام"
|
||||||
|
value={value.firstName}
|
||||||
|
onChange={setField("firstName")}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نام خانوادگی"
|
||||||
|
value={value.lastName}
|
||||||
|
onChange={setField("lastName")}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نسبت / رابطه"
|
||||||
|
value={value.relationship}
|
||||||
|
onChange={setField("relationship")}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
placeholder="مثلاً: دوست، همکار، فامیل..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="مدت زمان آشنایی"
|
||||||
|
value={value.acquaintanceDuration}
|
||||||
|
onChange={setField("acquaintanceDuration")}
|
||||||
|
fullWidth
|
||||||
|
placeholder="مثلاً: ۵ سال"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="نوع آشنایی"
|
||||||
|
value={value.acquaintanceType}
|
||||||
|
onChange={setField("acquaintanceType")}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<MenuItem value="Direct">مستقیم</MenuItem>
|
||||||
|
<MenuItem value="Indirect">غیرمستقیم</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="تلفن تماس"
|
||||||
|
value={value.phoneNumber}
|
||||||
|
onChange={setField("phoneNumber")}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
placeholder="مثلاً: 0912xxxxxxx"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="شغل معرف"
|
||||||
|
value={value.jobTitle}
|
||||||
|
onChange={setField("jobTitle")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="نام محل کار معرف"
|
||||||
|
value={value.workplaceName}
|
||||||
|
onChange={setField("workplaceName")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Section (Multi) ----------------
|
||||||
|
interface ReferralSectionProps {
|
||||||
|
value: ReferralFormData[];
|
||||||
|
onChange: (next: ReferralFormData[]) => void;
|
||||||
|
minItems?: number; // پیشفرض 1
|
||||||
|
maxItems?: number; // اختیاری
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyReferral = (): ReferralFormData => ({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
relationship: "",
|
||||||
|
acquaintanceDuration: "",
|
||||||
|
acquaintanceType: "Direct",
|
||||||
|
jobTitle: "",
|
||||||
|
workplaceName: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ReferralSection({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
minItems = 1,
|
||||||
|
maxItems,
|
||||||
|
title = "معرفها",
|
||||||
|
}: ReferralSectionProps) {
|
||||||
|
const items = value?.length ? value : Array.from({ length: minItems }, emptyReferral);
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
if (maxItems && items.length >= maxItems) return;
|
||||||
|
onChange([...(items || []), emptyReferral()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (idx: number, nextItem: ReferralFormData) => {
|
||||||
|
const next = items.map((it, i) => (i === idx ? nextItem : it));
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (idx: number) => {
|
||||||
|
if (items.length <= minItems) return;
|
||||||
|
const next = items.filter((_, i) => i !== idx);
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2 }}>
|
||||||
|
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={addItem}
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
disabled={!!maxItems && items.length >= maxItems}
|
||||||
|
>
|
||||||
|
افزودن معرف جديد
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gap: 2 }}>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<ReferralItemForm
|
||||||
|
key={idx}
|
||||||
|
index={idx}
|
||||||
|
value={item}
|
||||||
|
onChange={(next) => updateItem(idx, next)}
|
||||||
|
onRemove={() => removeItem(idx)}
|
||||||
|
disableRemove={items.length <= minItems}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
ui/forms/RelationForm.tsx
Normal file
141
ui/forms/RelationForm.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
Divider,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
export interface ReferenceFormData {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
relationship: string;
|
||||||
|
jobTitle: string;
|
||||||
|
workplaceName: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValueType = [ReferenceFormData, ReferenceFormData];
|
||||||
|
|
||||||
|
const emptyRef = (): ReferenceFormData => ({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
relationship: "",
|
||||||
|
jobTitle: "",
|
||||||
|
workplaceName: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: ValueType; // ✅ optional
|
||||||
|
onChange?: (next: ValueType) => void; // ✅ optional
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RelationForm({ value, onChange }: Props) {
|
||||||
|
// ✅ همیشه یک آرایه 2تایی معتبر داریم
|
||||||
|
const safeValue: ValueType = value ?? [emptyRef(), emptyRef()];
|
||||||
|
|
||||||
|
const safeOnChange = onChange ?? (() => {});
|
||||||
|
|
||||||
|
const updateField =
|
||||||
|
(index: 0 | 1, key: keyof ReferenceFormData) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const next: ValueType = [
|
||||||
|
{ ...safeValue[0] },
|
||||||
|
{ ...safeValue[1] },
|
||||||
|
];
|
||||||
|
|
||||||
|
next[index] = { ...next[index], [key]: e.target.value };
|
||||||
|
safeOnChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPersonFields = (index: 0 | 1) => (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
||||||
|
آشنای {index === 0 ? "اول" : "دوم"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
label="نام *"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
value={safeValue[index].firstName}
|
||||||
|
onChange={updateField(index, "firstName")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
label="نام خانوادگی *"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
value={safeValue[index].lastName}
|
||||||
|
onChange={updateField(index, "lastName")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
label="نسبت *"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
value={safeValue[index].relationship}
|
||||||
|
onChange={updateField(index, "relationship")}
|
||||||
|
placeholder="مثلاً: همکار، دوست"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
label="تلفن تماس *"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
inputMode="tel"
|
||||||
|
value={safeValue[index].phoneNumber}
|
||||||
|
onChange={updateField(index, "phoneNumber")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid >
|
||||||
|
<TextField
|
||||||
|
label="شغل *"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
value={safeValue[index].jobTitle}
|
||||||
|
onChange={updateField(index, "jobTitle")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
label="نام محل کار *"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
value={safeValue[index].workplaceName}
|
||||||
|
onChange={updateField(index, "workplaceName")}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={0} >
|
||||||
|
|
||||||
|
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||||
|
<AlertTitle>توجه</AlertTitle>
|
||||||
|
مشخصات دو نفر از آشنایان را وارد کنید و از درج بستگان درجه یک (پدر، مادر، همسر، برادر و خواهر) خودداری نمایید.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{renderPersonFields(0)}
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
{renderPersonFields(1)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
ui/forms/SkillsForm.tsx
Normal file
214
ui/forms/SkillsForm.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, MenuItem, Paper, TextField, Typography, Divider, Switch, FormControlLabel } from "@mui/material";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
type ProficiencyLevel = "" | "NONE" | "VERY_WEAK" | "WEAK" | "AVERAGE" | "GOOD" | "VERY_GOOD" | "EXCELLENT";
|
||||||
|
|
||||||
|
export interface ComputerSkillFormData {
|
||||||
|
pcUsage: ProficiencyLevel;
|
||||||
|
word: ProficiencyLevel;
|
||||||
|
excel: ProficiencyLevel;
|
||||||
|
powerPoint: ProficiencyLevel;
|
||||||
|
rahkaran: ProficiencyLevel;
|
||||||
|
kasra: ProficiencyLevel;
|
||||||
|
didgah: ProficiencyLevel;
|
||||||
|
his: ProficiencyLevel;
|
||||||
|
otherSoftware?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LanguageSkillsFormData {
|
||||||
|
englishLevel: ProficiencyLevel;
|
||||||
|
englishDescription: string;
|
||||||
|
hasEnglishCertificate: boolean; // جدید
|
||||||
|
englishCertificateType: string; // جدید
|
||||||
|
arabicLevel: ProficiencyLevel;
|
||||||
|
arabicDescription: string;
|
||||||
|
otherLanguagesDescription: string;
|
||||||
|
dialectsDescription: string;
|
||||||
|
otherSkills: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillsFormState {
|
||||||
|
computerSkill: ComputerSkillFormData;
|
||||||
|
languageSkill: LanguageSkillsFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: SkillsFormState;
|
||||||
|
onChange: (next: SkillsFormState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
const proficiencyOptions: { value: ProficiencyLevel; label: string }[] = [
|
||||||
|
{ value: "", label: "انتخاب ..." },
|
||||||
|
{ value: "NONE", label: "ندارد" },
|
||||||
|
{ value: "VERY_WEAK", label: "خیلی ضعیف" },
|
||||||
|
{ value: "WEAK", label: "ضعیف" },
|
||||||
|
{ value: "AVERAGE", label: "متوسط" },
|
||||||
|
{ value: "GOOD", label: "خوب" },
|
||||||
|
{ value: "VERY_GOOD", label: "خیلی خوب" },
|
||||||
|
{ value: "EXCELLENT", label: "عالی" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const certTypes = ["IELTS", "TOFEL", "TOLIMO", "MCHE"];
|
||||||
|
|
||||||
|
export default function SkillsForm({ value, onChange }: Props) {
|
||||||
|
|
||||||
|
const handleComputerChange = (key: keyof ComputerSkillFormData, val: string) => {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
computerSkill: { ...value.computerSkill, [key]: val },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLanguageChange = (key: keyof LanguageSkillsFormData, val: any) => {
|
||||||
|
const nextLang = { ...value.languageSkill, [key]: val };
|
||||||
|
|
||||||
|
// پاکسازی فیلد نوع مدرک در صورتی که سوییچ خاموش شود
|
||||||
|
if (key === "hasEnglishCertificate" && val === false) {
|
||||||
|
nextLang.englishCertificateType = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
languageSkill: nextLang,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "grid", gap: 3 }}>
|
||||||
|
{/* 1. Computer Skills Section */}
|
||||||
|
<Paper elevation={0} sx={{ p: { xs: 2, md: 3 }, borderRadius: "24px", border: "1px solid #e2e8f0" }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>مهارتهای کامپیوتری</Typography>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
||||||
|
{(['pcUsage', 'word', 'excel', 'powerPoint', 'rahkaran', 'kasra', 'didgah', 'his'] as const).map((field) => (
|
||||||
|
<TextField
|
||||||
|
key={field}
|
||||||
|
select
|
||||||
|
label={field.toUpperCase()}
|
||||||
|
value={value?.computerSkill[field]}
|
||||||
|
onChange={(e) => handleComputerChange(field, e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{proficiencyOptions.map((o) => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
))}
|
||||||
|
<TextField
|
||||||
|
label="سایر نرمافزارها"
|
||||||
|
value={value?.computerSkill.otherSoftware || ""}
|
||||||
|
onChange={(e) => handleComputerChange('otherSoftware', e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* 2. Language Skills Section */}
|
||||||
|
<Paper elevation={0} sx={{ p: { xs: 2, md: 3 }, borderRadius: "24px", border: "1px solid #e2e8f0" }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>آشنایی با زبانهای خارجه</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
|
{/* English */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="زبان انگلیسی*"
|
||||||
|
value={value?.languageSkill.englishLevel}
|
||||||
|
onChange={(e) => handleLanguageChange("englishLevel", e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{proficiencyOptions.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={value?.languageSkill.hasEnglishCertificate}
|
||||||
|
onChange={(e) => handleLanguageChange("hasEnglishCertificate", e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="مدرک معتبر زبان انگلیسی دارد"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{value?.languageSkill.hasEnglishCertificate && (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="نوع مدرک"
|
||||||
|
value={value?.languageSkill.englishCertificateType}
|
||||||
|
onChange={(e) => handleLanguageChange("englishCertificateType", e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
>
|
||||||
|
{certTypes.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="توضیحات زبان انگلیسی"
|
||||||
|
value={value?.languageSkill.englishDescription}
|
||||||
|
onChange={(e) => handleLanguageChange("englishDescription", e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ gridColumn: { xs: "1", md: "1 / -1" }, my: 1 }} />
|
||||||
|
|
||||||
|
{/* Arabic */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="زبان عربی*"
|
||||||
|
value={value?.languageSkill.arabicLevel}
|
||||||
|
onChange={(e) => handleLanguageChange("arabicLevel", e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{proficiencyOptions.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<Box />
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="توضیحات زبان عربی"
|
||||||
|
value={value?.languageSkill.arabicDescription}
|
||||||
|
onChange={(e) => handleLanguageChange("arabicDescription", e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ gridColumn: { xs: "1", md: "1 / -1" }, my: 1 }} />
|
||||||
|
|
||||||
|
{/* Other Skills */}
|
||||||
|
<TextField
|
||||||
|
label="سایر زبان ها (توضیحات در مورد میزان تسلط)"
|
||||||
|
value={value?.languageSkill.otherLanguagesDescription}
|
||||||
|
onChange={(e) => handleLanguageChange("otherLanguagesDescription", e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="آشنایی با گویش ها و لهجه های کشور (توضیحات در مورد میزان تسلط)"
|
||||||
|
value={value?.languageSkill.dialectsDescription}
|
||||||
|
onChange={(e) => handleLanguageChange("dialectsDescription", e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="سایر مهارت ها (اعم از ورزشی، هنری، فرهنگی، اجتماعی و ...)"
|
||||||
|
value={value?.languageSkill.otherSkills}
|
||||||
|
onChange={(e) => handleLanguageChange("otherSkills", e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
sx={{ gridColumn: { xs: "1", md: "1 / -1" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
ui/forms/WorkExperienceForm.tsx
Normal file
68
ui/forms/WorkExperienceForm.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, FormControlLabel, IconButton, Paper, Switch, TextField, Typography } from "@mui/material";
|
||||||
|
import { DeleteOutlineOutlined } from "@mui/icons-material";
|
||||||
|
|
||||||
|
export interface WorkExperienceFormItem {
|
||||||
|
id?: string;
|
||||||
|
hasNoExperience: boolean;
|
||||||
|
companyName: string;
|
||||||
|
lastPosition: string;
|
||||||
|
startYear: string;
|
||||||
|
endYear: string;
|
||||||
|
leavingReason: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: WorkExperienceFormItem;
|
||||||
|
index: number;
|
||||||
|
onChange: (next: WorkExperienceFormItem) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
disableRemove: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkExperienceItemForm({ value, index, onChange, onRemove, disableRemove }: Props) {
|
||||||
|
const setField = (key: keyof WorkExperienceFormItem) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange({ ...value, [key]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setHasNoExperience = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
// پاکسازی کامل سایر فیلدها در صورت انتخاب "فاقد سابقه"
|
||||||
|
onChange({
|
||||||
|
hasNoExperience: true,
|
||||||
|
companyName: "",
|
||||||
|
lastPosition: "",
|
||||||
|
startYear: "",
|
||||||
|
endYear: "",
|
||||||
|
leavingReason: "",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onChange({ ...value, hasNoExperience: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={0} >
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 1 }}>
|
||||||
|
<IconButton onClick={onRemove} disabled={disableRemove} size="small"><DeleteOutlineOutlined /></IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" }, gap: 2 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
sx={{ gridColumn: "1 / -1" }}
|
||||||
|
control={<Switch checked={value.hasNoExperience} onChange={(e) => setHasNoExperience(e.target.checked)} />}
|
||||||
|
label="فاقد سابقه کاری هستم"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField label="نام شرکت" value={value.companyName} onChange={setField("companyName")} fullWidth disabled={value.hasNoExperience} />
|
||||||
|
<TextField label="آخرین سمت" value={value.lastPosition} onChange={setField("lastPosition")} fullWidth disabled={value.hasNoExperience} />
|
||||||
|
<TextField label="سال شروع" value={value.startYear} onChange={(e) => onChange({ ...value, startYear: e.target.value.replace(/[^\d]/g, "") })} fullWidth disabled={value.hasNoExperience} inputMode="numeric" />
|
||||||
|
<TextField label="سال پایان" value={value.endYear} onChange={(e) => onChange({ ...value, endYear: e.target.value.replace(/[^\d]/g, "") })} fullWidth disabled={value.hasNoExperience} inputMode="numeric" />
|
||||||
|
<TextField label="علت ترک کار" value={value.leavingReason} onChange={setField("leavingReason")} fullWidth disabled={value.hasNoExperience} sx={{ gridColumn: { md: "1 / -1" } }} />
|
||||||
|
<TextField label="توضیحات" value={value.description} onChange={setField("description")} fullWidth disabled={value.hasNoExperience} multiline minRows={3} sx={{ gridColumn: { md: "1 / -1" } }} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
ui/forms/WorkExperienceSection.tsx
Normal file
74
ui/forms/WorkExperienceSection.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Button } from "@mui/material";
|
||||||
|
import { WorkExperienceFormItem, WorkExperienceItemForm } from "./WorkExperienceForm";
|
||||||
|
|
||||||
|
export interface WorkExperienceSectionProps {
|
||||||
|
value: WorkExperienceFormItem[];
|
||||||
|
onChange: (next: WorkExperienceFormItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyItem = (): WorkExperienceFormItem => ({
|
||||||
|
hasNoExperience: false,
|
||||||
|
companyName: "",
|
||||||
|
lastPosition: "",
|
||||||
|
startYear: "",
|
||||||
|
endYear: "",
|
||||||
|
leavingReason: "",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function WorkExperienceSection({ value, onChange }: WorkExperienceSectionProps) {
|
||||||
|
|
||||||
|
// رفع خطا: ایجاد تابع ایمن برای جلوگیری از "undefined is not a function"
|
||||||
|
const safeOnChange = (next: WorkExperienceFormItem[]) => {
|
||||||
|
if (typeof onChange === "function") {
|
||||||
|
onChange(next);
|
||||||
|
} else {
|
||||||
|
console.error("onChange is missing in WorkExperienceSection parent!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = value && value.length > 0 ? value : [emptyItem()];
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
safeOnChange([...items, emptyItem()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveItem = (index: number) => {
|
||||||
|
const next = items.filter((_, i) => i !== index);
|
||||||
|
safeOnChange(next.length > 0 ? next : [emptyItem()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemChange = (index: number, nextItem: WorkExperienceFormItem) => {
|
||||||
|
// منطق اصلی: اگر یک آیتم "فاقد سابقه" شد، لیست باید فقط شامل همان یک آیتم باشد
|
||||||
|
if (nextItem.hasNoExperience) {
|
||||||
|
safeOnChange([nextItem]);
|
||||||
|
} else {
|
||||||
|
const nextItems = items.map((it, i) => (i === index ? nextItem : it));
|
||||||
|
safeOnChange(nextItems);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasNoExperienceSelected = items.some((x) => x.hasNoExperience);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "grid", gap: 2 }}>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<WorkExperienceItemForm
|
||||||
|
key={idx}
|
||||||
|
index={idx}
|
||||||
|
value={item}
|
||||||
|
onChange={(next:any) => handleItemChange(idx, next)}
|
||||||
|
onRemove={() => handleRemoveItem(idx)}
|
||||||
|
disableRemove={items.length === 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<Button variant="outlined" onClick={handleAddItem} disabled={hasNoExperienceSelected}>
|
||||||
|
افزودن سابقه کاری
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
ui/forms/register-center/RegistrationCenterForm.tsx
Normal file
327
ui/forms/register-center/RegistrationCenterForm.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Container,
|
||||||
|
useTheme,
|
||||||
|
useMediaQuery,
|
||||||
|
Chip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
|
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||||
|
import BusinessIcon from "@mui/icons-material/Business";
|
||||||
|
import LocationOnIcon from "@mui/icons-material/LocationOn";
|
||||||
|
import LocalHospitalIcon from "@mui/icons-material/LocalHospital";
|
||||||
|
|
||||||
|
const TOTAL_STEPS = 12;
|
||||||
|
const STEP_LABELS = [
|
||||||
|
"انتخاب مرکز",
|
||||||
|
"موقعیت و آدرس",
|
||||||
|
"وضعیت فوریت",
|
||||||
|
"توضیحات تکمیلی",
|
||||||
|
"ساعات کاری",
|
||||||
|
"تصاویر مرکز",
|
||||||
|
"تجهیزات موجود",
|
||||||
|
"پرسنل",
|
||||||
|
"بیمههای طرف قرارداد",
|
||||||
|
"مجوزها",
|
||||||
|
"شرایط پذیرش",
|
||||||
|
"بررسی نهایی",
|
||||||
|
];
|
||||||
|
|
||||||
|
type CenterItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
isUrgent: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const centersMock: CenterItem[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "مرکز درمانی امید",
|
||||||
|
address: "تهران، خیابان ولیعصر، بالاتر از پارک ساعی، پلاک ۱۲۳",
|
||||||
|
isUrgent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "کلینیک تخصصی مهر",
|
||||||
|
address: "مشهد، بلوار وکیلآباد، بین وکیلآباد ۲۱ و ۲۳",
|
||||||
|
isUrgent: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
name: "بیمارستان شبانهروزی آتیه",
|
||||||
|
address: "اصفهان، خیابان شریعتی، کوچه ۸، ساختمان آتیه",
|
||||||
|
isUrgent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
name: "مرکز سلامت نوین",
|
||||||
|
address: "شیراز، میدان مطهری، خیابان معدل، نبش کوچه ۶",
|
||||||
|
isUrgent: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CenterRegistrationForm() {
|
||||||
|
const [activeStep, setActiveStep] = useState(1);
|
||||||
|
const [maxStepReached, setMaxStepReached] = useState(1);
|
||||||
|
|
||||||
|
const [selectedCenterId, setSelectedCenterId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
|
const selectedCenter =
|
||||||
|
centersMock.find((center) => center.id === selectedCenterId) || null;
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (activeStep < TOTAL_STEPS) {
|
||||||
|
const nextStep = activeStep + 1;
|
||||||
|
setActiveStep(nextStep);
|
||||||
|
if (nextStep > maxStepReached) setMaxStepReached(nextStep);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (activeStep > 1) setActiveStep((prev) => prev - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToStep = (step: number) => {
|
||||||
|
if (step <= maxStepReached) setActiveStep(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCenterList = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<>
|
||||||
|
{centersMock.map((center) => {
|
||||||
|
const isSelected = selectedCenterId === center.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-1" key={center.id}>
|
||||||
|
<Box
|
||||||
|
onClick={() => setSelectedCenterId(center.id)}
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
borderRadius: "18px",
|
||||||
|
border: isSelected
|
||||||
|
? "2px solid #2563eb"
|
||||||
|
: "1px solid #e2e8f0",
|
||||||
|
backgroundColor: isSelected ? "#eff6ff" : "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.25s ease",
|
||||||
|
boxShadow: isSelected
|
||||||
|
? "0 10px 25px -15px rgba(37,99,235,0.45)"
|
||||||
|
: "0 4px 12px rgba(15,23,42,0.04)",
|
||||||
|
"&:hover": {
|
||||||
|
transform: "translateY(-2px)",
|
||||||
|
borderColor: "#2563eb",
|
||||||
|
boxShadow: "0 12px 24px -16px rgba(37,99,235,0.35)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 2,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BusinessIcon sx={{ color: "#2563eb", fontSize: 22 }} />
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "#0f172a",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{center.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocationOnIcon
|
||||||
|
sx={{ color: "#94a3b8", fontSize: 18, mt: "2px" }}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: "0.92rem",
|
||||||
|
lineHeight: 1.9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{center.address}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Chip
|
||||||
|
icon={<LocalHospitalIcon />}
|
||||||
|
label={
|
||||||
|
center.isUrgent
|
||||||
|
? "استخدام فوری دارد"
|
||||||
|
: "استخدام فوری ندارد"
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
backgroundColor: center.isUrgent
|
||||||
|
? "#fee2e2"
|
||||||
|
: "#e2e8f0",
|
||||||
|
color: center.isUrgent ? "#b91c1c" : "#475569",
|
||||||
|
"& .MuiChip-icon": {
|
||||||
|
color: center.isUrgent ? "#dc2626" : "#64748b",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircleIcon
|
||||||
|
sx={{ color: "#2563eb", fontSize: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStepContent = (step: number) => {
|
||||||
|
switch (step) {
|
||||||
|
case 1:
|
||||||
|
return renderCenterList();
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return selectedCenter ? (
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography sx={{ fontWeight: 800, color: "#0f172a", mb: 2 }}>
|
||||||
|
مرکز انتخابشده
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderRadius: "20px",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
color: "#2563eb",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedCenter.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: "#64748b", mb: 2 }}>
|
||||||
|
{selectedCenter.address}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={
|
||||||
|
selectedCenter.isUrgent
|
||||||
|
? "استخدام فوری دارد"
|
||||||
|
: "استخدام فوری ندارد"
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
backgroundColor: selectedCenter.isUrgent
|
||||||
|
? "#dbeafe"
|
||||||
|
: "#e2e8f0",
|
||||||
|
color: selectedCenter.isUrgent ? "#1d4ed8" : "#475569",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography className="text-center text-[#94a3b8]">
|
||||||
|
ابتدا از مرحله قبل یک مرکز را انتخاب کنید.
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
return selectedCenter ? (
|
||||||
|
<Box sx={{ textAlign: "center" }}>
|
||||||
|
<BusinessIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: 60,
|
||||||
|
color: selectedCenter.isUrgent ? "#ef4444" : "#2563eb",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1, fontWeight: 700 }}>
|
||||||
|
وضعیت استخدام فوری
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{selectedCenter.isUrgent
|
||||||
|
? "این مرکز دارای استخدام فوری است."
|
||||||
|
: "این مرکز در حال حاضر استخدام فوری ندارد."}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={selectedCenter.isUrgent ? "فوری" : "عادی"}
|
||||||
|
sx={{
|
||||||
|
px: 1,
|
||||||
|
fontWeight: 800,
|
||||||
|
backgroundColor: selectedCenter.isUrgent
|
||||||
|
? "#fee2e2"
|
||||||
|
: "#e2e8f0",
|
||||||
|
color: selectedCenter.isUrgent ? "#dc2626" : "#475569",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography className="text-center text-[#94a3b8]">
|
||||||
|
ابتدا یک مرکز انتخاب کنید.
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Typography className="text-center text-[#94a3b8]">
|
||||||
|
محتوای مرحله <b>«{STEP_LABELS[step - 1]}»</b> <br />
|
||||||
|
(در حال توسعه...)
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ width: isMobile ? "100%" : "100%", flexGrow: 1 }}>
|
||||||
|
<div className="w-full grid grid-cols-2 gap-4">
|
||||||
|
{renderStepContent(activeStep)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
ui/providers/ThemeRegitstry.tsx
Normal file
20
ui/providers/ThemeRegitstry.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// components/ThemeRegistry.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
||||||
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
|
import theme from "@/core/theme";
|
||||||
|
|
||||||
|
export default function ThemeRegistry({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user