first commit
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
rasaddam:
|
||||
build: .
|
||||
image: wixarm/inspection:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
16
dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM ghcr.io/eic/node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
RUN ls -la
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npx", "vite", "preview", "--host", "0.0.0.0", "--port", "3000"]
|
||||
29
eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
plugins: { js },
|
||||
extends: ["js/recommended"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
languageOptions: { globals: globals.browser },
|
||||
},
|
||||
tseslint.configs.recommended,
|
||||
pluginReact.configs.flat.recommended,
|
||||
{
|
||||
rules: {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"react/prop-types": "off",
|
||||
"@typescript-eslint/no-unused-expressions": "off",
|
||||
"prefer-const": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
},
|
||||
]);
|
||||
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/images/fav.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>سامانه بازرسی</title>
|
||||
</head>
|
||||
|
||||
<body dir="rtl" class="bg-white dark:bg-dark-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
7641
package-lock.json
generated
Normal file
50
package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "inspection_frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"@tanstack/react-router": "^1.119.0",
|
||||
"axios": "^1.9.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns-jalali": "^4.1.0-0",
|
||||
"framer-motion": "^12.10.5",
|
||||
"gsap": "^3.13.0",
|
||||
"jalali-moment": "^3.3.11",
|
||||
"leaflet": "^1.9.4",
|
||||
"lottie-react": "^2.4.1",
|
||||
"maplibre-gl": "^5.15.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"zod": "^3.25.28",
|
||||
"zustand": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.2.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.33.0",
|
||||
"vite": "^6.3.1",
|
||||
"vite-plugin-svgr": "^4.3.0"
|
||||
}
|
||||
}
|
||||
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
34
src/App.css
Normal file
@@ -0,0 +1,34 @@
|
||||
@import url("./assets/fonts/fonts.css");
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "iranyekan", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
|
||||
"Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* zoom: 90%; */
|
||||
overflow-x: hidden;
|
||||
font-family: "iranyekan" !important;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.Toastify__toast {
|
||||
font-family: "iranyekan" !important;
|
||||
}
|
||||
|
||||
.dark-scrollbar {
|
||||
scrollbar-color: #323232 transparent;
|
||||
}
|
||||
|
||||
.light-scrollbar {
|
||||
scrollbar-color: #d9d9d9 transparent;
|
||||
}
|
||||
|
||||
159
src/App.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo, useCallback } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
import {
|
||||
useUserProfileStore,
|
||||
useUserStore,
|
||||
} from "./context/zustand-store/userStore";
|
||||
import { makeRouter } from "./routes/routes";
|
||||
import { useDarkMode } from "./hooks/useDarkMode";
|
||||
import { ItemWithSubItems } from "./types/userPermissions";
|
||||
import { useFetchProfile } from "./hooks/useFetchProfile";
|
||||
import { getInspectionMenuItems } from "./screen/SideBar";
|
||||
|
||||
const versionNumber = "/src/version.txt";
|
||||
import "./index.css";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import { checkIsMobile } from "./utils/checkIsMobile";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function AppContent() {
|
||||
const auth = useUserStore((s) => s.auth);
|
||||
const { profile } = useUserProfileStore();
|
||||
const { getProfile } = useFetchProfile();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth && !profile) {
|
||||
getProfile();
|
||||
}
|
||||
}, [auth, profile, getProfile]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const auth = useUserStore((s) => s.auth);
|
||||
const { profile } = useUserProfileStore();
|
||||
const [isDark] = useDarkMode();
|
||||
|
||||
const menuItems: ItemWithSubItems[] = useMemo(() => {
|
||||
const userPermissions = profile?.permissions || [];
|
||||
|
||||
const permissionsArray = Array.isArray(userPermissions)
|
||||
? userPermissions.filter((p): p is string => typeof p === "string")
|
||||
: [];
|
||||
return getInspectionMenuItems(permissionsArray);
|
||||
}, [profile?.permissions]);
|
||||
|
||||
const router = useMemo(() => {
|
||||
try {
|
||||
const newRouter = makeRouter(auth ?? null);
|
||||
if (!newRouter) {
|
||||
console.error("Router creation returned null");
|
||||
return makeRouter(null);
|
||||
}
|
||||
return newRouter;
|
||||
} catch (error) {
|
||||
console.error("Router creation error:", error);
|
||||
|
||||
try {
|
||||
return makeRouter(null);
|
||||
} catch (fallbackError) {
|
||||
console.error("Fallback router creation failed:", fallbackError);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}, [auth, menuItems]);
|
||||
|
||||
const hardRefresh = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("refresh", Date.now().toString());
|
||||
window.location.href = url.toString();
|
||||
}, []);
|
||||
|
||||
const runWhenIdle = useCallback((fn: () => void) => {
|
||||
const ric = (window as any).requestIdleCallback;
|
||||
if (typeof ric === "function") {
|
||||
ric(fn);
|
||||
} else {
|
||||
setTimeout(fn, 300);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
const controller = new AbortController();
|
||||
const checkVersion = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
fetch(versionNumber + `?_=${Date.now()}`, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
})
|
||||
.then((res) => res.text())
|
||||
.then(async (txt) => {
|
||||
if (aborted) return;
|
||||
const latest = txt.trim();
|
||||
const stored = localStorage.getItem("AppVersion");
|
||||
if (latest && latest !== stored) {
|
||||
localStorage.setItem("AppVersion", latest);
|
||||
const clearAndReload = async () => {
|
||||
if ("caches" in window) {
|
||||
const names = await caches.keys();
|
||||
for (const n of names) {
|
||||
await caches.delete(n).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
setTimeout(hardRefresh, 200);
|
||||
};
|
||||
runWhenIdle(clearAndReload);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
runWhenIdle(checkVersion);
|
||||
return () => {
|
||||
aborted = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [hardRefresh, runWhenIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("refresh")) {
|
||||
url.searchParams.delete("refresh");
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
url.pathname + url.search
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!router) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">در حال بارگذاری...</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark ? "dark:bg-dark-900 dark-scrollbar" : "bg-white light-scrollbar"
|
||||
}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContent />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
<ToastContainer position="bottom-right" rtl={true} theme="light" />
|
||||
{checkIsMobile() && <div className="h-20"></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/Pages/Auth.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Login from "../partials/auth/Login";
|
||||
import authBg from "../assets/images/auth-bg.png";
|
||||
|
||||
export const Auth = () => {
|
||||
return (
|
||||
<div
|
||||
className="relative flex items-center justify-center min-h-screen h-screen overflow-y-auto bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${authBg})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundAttachment: "fixed",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/10 dark:bg-black/30" />
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative z-10 w-full max-w-md px-4 py-4"
|
||||
>
|
||||
<Login />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
src/Pages/AutoLogin.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
const AutoLogin = () => {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">ورود خودکار</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
صفحه ورود خودکار - در حال توسعه
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoLogin;
|
||||
510
src/Pages/LadingInfo.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import React, { useState } from "react";
|
||||
import { Grid } from "../components/Grid/Grid";
|
||||
import Textfield from "../components/Textfeild/Textfeild";
|
||||
import DatePicker from "../components/date-picker/DatePicker";
|
||||
import { useApiMutation } from "../utils/useApiRequest";
|
||||
import { motion, AnimatePresence, Variants } from "framer-motion";
|
||||
import Typography from "../components/Typography/Typography";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import {
|
||||
CalendarIcon,
|
||||
IdentificationIcon,
|
||||
UserIcon,
|
||||
MapPinIcon,
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Button from "../components/Button/Button";
|
||||
import moment from "jalali-moment";
|
||||
|
||||
interface KalaItem {
|
||||
gOODHSCODE: number;
|
||||
gOODCODE: number;
|
||||
wEIGHT: number;
|
||||
pACKINGTYPECODE: number;
|
||||
gOODVALUE: number;
|
||||
iRANCODE: string;
|
||||
kALADESC: string;
|
||||
}
|
||||
|
||||
interface LadingData {
|
||||
bARNAMEHID: number;
|
||||
bARNUMBER: number;
|
||||
bARSERIAL: string;
|
||||
iSSUDATE: string;
|
||||
sENDERNAME: string;
|
||||
sENDERLASTNAME: string;
|
||||
sENDERNATIONALCODE: string;
|
||||
sENDERPOSTALCODE: string;
|
||||
rCIEVERNAME: string;
|
||||
rECIEVERLASTNAME: string;
|
||||
rECIEVERNATIONALCODE: string;
|
||||
rECIEVERPOSTALCODE: string;
|
||||
fIRSTDRIVERCARDID: string;
|
||||
sECONDDRIVERCARDID: string;
|
||||
fRIGHTERCARDID: string;
|
||||
lANDERTYPEID: number;
|
||||
lANDERCODE: number;
|
||||
sTATUS: string;
|
||||
cOST: number;
|
||||
kOOTAJNO: number;
|
||||
kOOTAJDATE: string;
|
||||
cUSTOMCODE: string;
|
||||
tRANSITTYPEID: number;
|
||||
cREATEDATE: string;
|
||||
tRACECODE: string;
|
||||
kalaList: KalaItem[];
|
||||
introduceTraceCode: string | null;
|
||||
cmpDesc: string;
|
||||
originCityDesc: string;
|
||||
destCityDesc: string;
|
||||
pLAQUE_ID: string;
|
||||
pLAQUE_SN: string;
|
||||
firstDriverName: string;
|
||||
firstDriverFamily: string;
|
||||
secondDriverName: string;
|
||||
secondDriverFamily: string;
|
||||
}
|
||||
|
||||
interface LadingResponse {
|
||||
status: boolean;
|
||||
statusCode: number;
|
||||
data: LadingData[];
|
||||
apiLogId: string;
|
||||
}
|
||||
|
||||
const LadingInfo: React.FC = () => {
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [postalCode, setPostalCode] = useState("");
|
||||
const [ladingData, setLadingData] = useState<LadingData[]>([]);
|
||||
const showToast = useToast();
|
||||
|
||||
const ladingMutation = useApiMutation<LadingResponse>({
|
||||
api: "ladinginfo",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
const convertToPersianDate = (gregorianDate: string): string => {
|
||||
if (!gregorianDate) return "";
|
||||
const jDate = moment(gregorianDate, "YYYY-MM-DD").locale("fa");
|
||||
return jDate.format("jYYYY/jMM/jDD");
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!startDate || !endDate) {
|
||||
showToast("لطفا تاریخ شروع و پایان را وارد کنید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!postalCode) {
|
||||
showToast("لطفا کد پستی را وارد کنید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (postalCode.length !== 10) {
|
||||
showToast("کد پستی باید 10 رقم باشد", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const persianStartDate = convertToPersianDate(startDate);
|
||||
const persianEndDate = convertToPersianDate(endDate);
|
||||
|
||||
const result = await ladingMutation.mutateAsync({
|
||||
start: persianStartDate,
|
||||
end: persianEndDate,
|
||||
postal: postalCode,
|
||||
});
|
||||
|
||||
if (result?.status && result?.data && result.data.length > 0) {
|
||||
setLadingData(result.data);
|
||||
} else {
|
||||
setLadingData([]);
|
||||
showToast("نتیجهای یافت نشد", "info");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Lading info error:", error);
|
||||
showToast(
|
||||
error?.response?.data?.message || "خطا در دریافت اطلاعات بارنامه",
|
||||
"error"
|
||||
);
|
||||
setLadingData([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePostalCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const numericValue = value.replace(/\D/g, "").slice(0, 10);
|
||||
setPostalCode(numericValue);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.95 },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
delay: i * 0.1,
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
},
|
||||
}),
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container column className="gap-4 p-4 max-w-6xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
className="w-full"
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
column
|
||||
className="gap-4 p-6 bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-600"
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
className="text-gray-900 dark:text-gray-100 font-bold mb-2"
|
||||
>
|
||||
دریافت اطلاعات بارنامه
|
||||
</Typography>
|
||||
|
||||
<Grid container className="gap-4 items-end">
|
||||
<Grid container className="flex-1" column>
|
||||
<DatePicker
|
||||
label="تاریخ شروع"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container className="flex-1" column>
|
||||
<DatePicker
|
||||
label="تاریخ پایان"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container className="flex-1" column>
|
||||
<Textfield
|
||||
placeholder="کد پستی"
|
||||
value={postalCode}
|
||||
onChange={handlePostalCodeChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={ladingMutation.isPending}
|
||||
className="px-6 py-2.5"
|
||||
>
|
||||
{ladingMutation.isPending ? "در حال جستجو..." : "جستجو"}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{ladingData.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="w-full"
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="mb-4 text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
نتایج جستجو ({ladingData.length})
|
||||
</Typography>
|
||||
|
||||
<Grid container column className="gap-6">
|
||||
{ladingData.map((lading, index) => (
|
||||
<motion.div
|
||||
key={lading.bARNAMEHID}
|
||||
custom={index}
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-600 p-6 hover:shadow-xl transition-shadow">
|
||||
<Grid container column className="gap-4">
|
||||
<div className="flex items-start justify-between pb-4 border-b border-gray-200 dark:border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="h6"
|
||||
className="text-gray-900 dark:text-gray-100 font-bold"
|
||||
>
|
||||
بارنامه ردیف {index + 1}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<CalendarIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
تاریخ صدور
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{lading.iSSUDATE}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<DocumentTextIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره بارنامه
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{lading.bARNUMBER}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<MapPinIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شهر مبدا
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{lading.originCityDesc}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<MapPinIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شهر مقصد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{lading.destCityDesc}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<UserIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام فرستنده
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{lading.sENDERNAME} {lading.sENDERLASTNAME}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<UserIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام گیرنده
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{lading.rCIEVERNAME} {lading.rECIEVERLASTNAME}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<IdentificationIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
کدرهگیری
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium break-all"
|
||||
>
|
||||
{lading.tRACECODE}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<IdentificationIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره پلاک
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{lading.pLAQUE_ID}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<IdentificationIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
سریال پلاک
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{lading.pLAQUE_SN}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<UserIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام راننده
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{lading.firstDriverName}{" "}
|
||||
{lading.firstDriverFamily}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lading.kalaList && lading.kalaList.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<DocumentTextIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
لیست کالا
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-dark-700">
|
||||
<th className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
ردیف
|
||||
</th>
|
||||
<th className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
نام کالا
|
||||
</th>
|
||||
<th className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
وزن
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lading.kalaList.map((kala, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className="hover:bg-gray-50 dark:hover:bg-dark-700"
|
||||
>
|
||||
<td className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 text-center">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 text-center">
|
||||
{kala.kALADESC}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 text-center">
|
||||
{kala.wEIGHT.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default LadingInfo;
|
||||
1097
src/Pages/MainPage.tsx
Normal file
182
src/Pages/Menu.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useUserProfileStore } from "../context/zustand-store/userStore";
|
||||
import { getFaPermissions } from "../utils/getFaPermissions";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
UsersIcon,
|
||||
DocumentTextIcon,
|
||||
ChartBarIcon,
|
||||
ChevronDownIcon,
|
||||
UserIcon,
|
||||
GlobeAsiaAustraliaIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ItemWithSubItems } from "../types/userPermissions";
|
||||
import { checkMenuPermission } from "../utils/checkMenuPermission";
|
||||
|
||||
const getInspectionMenuItems = (
|
||||
permissions: string[] = []
|
||||
): ItemWithSubItems[] => {
|
||||
const items: ItemWithSubItems[] = [];
|
||||
|
||||
if (checkMenuPermission("nationalinfo", permissions)) {
|
||||
items.push({
|
||||
en: "nationalinfo",
|
||||
fa: "اطلاعات",
|
||||
icon: () => <UserIcon className="w-6 h-6" />,
|
||||
subItems: [
|
||||
{
|
||||
name: "nationalinfo",
|
||||
path: "/nationalinfo",
|
||||
component: () => import("./NationalInfo"),
|
||||
},
|
||||
{
|
||||
name: "ladinginfo",
|
||||
path: "/ladinginfo",
|
||||
component: () => import("./LadingInfo"),
|
||||
},
|
||||
{
|
||||
name: "veterinarytransfer",
|
||||
path: "/veterinarytransfer",
|
||||
component: () => import("./VeterinaryTransfer"),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (checkMenuPermission("users", permissions)) {
|
||||
items.push({
|
||||
en: "users",
|
||||
fa: "کاربران",
|
||||
icon: () => <UsersIcon className="w-6 h-6" />,
|
||||
subItems: [
|
||||
{
|
||||
name: "users",
|
||||
path: "/users",
|
||||
component: () => import("./Users"),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (checkMenuPermission("inspections", permissions)) {
|
||||
items.push({
|
||||
en: "inspections",
|
||||
fa: "سوابق بازرسی",
|
||||
icon: () => <DocumentTextIcon className="w-6 h-6" />,
|
||||
subItems: [
|
||||
{
|
||||
name: "inspections",
|
||||
path: "/inspections",
|
||||
component: () => import("./UserInspections"),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (checkMenuPermission("statics", permissions)) {
|
||||
items.push({
|
||||
en: "statics",
|
||||
fa: "آمار",
|
||||
icon: () => <ChartBarIcon className="w-6 h-6" />,
|
||||
subItems: [
|
||||
{
|
||||
name: "statics",
|
||||
path: "/statics",
|
||||
component: () => import("./Statics"),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
export const Menu = () => {
|
||||
const { profile } = useUserProfileStore();
|
||||
const navigate = useNavigate();
|
||||
const menuItems = getInspectionMenuItems(profile?.permissions || []);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
const toggleSubmenu = (index: number) => {
|
||||
setOpenIndex((prev) => (prev === index ? null : index));
|
||||
};
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6 text-gray-900 dark:text-white">
|
||||
منو
|
||||
</h1>
|
||||
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-primary-500 hover:bg-primary-600 cursor-pointer dark:bg-primary-800 dark:hover:bg-primary-700 text-white font-semibold text-sm transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<GlobeAsiaAustraliaIcon className="w-5 h-5" />
|
||||
<span>مشاهده نقشه</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{menuItems.map(({ fa, icon: Icon, subItems }, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white dark:bg-dark-700 rounded-lg shadow-sm border border-gray-200 dark:border-dark-600 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSubmenu(index)}
|
||||
className="w-full flex justify-between items-center px-4 py-3 text-right hover:bg-gray-50 dark:hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-gray-600 dark:text-primary-100">
|
||||
<Icon />
|
||||
</div>
|
||||
<span className="text-gray-900 dark:text-white font-semibold">
|
||||
{fa}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={`w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-300 ${
|
||||
openIndex === index ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{openIndex === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pb-2 space-y-1 border-t border-gray-200 dark:border-dark-600">
|
||||
{subItems
|
||||
.filter((item) => !item?.path.includes("$"))
|
||||
.map((sub, subIndex) => (
|
||||
<button
|
||||
key={subIndex}
|
||||
onClick={() => navigate({ to: sub.path })}
|
||||
className={`w-full text-right px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
currentPath === sub.path
|
||||
? "bg-primary-100 dark:bg-dark-500 text-primary-700 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-600"
|
||||
}`}
|
||||
>
|
||||
{getFaPermissions(sub.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
988
src/Pages/NationalInfo.tsx
Normal file
@@ -0,0 +1,988 @@
|
||||
import React, { useState } from "react";
|
||||
import { Grid } from "../components/Grid/Grid";
|
||||
import Textfield from "../components/Textfeild/Textfeild";
|
||||
import { useApiMutation } from "../utils/useApiRequest";
|
||||
import { motion, AnimatePresence, Variants } from "framer-motion";
|
||||
import Typography from "../components/Typography/Typography";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import {
|
||||
UserIcon,
|
||||
IdentificationIcon,
|
||||
PhoneIcon,
|
||||
CalendarIcon,
|
||||
CreditCardIcon,
|
||||
InformationCircleIcon,
|
||||
ClipboardDocumentIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Button from "../components/Button/Button";
|
||||
import Checkbox from "../components/CheckBox/CheckBox";
|
||||
|
||||
interface PersonData {
|
||||
_id: string;
|
||||
nationalcode: string;
|
||||
name: string;
|
||||
family: string;
|
||||
father: string;
|
||||
birthdate: string;
|
||||
mobile: string[];
|
||||
info: string[];
|
||||
card: string[];
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
data: PersonData[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface PersonRegistryData {
|
||||
registeryOfficePersonId: number;
|
||||
nationalCode: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fatherName: string;
|
||||
supervisorNationalCode: string | null;
|
||||
gender: boolean;
|
||||
identityNo: string;
|
||||
identitySeries: string;
|
||||
identitySerial: string;
|
||||
birthDate: string;
|
||||
birthDateUnix: number;
|
||||
isLive: boolean;
|
||||
deathDate: string | null;
|
||||
city: string;
|
||||
vilage: string | null;
|
||||
town: string | null;
|
||||
errorCode: number;
|
||||
errorDescription: string | null;
|
||||
createdOn: string;
|
||||
errorCodeSpecified: boolean | null;
|
||||
birthDateDay: number;
|
||||
birthDateMonth: number;
|
||||
birthDateYear: number;
|
||||
}
|
||||
|
||||
interface PersonRegistryResponse {
|
||||
status: boolean;
|
||||
statusCode: number;
|
||||
data: PersonRegistryData;
|
||||
apiLogId: string;
|
||||
}
|
||||
|
||||
interface UnitRegistryData {
|
||||
counter: number;
|
||||
serviceType: number;
|
||||
title: string;
|
||||
isicname: string;
|
||||
fullname: string;
|
||||
state: string;
|
||||
city: string;
|
||||
address: string;
|
||||
licenseNumber: string;
|
||||
licenseExpireDate: string;
|
||||
licenseType: string;
|
||||
licenseStatus: string;
|
||||
layerTwo: {
|
||||
phonenumber: string;
|
||||
unionName: string;
|
||||
postalcode: string;
|
||||
nationalcode: string;
|
||||
mobilenumber: string;
|
||||
nationalId: string | null;
|
||||
corporationName: string | null;
|
||||
licenseIssueDate: string;
|
||||
jobType: string;
|
||||
hasPartner: string;
|
||||
hasSteward: string;
|
||||
isForeigner: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UnitRegistryResponse {
|
||||
status?: boolean;
|
||||
statusCode?: number;
|
||||
data?: UnitRegistryData[];
|
||||
}
|
||||
|
||||
const NationalInfo: React.FC = () => {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<PersonData[]>([]);
|
||||
const [personRegistryChecked, setPersonRegistryChecked] = useState(false);
|
||||
const [unitRegistryChecked, setUnitRegistryChecked] = useState(false);
|
||||
const [personRegistryData, setPersonRegistryData] =
|
||||
useState<PersonRegistryData | null>(null);
|
||||
const [unitRegistryData, setUnitRegistryData] = useState<UnitRegistryData[]>(
|
||||
[]
|
||||
);
|
||||
const showToast = useToast();
|
||||
|
||||
const searchMutation = useApiMutation<ApiResponse>({
|
||||
api: "people_info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
const personRegistryMutation = useApiMutation<PersonRegistryResponse>({
|
||||
api: "national-documents",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
const unitRegistryMutation = useApiMutation<UnitRegistryResponse>({
|
||||
api: "national-documents",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
const trimmedValue = searchValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
showToast("لطفا مقدار جستجو را وارد کنید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let searchField: "mobile" | "nationalcode";
|
||||
if (trimmedValue.length === 11 && trimmedValue.startsWith("09")) {
|
||||
searchField = "mobile";
|
||||
} else if (trimmedValue.length === 10) {
|
||||
searchField = "nationalcode";
|
||||
} else {
|
||||
showToast(
|
||||
"لطفا 10 رقم (کد ملی) یا 11 رقم (شماره موبایل) وارد کنید",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await searchMutation.mutateAsync({
|
||||
searchfield: searchField,
|
||||
value: trimmedValue,
|
||||
});
|
||||
|
||||
if (result?.data && result.data.length > 0) {
|
||||
setSearchResults(result.data);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
showToast("نتیجهای یافت نشد", "info");
|
||||
}
|
||||
|
||||
let nationalCodeForRegistry: string | null = null;
|
||||
|
||||
if (trimmedValue.length === 10) {
|
||||
nationalCodeForRegistry = trimmedValue;
|
||||
} else if (result?.data && result.data.length > 0) {
|
||||
const personWithNationalCode = result.data.find(
|
||||
(person) => person.nationalcode && person.nationalcode.length === 10
|
||||
);
|
||||
if (personWithNationalCode) {
|
||||
nationalCodeForRegistry = personWithNationalCode.nationalcode;
|
||||
}
|
||||
}
|
||||
|
||||
if (personRegistryChecked) {
|
||||
if (!nationalCodeForRegistry) {
|
||||
showToast("کد ملی برای استعلام ثبت احوال یافت نشد", "error");
|
||||
setPersonRegistryData(null);
|
||||
} else {
|
||||
try {
|
||||
const personResult = await personRegistryMutation.mutateAsync({
|
||||
type: "person",
|
||||
info: nationalCodeForRegistry,
|
||||
});
|
||||
if (personResult?.status && personResult?.data) {
|
||||
setPersonRegistryData(personResult.data);
|
||||
} else {
|
||||
setPersonRegistryData(null);
|
||||
showToast("نتیجهای از ثبت احوال یافت نشد", "info");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Person registry error:", error);
|
||||
showToast(
|
||||
error?.response?.data?.message || "خطا در استعلام ثبت احوال",
|
||||
"error"
|
||||
);
|
||||
setPersonRegistryData(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setPersonRegistryData(null);
|
||||
}
|
||||
|
||||
if (unitRegistryChecked) {
|
||||
if (!nationalCodeForRegistry) {
|
||||
showToast("کد ملی برای استعلام واحد صنفی یافت نشد", "error");
|
||||
setUnitRegistryData([]);
|
||||
} else {
|
||||
try {
|
||||
const unitResult = await unitRegistryMutation.mutateAsync({
|
||||
type: "guild",
|
||||
info: nationalCodeForRegistry,
|
||||
});
|
||||
const unitsCandidate =
|
||||
unitResult?.data ?? (Array.isArray(unitResult) ? unitResult : []);
|
||||
setUnitRegistryData(
|
||||
Array.isArray(unitsCandidate) ? unitsCandidate : []
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Unit registry error:", error);
|
||||
showToast(
|
||||
error?.response?.data?.message || "خطا در استعلام واحد صنفی",
|
||||
"error"
|
||||
);
|
||||
setUnitRegistryData([]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setUnitRegistryData([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Search error:", error);
|
||||
showToast(error?.response?.data?.message || "خطا در جستجو", "error");
|
||||
setSearchResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const numericValue = value.replace(/\D/g, "").slice(0, 11);
|
||||
setSearchValue(numericValue);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayName = (person: PersonData) => {
|
||||
if (person.family && person.name.includes(person.family)) {
|
||||
return person.name;
|
||||
}
|
||||
return person.family ? `${person.name} ${person.family}` : person.name;
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.95 },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
delay: i * 0.1,
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
},
|
||||
}),
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
const useClipboard = () => {
|
||||
const showToast = useToast();
|
||||
const copyToClipboard = async (text: string, label?: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(label ? `${label} کپی شد` : "کپی شد", "success");
|
||||
} catch {
|
||||
showToast("خطا در کپی کردن به کلیپبورد", "error");
|
||||
}
|
||||
};
|
||||
return copyToClipboard;
|
||||
};
|
||||
|
||||
const CopyIconButton: React.FC<{
|
||||
text: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}> = ({ text, label, className }) => {
|
||||
const copyToClipboard = useClipboard();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(text, label)}
|
||||
title="کپی به کلیپبورد"
|
||||
className={
|
||||
className ??
|
||||
"ml-2 inline-flex items-center justify-center p-1 rounded hover:bg-gray-100 dark:hover:bg-dark-600 transition"
|
||||
}
|
||||
>
|
||||
<ClipboardDocumentIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container column className="gap-4 p-4 max-w-6xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
className="w-full"
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
column
|
||||
className="gap-4 p-6 bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-600"
|
||||
>
|
||||
<Grid container className="gap-2 items-center justify-center">
|
||||
<Grid container className="flex-1" column>
|
||||
<Textfield
|
||||
placeholder="کد ملی یا شماره موبایل"
|
||||
value={searchValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={
|
||||
searchMutation.isPending ||
|
||||
personRegistryMutation.isPending ||
|
||||
unitRegistryMutation.isPending
|
||||
}
|
||||
className="px-6 py-2.5"
|
||||
>
|
||||
{searchMutation.isPending ||
|
||||
personRegistryMutation.isPending ||
|
||||
unitRegistryMutation.isPending
|
||||
? "در حال جستجو..."
|
||||
: "جستجو"}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره موبایل باید با 09 آغاز شود
|
||||
</Typography>
|
||||
<Grid container className="gap-4 justify-center">
|
||||
<Checkbox
|
||||
label="استعلام از ثبت احوال"
|
||||
checked={personRegistryChecked}
|
||||
onChange={(e) => setPersonRegistryChecked(e.target.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label="استعلام واحد صنفی"
|
||||
checked={unitRegistryChecked}
|
||||
onChange={(e) => setUnitRegistryChecked(e.target.checked)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{searchResults.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="w-full"
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="mb-4 text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
نتایج جستجو ({searchResults.length})
|
||||
</Typography>
|
||||
|
||||
<Grid container column className="gap-4">
|
||||
{searchResults.map((person, index) => (
|
||||
<motion.div
|
||||
key={person._id}
|
||||
custom={index}
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-600 p-6 hover:shadow-xl transition-shadow">
|
||||
<Grid container column className="gap-4">
|
||||
<div className="flex items-start justify-between pb-4 border-b border-gray-200 dark:border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<UserIcon className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="h6"
|
||||
className="text-gray-900 dark:text-gray-100 font-bold flex items-center"
|
||||
>
|
||||
{getDisplayName(person)}
|
||||
{/* کپی نام کامل */}
|
||||
<CopyIconButton
|
||||
text={getDisplayName(person)}
|
||||
label="نام"
|
||||
className="ml-2"
|
||||
/>
|
||||
</Typography>
|
||||
{person.father && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-600 dark:text-gray-400 mt-1 flex items-center"
|
||||
>
|
||||
فرزند {person.father}
|
||||
<CopyIconButton
|
||||
text={person.father}
|
||||
label="نام پدر"
|
||||
className="ml-2"
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<IdentificationIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
کد ملی
|
||||
</Typography>
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{person.nationalcode}
|
||||
</Typography>
|
||||
{person.nationalcode && (
|
||||
<CopyIconButton
|
||||
text={person.nationalcode}
|
||||
label="کد ملی"
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{person.birthdate && (
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<CalendarIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
تاریخ تولد
|
||||
</Typography>
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{person.birthdate}
|
||||
</Typography>
|
||||
<CopyIconButton
|
||||
text={person.birthdate}
|
||||
label="تاریخ تولد"
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{person.mobile && person.mobile.length > 0 && (
|
||||
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<PhoneIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs mb-1"
|
||||
>
|
||||
شماره تلفن
|
||||
</Typography>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{person.mobile.map((mobile, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 rounded text-sm font-medium"
|
||||
>
|
||||
{mobile}
|
||||
<CopyIconButton
|
||||
text={mobile}
|
||||
label="شماره تلفن"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{person.card && person.card.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CreditCardIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
کارت های بانکی
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{person.card.map((card, idx) => (
|
||||
<motion.span
|
||||
key={idx}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="inline-flex items-center gap-1 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded-lg text-sm font-mono border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
{card}
|
||||
<CopyIconButton
|
||||
text={card}
|
||||
label="شماره کارت"
|
||||
/>
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{person.info && person.info.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
اطلاعات جمع آوری شده توسط هوش مصنوعی
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{person.info.map((info, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg border-r-4 border-primary-500 flex items-start justify-between"
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{info}
|
||||
</Typography>
|
||||
<CopyIconButton
|
||||
text={info}
|
||||
label="اطلاعات"
|
||||
className="ml-2"
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{personRegistryData && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="w-full"
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="mb-4 text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
استعلام از ثبت احوال
|
||||
</Typography>
|
||||
<div className="bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-600 p-6">
|
||||
<Grid container column className="gap-4">
|
||||
<div className="flex items-start justify-between pb-4 border-b border-gray-200 dark:border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<UserIcon className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="h6"
|
||||
className="text-gray-900 dark:text-gray-100 font-bold"
|
||||
>
|
||||
{personRegistryData.firstName}{" "}
|
||||
{personRegistryData.lastName}
|
||||
</Typography>
|
||||
{personRegistryData.fatherName && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-600 dark:text-gray-400 mt-1"
|
||||
>
|
||||
فرزند {personRegistryData.fatherName}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<IdentificationIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
کد ملی
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{personRegistryData.nationalCode}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{personRegistryData.birthDate && (
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<CalendarIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
تاریخ تولد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{personRegistryData.birthDate}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{personRegistryData.city && (
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شهر
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{personRegistryData.city}
|
||||
{personRegistryData.vilage &&
|
||||
` - ${personRegistryData.vilage}`}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
جنسیت
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{personRegistryData.gender ? "مرد" : "زن"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
وضعیت حیات
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{personRegistryData.isLive ? "زنده" : "فوت شده"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Grid>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{unitRegistryData.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="w-full"
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="mb-4 text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
استعلام واحد صنفی ({unitRegistryData.length})
|
||||
</Typography>
|
||||
<Grid container column className="gap-4">
|
||||
{unitRegistryData.map((unit, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
custom={index}
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-600 p-6 hover:shadow-xl transition-shadow">
|
||||
<Grid container column className="gap-4">
|
||||
<div className="flex items-start justify-between pb-4 border-b border-gray-200 dark:border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<CreditCardIcon className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="h6"
|
||||
className="text-gray-900 dark:text-gray-100 font-bold"
|
||||
>
|
||||
{unit.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-600 dark:text-gray-400 mt-1"
|
||||
>
|
||||
{unit.fullname}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<IdentificationIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
کد ملی
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{unit.layerTwo.nationalcode}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<PhoneIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره موبایل
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{unit.layerTwo.mobilenumber}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<PhoneIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره تلفن
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{unit.layerTwo.phonenumber}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<CreditCardIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره پروانه
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{unit.licenseNumber}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نوع پروانه
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{unit.licenseType}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
وضعیت پروانه
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{unit.licenseStatus}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<CalendarIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
تاریخ انقضا
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{unit.licenseExpireDate}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شهر / استان
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{unit.city} - {unit.state}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unit.address && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
آدرس
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg"
|
||||
>
|
||||
{unit.address}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unit.layerTwo.unionName && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
نام اتحادیه
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg"
|
||||
>
|
||||
{unit.layerTwo.unionName}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default NationalInfo;
|
||||
12
src/Pages/NotFound.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">404</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">صفحه یافت نشد</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
12
src/Pages/Statics.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import { Grid } from "../components/Grid/Grid";
|
||||
import Typography from "../components/Typography/Typography";
|
||||
const Statics: React.FC = () => {
|
||||
return (
|
||||
<Grid container column>
|
||||
<Typography variant="body1">این بخش در دست توسعه است</Typography>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default Statics;
|
||||
142
src/Pages/UserInspections.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Grid } from "../components/Grid/Grid";
|
||||
import { useUserProfileStore } from "../context/zustand-store/userStore";
|
||||
import { useApiRequest } from "../utils/useApiRequest";
|
||||
import { formatJustDate, formatJustTime } from "../utils/formatTime";
|
||||
import Typography from "../components/Typography/Typography";
|
||||
import { EyeIcon } from "@heroicons/react/24/outline";
|
||||
import { useModalStore } from "../context/zustand-store/appStore";
|
||||
import { MainInfractions } from "../partials/main/MainInfractions";
|
||||
import MainSubmitInspection from "../partials/main/MainSubmitInspection";
|
||||
import { useDrawerStore } from "../context/zustand-store/appStore";
|
||||
import Table from "../components/Table/Table";
|
||||
import { Popover } from "../components/PopOver/PopOver";
|
||||
import { Tooltip } from "../components/Tooltip/Tooltip";
|
||||
import Button from "../components/Button/Button";
|
||||
import { DeleteButtonForPopOver } from "../components/PopOverButtons/PopOverButtons";
|
||||
|
||||
const UserInspections: React.FC = () => {
|
||||
const { profile } = useUserProfileStore();
|
||||
const { openModal } = useModalStore();
|
||||
const { openDrawer } = useDrawerStore();
|
||||
const [tableData, setTableData] = useState<any[][]>([]);
|
||||
|
||||
const { data: inspectionsData, refetch } = useApiRequest({
|
||||
api: `/userinspections/${profile?._id || profile?.Id}`,
|
||||
method: "get",
|
||||
queryKey: ["userinspections"],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inspectionsData) {
|
||||
const d = inspectionsData.map((field: any, i: number) => {
|
||||
return [
|
||||
i + 1,
|
||||
`${formatJustDate(field?.createdAt)} ساعت ${formatJustTime(
|
||||
field?.createdAt
|
||||
)}`,
|
||||
field.inspectors?.map((item: any, idx: number) => (
|
||||
<Typography key={idx} variant="body2" className="text-xs">
|
||||
{item.fullname}
|
||||
</Typography>
|
||||
)) || "-",
|
||||
field?.license_type,
|
||||
field?.issuer,
|
||||
field?.registration_number,
|
||||
field?.ownership_type,
|
||||
field?.unit_type,
|
||||
field?.economic_code,
|
||||
field?.document_number,
|
||||
field?.infractions?.length ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography variant="body2" className="text-red-600">
|
||||
دارد
|
||||
</Typography>
|
||||
<button
|
||||
onClick={() => {
|
||||
openModal({
|
||||
title: "مشاهده تخلفات",
|
||||
content: (
|
||||
<MainInfractions data={field} handleUpdate={refetch} />
|
||||
),
|
||||
});
|
||||
}}
|
||||
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Typography variant="body2" className="text-blue-600">
|
||||
ندارد
|
||||
</Typography>
|
||||
),
|
||||
parseInt(field?.violation_amount || "0")?.toLocaleString() !== "NaN"
|
||||
? parseInt(field?.violation_amount || "0")?.toLocaleString()
|
||||
: "-",
|
||||
parseInt(field?.plaintiff_damage || "0")?.toLocaleString() !== "NaN"
|
||||
? parseInt(field?.plaintiff_damage || "0")?.toLocaleString()
|
||||
: "-",
|
||||
field?.description || "-",
|
||||
<Popover key={i}>
|
||||
<Tooltip title="ویرایش" position="right">
|
||||
<Button
|
||||
variant="edit"
|
||||
access="submit"
|
||||
onClick={() => {
|
||||
openDrawer({
|
||||
title: "ویرایش بازرسی",
|
||||
content: (
|
||||
<MainSubmitInspection
|
||||
item={field}
|
||||
isEdit
|
||||
inspectId={field?._id || field?.Id}
|
||||
handleUpdate={refetch}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DeleteButtonForPopOver
|
||||
access="submit"
|
||||
api={`inspections/${field?._id || field?.Id}`}
|
||||
getData={refetch}
|
||||
/>
|
||||
</Popover>,
|
||||
];
|
||||
});
|
||||
setTableData(d);
|
||||
}
|
||||
}, [inspectionsData, profile]);
|
||||
|
||||
return (
|
||||
<Grid container column className="justify-center">
|
||||
<Grid container>
|
||||
<Table
|
||||
title="سوابق بازرسی من"
|
||||
columns={[
|
||||
"ردیف",
|
||||
"تاریخ بازرسی",
|
||||
"بازرسان همراه",
|
||||
"نوع پروانه کسب",
|
||||
"صادر کننده پروانه",
|
||||
"شماره ثبت",
|
||||
"نوع مالکیت",
|
||||
"نوع واحد",
|
||||
"کد اقتصادی",
|
||||
"شماره پرونده یا مجوز",
|
||||
"تخلف",
|
||||
"جمع کل تخلف",
|
||||
"جمع کل خسارت به شاکی",
|
||||
"توضیحات",
|
||||
"عملیات",
|
||||
]}
|
||||
rows={tableData}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInspections;
|
||||
212
src/Pages/UserProfile.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React from "react";
|
||||
import {
|
||||
PhoneIcon,
|
||||
MapPinIcon,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
ArrowLeftStartOnRectangleIcon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Grid } from "../components/Grid/Grid";
|
||||
import { useDarkMode } from "../hooks/useDarkMode";
|
||||
import clsx from "clsx";
|
||||
import { useModalStore } from "../context/zustand-store/appStore";
|
||||
import { Logout } from "../partials/auth/Logout";
|
||||
import { useUserProfileStore } from "../context/zustand-store/userStore";
|
||||
import { getFaProvince } from "../utils/getFaProvince";
|
||||
import { getFaCityName } from "../utils/getFaCityName";
|
||||
import userImage from "../assets/images/user.png";
|
||||
|
||||
interface ProfileCardProps {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string | null;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
const ProfileCard = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
show = true,
|
||||
}: ProfileCardProps) => {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Grid
|
||||
className={clsx(
|
||||
"group relative p-4 transition-all duration-500 hover:-translate-y-1 hover:scale-[1.01]"
|
||||
)}
|
||||
>
|
||||
<Grid className="relative flex items-start gap-4">
|
||||
<Grid
|
||||
className={clsx(
|
||||
"rounded-xl p-3 shadow-lg group-hover:shadow-xl transition-all duration-500 group-hover:scale-110",
|
||||
"bg-primary-600"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-white" />
|
||||
</Grid>
|
||||
<Grid className="flex flex-col min-w-0">
|
||||
<span className="text-xs text-gray-500 dark:text-white font-semibold uppercase tracking-wider mb-2">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-primary-600 font-bold text-sm leading-relaxed wrap-break-word">
|
||||
{value}
|
||||
</span>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const UserProfile: React.FC = () => {
|
||||
const [isDark, setIsDark] = useDarkMode();
|
||||
const { profile } = useUserProfileStore();
|
||||
|
||||
const { openModal } = useModalStore();
|
||||
|
||||
const getUserRole = (permissions: string[] = []): string => {
|
||||
if (permissions.includes("admin")) {
|
||||
return "بازرس ارشد";
|
||||
} else if (permissions.includes("submit")) {
|
||||
return "بازرس";
|
||||
} else {
|
||||
return "ناظر";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid className="min-h-screen bg-gray-50 dark:bg-dark-900">
|
||||
<div className="relative overflow-hidden bg-linear-to-br from-primary-500 via-primary-600 to-primary-700 dark:from-dark-800 dark:via-dark-700 dark:to-dark-800">
|
||||
<Grid className="relative px-6 py-12 md:px-12 md:py-8 items-center">
|
||||
<Grid className="mx-auto">
|
||||
<Grid className="flex flex-col lg:flex-row items-center gap-8 lg:gap-12">
|
||||
<Grid className="relative group">
|
||||
<Grid className="relative">
|
||||
<Grid className="relative bg-white/80 dark:bg-dark-600 backdrop-blur-xl rounded-full p-4 border border-white/20 shadow-2xl">
|
||||
<img
|
||||
src={userImage}
|
||||
alt="User"
|
||||
className="h-24 w-24 md:h-28 md:w-28 rounded-full object-cover drop-shadow-lg"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid className="flex-1 text-center lg:text-right">
|
||||
<Grid className="space-y-4">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white dark:text-dark-100 bg-clip-text drop-shadow-sm">
|
||||
{profile?.fullname || "کاربر"}
|
||||
</h1>
|
||||
|
||||
<Grid container className="w-auto gap-2 justify-start">
|
||||
{profile?.permissions && profile.permissions.length > 0 && (
|
||||
<Grid className="inline-flex items-center gap-2 bg-white dark:bg-dark-600 backdrop-blur-sm rounded-xl py-3 px-6 border border-primary-200/30">
|
||||
<span className="text-gray-600 dark:text-dark-100 font-semibold text-sm">
|
||||
{getUserRole(profile.permissions)}
|
||||
</span>
|
||||
</Grid>
|
||||
)}
|
||||
{profile?.province && (
|
||||
<Grid className="inline-flex items-center gap-2 bg-white dark:bg-dark-600 backdrop-blur-sm rounded-xl py-2 px-4 border border-emerald-200/30">
|
||||
<span className="text-gray-600 dark:text-dark-100 font-semibold text-sm">
|
||||
استان: {getFaProvince(profile.province)}
|
||||
</span>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid className="flex flex-col justify-center items-end gap-4">
|
||||
<button
|
||||
onClick={() => setIsDark(!isDark)}
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-3 rounded-2xl transition-all duration-500 cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<Grid
|
||||
className={clsx(
|
||||
"rounded-xl transition-all duration-500 group-hover:scale-110 bg-transparent"
|
||||
)}
|
||||
>
|
||||
{isDark ? (
|
||||
<SunIcon className="w-5 h-5 text-white dark:text-dark-100" />
|
||||
) : (
|
||||
<MoonIcon className="w-5 h-5 text-white dark:text-dark-100" />
|
||||
)}
|
||||
</Grid>
|
||||
<span className="text-white dark:text-dark-100 font-semibold text-sm">
|
||||
{isDark ? "حالت روشن" : "حالت تاریک"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
openModal({
|
||||
title: "از سامانه خارج میشوید؟",
|
||||
content: <Logout />,
|
||||
});
|
||||
}}
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-3 rounded-2xl transition-all duration-500 cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<Grid
|
||||
className={clsx(
|
||||
"rounded-xl transition-all duration-500 group-hover:scale-110 bg-transparent"
|
||||
)}
|
||||
>
|
||||
<ArrowLeftStartOnRectangleIcon className="h-5 w-5 text-white" />
|
||||
</Grid>
|
||||
<span className="text-white font-semibold text-sm">
|
||||
خروج از سامانه
|
||||
</span>
|
||||
</button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<Grid className="pb-8 mt-4">
|
||||
<Grid className="max-w-full mx-auto bg-white dark:bg-gray-900 p-4 rounded-2xl">
|
||||
<Grid className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-gray-600 bg-[#FFF2E5] p-2 rounded-xl">
|
||||
اطلاعات کاربری
|
||||
</span>
|
||||
<div className="flex-1 border-t-2 border-dotted border-primary-800"></div>
|
||||
</Grid>
|
||||
<Grid className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-6 gap-6 mt-2">
|
||||
<ProfileCard
|
||||
icon={UserIcon}
|
||||
label="نام کامل"
|
||||
value={profile?.fullname || "بدون نام"}
|
||||
/>
|
||||
|
||||
<ProfileCard
|
||||
icon={PhoneIcon}
|
||||
label="شماره موبایل"
|
||||
value={profile?.mobile || "بدون شماره موبایل"}
|
||||
/>
|
||||
|
||||
<ProfileCard
|
||||
icon={MapPinIcon}
|
||||
label="موقعیت"
|
||||
value={
|
||||
profile?.province
|
||||
? `${getFaProvince(profile.province)}${
|
||||
profile?.city ? `، ${getFaCityName(profile.city)}` : ""
|
||||
}`
|
||||
: "نامشخص"
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
99
src/Pages/Users.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Grid } from "../components/Grid/Grid";
|
||||
import { useUserProfileStore } from "../context/zustand-store/userStore";
|
||||
import { useApiRequest } from "../utils/useApiRequest";
|
||||
import Table from "../components/Table/Table";
|
||||
import Typography from "../components/Typography/Typography";
|
||||
import Button from "../components/Button/Button";
|
||||
import { useDrawerStore } from "../context/zustand-store/appStore";
|
||||
import { SubmitNewUser } from "../partials/users/SubmitNewUser";
|
||||
import { Popover } from "../components/PopOver/PopOver";
|
||||
import { DeleteButtonForPopOver } from "../components/PopOverButtons/PopOverButtons";
|
||||
import { getFaPermissions } from "../utils/getFaPermissions";
|
||||
import { getFaProvince } from "../utils/getFaProvince";
|
||||
import { getFaCityName } from "../utils/getFaCityName";
|
||||
|
||||
const Users: React.FC = () => {
|
||||
const { profile } = useUserProfileStore();
|
||||
const { openDrawer } = useDrawerStore();
|
||||
const [tableData, setTableData] = useState<any[][]>([]);
|
||||
|
||||
const { data: usersData, refetch } = useApiRequest({
|
||||
api: `/users/${profile?.province || "hamedan"}`,
|
||||
method: "get",
|
||||
queryKey: ["users", profile?.province],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (usersData) {
|
||||
const d = usersData.map((item: any, i: number) => {
|
||||
return [
|
||||
i + 1,
|
||||
item?.fullname || "-",
|
||||
item?.mobile || "-",
|
||||
item?.permissions?.map((perm: string, idx: number) => (
|
||||
<Typography key={idx} variant="body2" className="text-xs">
|
||||
{getFaPermissions(perm)}
|
||||
</Typography>
|
||||
)) || "-",
|
||||
getFaProvince(item?.province || ""),
|
||||
getFaCityName(item?.city || ""),
|
||||
item?.mobile === profile?.mobile ? (
|
||||
<Typography variant="body2" className="text-gray-400">
|
||||
-
|
||||
</Typography>
|
||||
) : (
|
||||
<Popover key={i}>
|
||||
<DeleteButtonForPopOver
|
||||
access="add"
|
||||
api={`users/${item?._id || item?.Id}`}
|
||||
getData={refetch}
|
||||
/>
|
||||
</Popover>
|
||||
),
|
||||
];
|
||||
});
|
||||
setTableData(d);
|
||||
}
|
||||
}, [usersData, profile]);
|
||||
|
||||
return (
|
||||
<Grid container column className="justify-center">
|
||||
<Grid>
|
||||
<Button
|
||||
variant="submit"
|
||||
access="add"
|
||||
onClick={() => {
|
||||
openDrawer({
|
||||
title: "ثبت کاربر جدید",
|
||||
content: (
|
||||
<SubmitNewUser
|
||||
province={profile?.province || ""}
|
||||
onSuccess={refetch}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
ثبت کاربر جدید
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Table
|
||||
title="کاربران"
|
||||
columns={[
|
||||
"ردیف",
|
||||
"نام کامل",
|
||||
"شماره موبایل",
|
||||
"دسترسیها",
|
||||
"استان",
|
||||
"شهر",
|
||||
"عملیات",
|
||||
]}
|
||||
rows={tableData}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
876
src/Pages/VeterinaryTransfer.tsx
Normal file
@@ -0,0 +1,876 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence, Variants } from "framer-motion";
|
||||
import Typography from "../components/Typography/Typography";
|
||||
import { Grid } from "../components/Grid/Grid";
|
||||
import Textfield from "../components/Textfeild/Textfeild";
|
||||
import Button from "../components/Button/Button";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { useApiMutation } from "../utils/useApiRequest";
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
IdentificationIcon,
|
||||
ArrowPathIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
interface TransferGood {
|
||||
goodCode: number;
|
||||
goodAmount: number;
|
||||
goodUnit: number;
|
||||
goodUnitStr: string;
|
||||
goodStr: string;
|
||||
}
|
||||
|
||||
interface VeterinaryTransferItem {
|
||||
trIDCode: string;
|
||||
trTypeCode: number;
|
||||
trTypeCodeStr: string;
|
||||
trStatus: number;
|
||||
trStatusStr: string;
|
||||
sourcePartIDCode: string;
|
||||
sourceCertID: string;
|
||||
desPartIDCode: string;
|
||||
desCertID: string;
|
||||
issueDate: string;
|
||||
issueDateStr: string;
|
||||
resideDate: string;
|
||||
resideDateStr: string;
|
||||
listofGoods: TransferGood[];
|
||||
message: string | null;
|
||||
errorCode: number;
|
||||
}
|
||||
|
||||
interface VeterinaryTransferResponse {
|
||||
status: boolean;
|
||||
statusCode: number;
|
||||
data: VeterinaryTransferItem[];
|
||||
apiLogId: string;
|
||||
}
|
||||
|
||||
interface UnitProperty {
|
||||
partIDCode: string | null;
|
||||
unitPostalCode: string | null;
|
||||
postalAddress: string | null;
|
||||
detailAddress: string | null;
|
||||
unitName: string | null;
|
||||
unitGroupStr: string | null;
|
||||
unitTypeStr: string | null;
|
||||
licenseStatusStr: string | null;
|
||||
licenseID: string | null;
|
||||
ownerFullName: string | null;
|
||||
ownerCompany: string | null;
|
||||
lesseeFullName: string | null;
|
||||
activityTypeName: string | null;
|
||||
}
|
||||
|
||||
interface InquiryFarmData {
|
||||
listUnitProperty?: UnitProperty[];
|
||||
errorCode?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface InquiryFarmResponse {
|
||||
status: boolean;
|
||||
statusCode: number;
|
||||
data: InquiryFarmData;
|
||||
apiLogId: string;
|
||||
}
|
||||
|
||||
const VeterinaryTransfer: React.FC = () => {
|
||||
const [trIDCode, setTrIDCode] = useState("");
|
||||
const [transferData, setTransferData] = useState<VeterinaryTransferItem[]>(
|
||||
[]
|
||||
);
|
||||
const [farmInfoByPartId, setFarmInfoByPartId] = useState<
|
||||
Record<string, InquiryFarmResponse | null>
|
||||
>({});
|
||||
const [farmLoadingByPartId, setFarmLoadingByPartId] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const showToast = useToast();
|
||||
|
||||
const transferMutation = useApiMutation<VeterinaryTransferResponse>({
|
||||
api: "veterinary-transfer",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
const inquiryFarmMutation = useApiMutation<InquiryFarmResponse>({
|
||||
api: "inquiry-farm",
|
||||
method: "get",
|
||||
disableBackdrop: true,
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
const trimmed = trIDCode.trim();
|
||||
if (!trimmed) {
|
||||
showToast("لطفا کد رهگیری گواهی بهداشت حمل را وارد کنید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await transferMutation.mutateAsync({ trIDCode: trimmed });
|
||||
if (result?.status && Array.isArray(result?.data) && result.data.length) {
|
||||
setTransferData(result.data);
|
||||
} else {
|
||||
setTransferData([]);
|
||||
showToast("نتیجهای یافت نشد", "info");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Veterinary transfer error:", error);
|
||||
showToast(
|
||||
error?.response?.data?.message ||
|
||||
"خطا در دریافت اطلاعات گواهی بهداشتی حمل",
|
||||
"error"
|
||||
);
|
||||
setTransferData([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFarmInquiry = async (partIdCode: string) => {
|
||||
const code = String(partIdCode || "").trim();
|
||||
if (!code) return;
|
||||
|
||||
setFarmLoadingByPartId((prev) => ({ ...prev, [code]: true }));
|
||||
try {
|
||||
const res = await inquiryFarmMutation.mutateAsync({ PartIdCode: code });
|
||||
setFarmInfoByPartId((prev) => ({ ...prev, [code]: res }));
|
||||
if (!res?.status) {
|
||||
showToast("خطا در استعلام واحد کشاورزی", "error");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Inquiry farm error:", error);
|
||||
showToast(
|
||||
error?.response?.data?.message || "خطا در استعلام واحد کشاورزی",
|
||||
"error"
|
||||
);
|
||||
setFarmInfoByPartId((prev) => ({ ...prev, [code]: null }));
|
||||
} finally {
|
||||
setFarmLoadingByPartId((prev) => ({ ...prev, [code]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const getFirstUnitProperty = (res?: InquiryFarmResponse | null) => {
|
||||
const list = res?.data?.listUnitProperty;
|
||||
if (!Array.isArray(list) || list.length === 0) return null;
|
||||
return list[0];
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.95 },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
delay: i * 0.1,
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
},
|
||||
}),
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container column className="gap-4 p-4 max-w-6xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
className="w-full"
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
column
|
||||
className="gap-4 p-6 bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-600"
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
className="text-gray-900 dark:text-gray-100 font-bold mb-2"
|
||||
>
|
||||
استعلام گواهی بهداشتی حمل
|
||||
</Typography>
|
||||
|
||||
<Grid container className="gap-4 items-end">
|
||||
<Grid container className="flex-1" column>
|
||||
<Textfield
|
||||
placeholder="کد رهگیری گواهی بهداشت حمل"
|
||||
value={trIDCode}
|
||||
onChange={(e) => setTrIDCode(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={transferMutation.isPending}
|
||||
className="px-6 py-2.5"
|
||||
>
|
||||
{transferMutation.isPending ? "در حال جستجو..." : "جستجو"}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{transferData.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="w-full"
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="mb-4 text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
نتایج جستجو ({transferData.length})
|
||||
</Typography>
|
||||
|
||||
<Grid container column className="gap-6">
|
||||
{transferData.map((item, index) => {
|
||||
const sourceCode = String(item.sourcePartIDCode || "").trim();
|
||||
const destCode = String(item.desPartIDCode || "").trim();
|
||||
const sourceInquiry = farmInfoByPartId[sourceCode];
|
||||
const destInquiry = farmInfoByPartId[destCode];
|
||||
const sourceUnit = getFirstUnitProperty(sourceInquiry);
|
||||
const destUnit = getFirstUnitProperty(destInquiry);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${item.trIDCode}-${index}`}
|
||||
custom={index}
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-600 p-6 hover:shadow-xl transition-shadow">
|
||||
<Grid container column className="gap-4">
|
||||
<div className="flex items-start justify-between pb-4 border-b border-gray-200 dark:border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="h6"
|
||||
className="text-gray-900 dark:text-gray-100 font-bold"
|
||||
>
|
||||
ردیف {index + 1}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
کد رهگیری گواهی بهداشتی حمل
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium break-all"
|
||||
>
|
||||
{item.trIDCode}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نوع گواهی بهداشتی حمل
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{item.trTypeCodeStr}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
وضعیت
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{item.trStatusStr}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره مجوز مبداء
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{item.sourceCertID || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره مجوز مقصد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{item.desCertID || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
تاریخ صدور گواهی بهداشتی حمل
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{item.issueDateStr}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
تاریخ تغییر وضعیت
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{item.resideDateStr}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شناسه یکتای واحدهای کشاورزی مبداء
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium break-all"
|
||||
>
|
||||
{item.sourcePartIDCode}
|
||||
</Typography>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => fetchFarmInquiry(sourceCode)}
|
||||
disabled={!!farmLoadingByPartId[sourceCode]}
|
||||
className="px-4 py-2"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ArrowPathIcon className="w-5 h-5" />
|
||||
{farmLoadingByPartId[sourceCode]
|
||||
? "..."
|
||||
: "استعلام"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شناسه یکتای واحدهای کشاورزی مقصد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium break-all"
|
||||
>
|
||||
{item.desPartIDCode}
|
||||
</Typography>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => fetchFarmInquiry(destCode)}
|
||||
disabled={!!farmLoadingByPartId[destCode]}
|
||||
className="px-4 py-2"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ArrowPathIcon className="w-5 h-5" />
|
||||
{farmLoadingByPartId[destCode]
|
||||
? "..."
|
||||
: "استعلام"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.listofGoods && item.listofGoods.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<DocumentTextIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
لیست کالا
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-dark-700">
|
||||
<th className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
ردیف
|
||||
</th>
|
||||
<th className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
نام کالا
|
||||
</th>
|
||||
<th className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
مقدار
|
||||
</th>
|
||||
<th className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
واحد
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{item.listofGoods.map((g, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className="hover:bg-gray-50 dark:hover:bg-dark-700"
|
||||
>
|
||||
<td className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 text-center">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 text-center">
|
||||
{g.goodStr}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 text-center">
|
||||
{Number(g.goodAmount).toLocaleString()}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-dark-600 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 text-center">
|
||||
{g.goodUnitStr}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sourceUnit || destUnit) && (
|
||||
<div className="pt-2">
|
||||
{sourceUnit && (
|
||||
<div className="mb-6">
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-700 dark:text-gray-300 font-semibold mb-3"
|
||||
>
|
||||
شناسه یکتای واحد کشاورزی (مبداء)
|
||||
</Typography>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
کد پستی
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.unitPostalCode || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
جزئیات آدرس
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.detailAddress ||
|
||||
sourceUnit.postalAddress ||
|
||||
"-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام واحد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.unitName || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
طبقه بندی نوع واحد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.unitGroupStr || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نوع واحد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.unitTypeStr || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
وضعیت پروانه بهره برداری/مجوز فعالیت
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.licenseStatusStr || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره پروانه بهره برداری/مجوز فعالیت
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.licenseID || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام مالک/بهره بردار
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.ownerFullName || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام شرکت مالک/بهره بردار
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.ownerCompany || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام مستاجر
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.lesseeFullName?.trim() || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
عنوان فعالیت
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{sourceUnit.activityTypeName || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{destUnit && (
|
||||
<div>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-700 dark:text-gray-300 font-semibold mb-3"
|
||||
>
|
||||
شناسه یکتای واحد کشاورزی (مقصد)
|
||||
</Typography>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
کد پستی
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.unitPostalCode || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
جزئیات آدرس
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.detailAddress ||
|
||||
destUnit.postalAddress ||
|
||||
"-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام واحد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.unitName || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
طبقه بندی نوع واحد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.unitGroupStr || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نوع واحد
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.unitTypeStr || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
وضعیت پروانه بهره برداری/مجوز فعالیت
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.licenseStatusStr || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
شماره پروانه بهره برداری/مجوز فعالیت
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.licenseID || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام مالک/بهره بردار
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.ownerFullName || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام شرکت مالک/بهره بردار
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.ownerCompany || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
نام مستاجر
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.lesseeFullName?.trim() || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
عنوان فعالیت
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium"
|
||||
>
|
||||
{destUnit.activityTypeName || "-"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.message && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<IdentificationIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="text-gray-700 dark:text-gray-300 font-semibold"
|
||||
>
|
||||
پیام
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 dark:text-gray-100 font-medium mt-2"
|
||||
>
|
||||
{item.message}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default VeterinaryTransfer;
|
||||
1
src/assets/animations/nodata.json
Normal file
1
src/assets/animations/waiting.json
Normal file
BIN
src/assets/fonts/eot/iranyekanwebblackfanum.eot
Normal file
BIN
src/assets/fonts/eot/iranyekanwebboldfanum.eot
Normal file
BIN
src/assets/fonts/eot/iranyekanwebextrablackfanum.eot
Normal file
BIN
src/assets/fonts/eot/iranyekanwebextraboldfanum.eot
Normal file
BIN
src/assets/fonts/eot/iranyekanweblightfanum.eot
Normal file
BIN
src/assets/fonts/eot/iranyekanwebmediumfanum.eot
Normal file
BIN
src/assets/fonts/eot/iranyekanwebregularfanum.eot
Normal file
BIN
src/assets/fonts/eot/iranyekanwebthinfanum.eot
Normal file
127
src/assets/fonts/fonts.css
Normal file
@@ -0,0 +1,127 @@
|
||||
@font-face {
|
||||
font-family: iranyekan;
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: url("./eot/iranyekanwebboldfanum.eot");
|
||||
src: url("./eot/iranyekanwebboldfanum.eot?#iefix") format("embedded-opentype"),
|
||||
/* IE6-8 */ url("./woff/iranyekanwebboldfanum.woff") format("woff"),
|
||||
/* FF3.6+, IE9, Chrome6+, Saf5.1+*/ url("./ttf/iranyekanwebboldfanum.ttf")
|
||||
format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iranyekan;
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
src: url("./eot/iranyekanwebthinfanum.eot");
|
||||
src: url("./eot/iranyekanwebthinfanum.eot?#iefix") format("embedded-opentype"),
|
||||
/* IE6-8 */ url("./woff/iranyekanwebthinfanum.woff") format("woff"),
|
||||
/* FF3.6+, IE9, Chrome6+, Saf5.1+*/ url("./ttf/iranyekanwebthinfanum.ttf")
|
||||
format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iranyekan;
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url("./eot/iranyekanweblightfanum.eot");
|
||||
src: url("./eot/iranyekanweblightfanum.eot?#iefix")
|
||||
format("embedded-opentype"),
|
||||
/* IE6-8 */ url("./woff/iranyekanweblightfanum.woff") format("woff"),
|
||||
/* FF3.6+, IE9, Chrome6+, Saf5.1+*/ url("./ttf/iranyekanweblightfanum.ttf")
|
||||
format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iranyekan;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url("./eot/iranyekanwebregularfanum.eot");
|
||||
src: url("./eot/iranyekanwebregularfanum.eot?#iefix")
|
||||
format("embedded-opentype"),
|
||||
/* IE6-8 */ url("./woff/iranyekanwebregularfanum.woff") format("woff"),
|
||||
/* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||
url("./ttf/iranyekanwebregularfanum.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iranyekan;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url("./eot/iranyekanwebmediumfanum.eot");
|
||||
src: url("./eot/iranyekanwebmediumfanum.eot?#iefix") format("embedded-opentype"),
|
||||
/* IE6-8 */ url("./woff/iranyekanwebmediumfanum.woff") format("woff"),
|
||||
/* FF3.6+, IE9, Chrome6+, Saf5.1+*/ url("./ttf/iranyekanwebmediumfanum.ttf")
|
||||
format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iranyekan;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url("./eot/iranyekanwebextraboldfanum.eot");
|
||||
src: url("./eot/iranyekanwebextraboldfanum.eot?#iefix")
|
||||
format("embedded-opentype"),
|
||||
/* IE6-8 */ url("./woff/iranyekanwebextraboldfanum.woff") format("woff"),
|
||||
/* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||
url("./ttf/iranyekanwebextraboldfanum.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iranyekan;
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
src: url("./eot/iranyekanwebblackfanum.eot");
|
||||
src: url("./eot/iranyekanwebblackfanum.eot?#iefix") format("embedded-opentype"),
|
||||
/* IE6-8 */ url("./woff/iranyekanwebblackfanum.woff") format("woff"),
|
||||
/* FF3.6+, IE9, Chrome6+, Saf5.1+*/ url("./ttf/iranyekanwebblackfanum.ttf")
|
||||
format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iranyekan;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
src: url("./eot/iranyekanwebextrablackfanum.eot");
|
||||
src: url("./eot/iranyekanwebextrablackfanum.eot?#iefix")
|
||||
format("embedded-opentype"),
|
||||
/* IE6-8 */ url("./woff/iranyekanwebextrablackfanum.woff") format("woff"),
|
||||
/* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||
url("./ttf/iranyekanwebextrablackfanum.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "nazanin";
|
||||
src: local("nazanin"), url("./ttf/B-NAZANIN.TTF") format("truetype");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "titr";
|
||||
src: local("titr"), url("./ttf/Titr.ttf") format("truetype");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* @font-face {
|
||||
font-family: "vazir";
|
||||
src: local("vazir"), url("./ttf/Vazir-Medium.ttf") format("truetype");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "nima";
|
||||
src: local("vazir"), url("./ttf/Nima.ttf") format("truetype");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "sharif";
|
||||
src: local("vazir"), url("./ttf/Sharif.ttf") format("truetype");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "azar";
|
||||
src: local("vazir"), url("./ttf/AzarMehr.ttf") format("truetype");
|
||||
font-weight: bold;
|
||||
} */
|
||||
1475
src/assets/fonts/svg/iranyekanwebblackfanum.svg
Normal file
|
After Width: | Height: | Size: 199 KiB |
1578
src/assets/fonts/svg/iranyekanwebboldfanum.svg
Normal file
|
After Width: | Height: | Size: 221 KiB |
1489
src/assets/fonts/svg/iranyekanwebextrablackfanum.svg
Normal file
|
After Width: | Height: | Size: 201 KiB |
1478
src/assets/fonts/svg/iranyekanwebextraboldfanum.svg
Normal file
|
After Width: | Height: | Size: 198 KiB |
1628
src/assets/fonts/svg/iranyekanweblightfanum.svg
Normal file
|
After Width: | Height: | Size: 233 KiB |
1584
src/assets/fonts/svg/iranyekanwebmediumfanum.svg
Normal file
|
After Width: | Height: | Size: 222 KiB |
1560
src/assets/fonts/svg/iranyekanwebregularfanum.svg
Normal file
|
After Width: | Height: | Size: 219 KiB |
1651
src/assets/fonts/svg/iranyekanwebthinfanum.svg
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
src/assets/fonts/ttf/AzarMehr.ttf
Normal file
BIN
src/assets/fonts/ttf/B-NAZANIN.TTF
Normal file
BIN
src/assets/fonts/ttf/Nima.ttf
Normal file
BIN
src/assets/fonts/ttf/Sharif.ttf
Normal file
BIN
src/assets/fonts/ttf/Titr.ttf
Normal file
BIN
src/assets/fonts/ttf/Vazir-Medium.ttf
Normal file
BIN
src/assets/fonts/ttf/iranyekanwebblackfanum.ttf
Normal file
BIN
src/assets/fonts/ttf/iranyekanwebboldfanum.ttf
Normal file
BIN
src/assets/fonts/ttf/iranyekanwebextrablackfanum.ttf
Normal file
BIN
src/assets/fonts/ttf/iranyekanwebextraboldfanum.ttf
Normal file
BIN
src/assets/fonts/ttf/iranyekanweblightfanum.ttf
Normal file
BIN
src/assets/fonts/ttf/iranyekanwebmediumfanum.ttf
Normal file
BIN
src/assets/fonts/ttf/iranyekanwebregularfanum.ttf
Normal file
BIN
src/assets/fonts/ttf/iranyekanwebthinfanum.ttf
Normal file
BIN
src/assets/fonts/woff/iranyekanwebblackfanum.woff
Normal file
BIN
src/assets/fonts/woff/iranyekanwebboldfanum.woff
Normal file
BIN
src/assets/fonts/woff/iranyekanwebextrablackfanum.woff
Normal file
BIN
src/assets/fonts/woff/iranyekanwebextraboldfanum.woff
Normal file
BIN
src/assets/fonts/woff/iranyekanweblightfanum.woff
Normal file
BIN
src/assets/fonts/woff/iranyekanwebmediumfanum.woff
Normal file
BIN
src/assets/fonts/woff/iranyekanwebregularfanum.woff
Normal file
BIN
src/assets/fonts/woff/iranyekanwebthinfanum.woff
Normal file
BIN
src/assets/images/IranOutlined.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/images/auth-bg.png
Normal file
|
After Width: | Height: | Size: 612 KiB |
BIN
src/assets/images/chicken.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src/assets/images/dark.png
Normal file
|
After Width: | Height: | Size: 980 KiB |
BIN
src/assets/images/fav.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/images/green_dot.png
Normal file
|
After Width: | Height: | Size: 991 B |
BIN
src/assets/images/hen.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/assets/images/light.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
src/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
src/assets/images/marker.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/images/navlogo.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
src/assets/images/orange_dot.png
Normal file
|
After Width: | Height: | Size: 959 B |
BIN
src/assets/images/red_dot.png
Normal file
|
After Width: | Height: | Size: 961 B |
BIN
src/assets/images/sheep.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src/assets/images/store.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
3
src/assets/images/svg/excel.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g fill='none'><path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/><path fill='currentColor' d='M13.586 2A2 2 0 0 1 15 2.586L19.414 7A2 2 0 0 1 20 8.414V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zM12 4H6v16h12V10h-4.5A1.5 1.5 0 0 1 12 8.5zm-1.06 8.525 1.06 1.06 1.06-1.06a1 1 0 0 1 1.415 1.414L13.415 15l1.06 1.06a1 1 0 0 1-1.414 1.415L12 16.415l-1.06 1.06a1 1 0 0 1-1.415-1.414l1.06-1.06-1.06-1.062a1 1 0 1 1 1.414-1.414ZM14 4.415V8h3.586L14 4.414Z'/></g></svg>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/images/user.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/images/yellow_dot.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
653
src/components/AutoComplete/AutoComplete.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useId,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { getSizeStyles } from "../../data/getInputSizes";
|
||||
import Textfield from "../Textfeild/Textfeild";
|
||||
import { motion } from "framer-motion";
|
||||
import { Tooltip } from "../Tooltip/Tooltip";
|
||||
import { createPortal } from "react-dom";
|
||||
import { checkIsMobile } from "../../utils/checkIsMobile";
|
||||
|
||||
interface DataItem {
|
||||
key: number | string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
isGroupHeader?: boolean;
|
||||
originalGroupKey?: string | number;
|
||||
}
|
||||
|
||||
interface AutoCompleteProps {
|
||||
data: DataItem[];
|
||||
multiselect?: boolean;
|
||||
inPage?: boolean;
|
||||
disabled?: boolean;
|
||||
selectedKeys: (number | string)[];
|
||||
onChange: (keys: (number | string)[]) => void | [];
|
||||
width?: string;
|
||||
buttonHeight?: number | string;
|
||||
title?: string;
|
||||
error?: boolean;
|
||||
size?: "small" | "medium" | "large";
|
||||
helperText?: string;
|
||||
onChangeValue?: (data: { value: string; key: number | string }) => void;
|
||||
onGroupHeaderClick?: (groupKey: string | number) => void;
|
||||
selectField?: boolean;
|
||||
}
|
||||
|
||||
const AutoComplete: React.FC<AutoCompleteProps> = ({
|
||||
data,
|
||||
multiselect = false,
|
||||
selectedKeys,
|
||||
onChange,
|
||||
disabled = false,
|
||||
inPage = false,
|
||||
title = "",
|
||||
error = false,
|
||||
size = "medium",
|
||||
helperText,
|
||||
onChangeValue,
|
||||
onGroupHeaderClick,
|
||||
selectField = false,
|
||||
}) => {
|
||||
const [filteredData, setFilteredData] = useState<DataItem[]>(data);
|
||||
const [showOptions, setShowOptions] = useState<boolean>(false);
|
||||
const [dropdownWidth, setDropdownWidth] = useState<number>(0);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
}>({ top: 0, left: 0 });
|
||||
const [maxHeight, setMaxHeight] = useState<number>(240);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLUListElement>(null);
|
||||
const uniqueId = useId();
|
||||
const selectedKeysRef = useRef<(number | string)[]>(selectedKeys);
|
||||
const isInternalChangeRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateDropdownDimensions = () => {
|
||||
if (inputRef.current) {
|
||||
const rect = inputRef.current.getBoundingClientRect();
|
||||
const defaultMaxHeight = 240;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const availableHeight = Math.max(100, spaceBelow - 10);
|
||||
const calculatedMaxHeight =
|
||||
spaceBelow < defaultMaxHeight ? availableHeight : defaultMaxHeight;
|
||||
|
||||
setDropdownWidth(rect.width);
|
||||
setMaxHeight(calculatedMaxHeight);
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateDropdownDimensions();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateDropdownDimensions);
|
||||
if (inputRef.current) {
|
||||
resizeObserver.observe(inputRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updateDropdownDimensions);
|
||||
window.addEventListener("scroll", updateDropdownDimensions);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", updateDropdownDimensions);
|
||||
window.removeEventListener("scroll", updateDropdownDimensions);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showOptions) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let isActive = true;
|
||||
let lastTop = 0;
|
||||
let lastLeft = 0;
|
||||
let lastWidth = 0;
|
||||
let lastMaxHeight = 0;
|
||||
|
||||
const updatePosition = (force = false) => {
|
||||
if (!isActive || !inputRef.current) return;
|
||||
|
||||
const rect = inputRef.current.getBoundingClientRect();
|
||||
const defaultMaxHeight = 240;
|
||||
const viewportHeight =
|
||||
window.visualViewport?.height || window.innerHeight;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const availableHeight = Math.max(100, spaceBelow - 10);
|
||||
const calculatedMaxHeight =
|
||||
spaceBelow < defaultMaxHeight ? availableHeight : defaultMaxHeight;
|
||||
|
||||
const newTop = rect.bottom + window.scrollY;
|
||||
const newLeft = rect.left + window.scrollX;
|
||||
const newWidth = rect.width;
|
||||
|
||||
if (
|
||||
force ||
|
||||
Math.abs(newTop - lastTop) > 0.5 ||
|
||||
Math.abs(newLeft - lastLeft) > 0.5 ||
|
||||
Math.abs(newWidth - lastWidth) > 0.5 ||
|
||||
Math.abs(calculatedMaxHeight - lastMaxHeight) > 1
|
||||
) {
|
||||
setDropdownWidth(newWidth);
|
||||
setMaxHeight(calculatedMaxHeight - 30);
|
||||
setDropdownPosition({
|
||||
top: newTop,
|
||||
left: newLeft,
|
||||
});
|
||||
lastTop = newTop;
|
||||
lastLeft = newLeft;
|
||||
lastWidth = newWidth;
|
||||
lastMaxHeight = calculatedMaxHeight;
|
||||
}
|
||||
|
||||
if (isActive && showOptions) {
|
||||
animationFrameId = requestAnimationFrame(() => updatePosition(false));
|
||||
}
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
const handleResize = () => updatePosition(true);
|
||||
const handleScroll = () => updatePosition();
|
||||
let lastViewportHeight =
|
||||
window.visualViewport?.height || window.innerHeight;
|
||||
const handleVisualViewportResize = () => {
|
||||
const currentHeight = window.visualViewport?.height || window.innerHeight;
|
||||
const heightDiff = Math.abs(currentHeight - lastViewportHeight);
|
||||
lastViewportHeight = currentHeight;
|
||||
|
||||
if (heightDiff > 50) {
|
||||
setTimeout(() => {
|
||||
updatePosition(true);
|
||||
setTimeout(() => updatePosition(true), 200);
|
||||
setTimeout(() => updatePosition(true), 400);
|
||||
}, 50);
|
||||
} else {
|
||||
setTimeout(() => updatePosition(true), 50);
|
||||
}
|
||||
};
|
||||
const handleVisualViewportScroll = () => updatePosition();
|
||||
const handleFocus = () => {
|
||||
setTimeout(() => updatePosition(true), 300);
|
||||
};
|
||||
const handleBlur = () => {
|
||||
setTimeout(() => {
|
||||
updatePosition(true);
|
||||
setTimeout(() => updatePosition(true), 200);
|
||||
setTimeout(() => updatePosition(true), 400);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
|
||||
if (checkIsMobile()) {
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener(
|
||||
"resize",
|
||||
handleVisualViewportResize
|
||||
);
|
||||
window.visualViewport.addEventListener(
|
||||
"scroll",
|
||||
handleVisualViewportScroll
|
||||
);
|
||||
}
|
||||
const inputElement = inputRef.current;
|
||||
if (inputElement) {
|
||||
inputElement.addEventListener("focus", handleFocus);
|
||||
inputElement.addEventListener("blur", handleBlur);
|
||||
inputElement.addEventListener("touchstart", handleFocus);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
if (checkIsMobile()) {
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener(
|
||||
"resize",
|
||||
handleVisualViewportResize
|
||||
);
|
||||
window.visualViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleVisualViewportScroll
|
||||
);
|
||||
}
|
||||
const inputElement = inputRef.current;
|
||||
if (inputElement) {
|
||||
inputElement.removeEventListener("focus", handleFocus);
|
||||
inputElement.removeEventListener("blur", handleBlur);
|
||||
inputElement.removeEventListener("touchstart", handleFocus);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [showOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const clickedInsideCurrent = target.closest(`.select-group-${uniqueId}`);
|
||||
const clickedOnAnotherAutocomplete =
|
||||
target.closest(".select-group") && !clickedInsideCurrent;
|
||||
|
||||
const clickedOnPortalDropdown = target.closest(
|
||||
`[data-autocomplete-portal="${uniqueId}"]`
|
||||
);
|
||||
|
||||
if (clickedOnAnotherAutocomplete) {
|
||||
setShowOptions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clickedInsideCurrent && !clickedOnPortalDropdown) {
|
||||
setTimeout(() => {
|
||||
const isInputFocused = document.activeElement === inputRef.current;
|
||||
if (!isInputFocused) {
|
||||
setShowOptions(false);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [uniqueId]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredData(data);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedKeysRef.current = selectedKeys;
|
||||
}, [selectedKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInternalChangeRef.current) {
|
||||
isInternalChangeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedKeys?.length > 0 && onChangeValue) {
|
||||
const selectedItem = data.find((item) => item.key === selectedKeys[0]);
|
||||
if (selectedItem) {
|
||||
onChangeValue({
|
||||
value: selectedItem.value.trim(),
|
||||
key: selectedItem.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedKeys, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showOptions) {
|
||||
setIsTyping(false);
|
||||
}
|
||||
}, [showOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showOptions || !checkIsMobile()) return;
|
||||
|
||||
const originalOverflow = window.getComputedStyle(document.body).overflow;
|
||||
const originalPosition = window.getComputedStyle(document.body).position;
|
||||
const originalTop = document.body.style.top;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.style.position = "fixed";
|
||||
document.body.style.top = `-${scrollY}px`;
|
||||
document.body.style.width = "100%";
|
||||
|
||||
const preventTouchMove = (e: TouchEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const dropdown = document.querySelector(
|
||||
`[data-autocomplete-portal="${uniqueId}"]`
|
||||
);
|
||||
|
||||
if (dropdown) {
|
||||
const touch = e.touches[0] || e.changedTouches[0];
|
||||
if (touch) {
|
||||
const elementAtPoint = document.elementFromPoint(
|
||||
touch.clientX,
|
||||
touch.clientY
|
||||
);
|
||||
if (
|
||||
elementAtPoint &&
|
||||
(dropdown.contains(elementAtPoint) || dropdown.contains(target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else if (dropdown.contains(target)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener("touchmove", preventTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
document.body.style.position = originalPosition;
|
||||
document.body.style.top = originalTop;
|
||||
document.body.style.width = "";
|
||||
window.scrollTo(0, scrollY);
|
||||
document.removeEventListener("touchmove", preventTouchMove);
|
||||
};
|
||||
}, [showOptions, uniqueId]);
|
||||
|
||||
const inputValue = useMemo(() => {
|
||||
if (selectedKeys?.length > 0) {
|
||||
const selectedValues = data
|
||||
.filter((item) => selectedKeys?.includes(item.key))
|
||||
.map((item) => item.value);
|
||||
return selectedValues?.join(", ");
|
||||
}
|
||||
return "";
|
||||
}, [selectedKeys, data]);
|
||||
|
||||
const [localInputValue, setLocalInputValue] = useState<string>("");
|
||||
const [isTyping, setIsTyping] = useState<boolean>(false);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setLocalInputValue(value);
|
||||
setIsTyping(true);
|
||||
const filtered = data.filter((item) =>
|
||||
item.value.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
setFilteredData(filtered);
|
||||
setShowOptions(true);
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newSelectedKeys: (number | string)[]) => {
|
||||
isInternalChangeRef.current = true;
|
||||
onChange(newSelectedKeys);
|
||||
|
||||
if (onChangeValue && newSelectedKeys.length > 0) {
|
||||
const selectedItem = data.find(
|
||||
(item) => item.key === newSelectedKeys[0]
|
||||
);
|
||||
if (selectedItem) {
|
||||
onChangeValue({
|
||||
value: selectedItem.value.trim(),
|
||||
key: selectedItem.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange, onChangeValue, data]
|
||||
);
|
||||
|
||||
const handleOptionClick = useCallback(
|
||||
(key: number | string) => {
|
||||
const currentSelectedKeys = selectedKeysRef.current;
|
||||
let newSelectedKeys: (number | string)[];
|
||||
|
||||
if (multiselect) {
|
||||
if (currentSelectedKeys.includes(key)) {
|
||||
newSelectedKeys = currentSelectedKeys.filter((item) => item !== key);
|
||||
} else {
|
||||
newSelectedKeys = [...currentSelectedKeys, key];
|
||||
}
|
||||
} else {
|
||||
if (currentSelectedKeys.includes(key)) {
|
||||
newSelectedKeys = currentSelectedKeys.filter((item) => item !== key);
|
||||
} else {
|
||||
newSelectedKeys = [key];
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(newSelectedKeys);
|
||||
|
||||
setIsTyping(false);
|
||||
if (!multiselect) {
|
||||
setLocalInputValue("");
|
||||
setShowOptions(false);
|
||||
}
|
||||
},
|
||||
[multiselect, handleChange]
|
||||
);
|
||||
|
||||
const handleInputClick = useCallback(() => {
|
||||
document.querySelectorAll(".select-group").forEach((el) => {
|
||||
if (!el.classList.contains(`select-group-${uniqueId}`)) {
|
||||
const input = el.querySelector("input");
|
||||
if (input) {
|
||||
input.blur();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setShowOptions(true);
|
||||
setFilteredData(data);
|
||||
setLocalInputValue("");
|
||||
setIsTyping(false);
|
||||
}, [uniqueId, data]);
|
||||
|
||||
const handleCloseInput = useCallback(() => {
|
||||
setShowOptions(false);
|
||||
setIsTyping(false);
|
||||
}, []);
|
||||
|
||||
const selectedKeysSet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const enabledItems = filteredData.filter((item) => !item.disabled);
|
||||
const allEnabledKeys = enabledItems.map((item) => item.key);
|
||||
handleChange(allEnabledKeys);
|
||||
}, [filteredData, handleChange]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
handleChange([]);
|
||||
}, [handleChange]);
|
||||
|
||||
const areAllSelected = useMemo(() => {
|
||||
const enabledItems = filteredData.filter((item) => !item.disabled);
|
||||
return (
|
||||
enabledItems.length > 0 &&
|
||||
enabledItems.every((item) => selectedKeysSet.has(item.key))
|
||||
);
|
||||
}, [filteredData, selectedKeysSet]);
|
||||
|
||||
const dropdownOptions = useMemo(() => {
|
||||
if (filteredData.length === 0) {
|
||||
return (
|
||||
<li className="px-4 py-3 text-gray-500 dark:text-dark-400 text-center">
|
||||
نتیجهای یافت نشد
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const selectAllHeader = multiselect ? (
|
||||
<li
|
||||
key="select-all-header"
|
||||
onClick={areAllSelected ? handleDeselectAll : handleSelectAll}
|
||||
className="flex items-center my-1 justify-start gap-2 px-4 py-2 cursor-pointer transition-colors duration-150 rounded-md border border-gray-200 dark:border-dark-600 hover:bg-primary-100 text-dark-800 dark:text-dark-100 dark:hover:bg-dark-700 bg-gray-50 dark:bg-dark-700 font-semibold"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{areAllSelected ? "عدم انتخاب همه" : "انتخاب همه"}
|
||||
</span>
|
||||
{areAllSelected && (
|
||||
<CheckIcon className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
|
||||
)}
|
||||
</li>
|
||||
) : null;
|
||||
|
||||
const options = filteredData.map((item) => {
|
||||
const isSelected = selectedKeysSet.has(item.key);
|
||||
const isGroupHeader = item.isGroupHeader;
|
||||
const handleClick = () => {
|
||||
if (isGroupHeader && onGroupHeaderClick) {
|
||||
const groupKey =
|
||||
item.originalGroupKey !== undefined
|
||||
? item.originalGroupKey
|
||||
: String(item.key).startsWith("__group__")
|
||||
? String(item.key).slice(11)
|
||||
: item.key;
|
||||
onGroupHeaderClick(groupKey);
|
||||
} else if (!item.disabled) {
|
||||
handleOptionClick(item.key);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<li
|
||||
key={`${item.key}`}
|
||||
onClick={handleClick}
|
||||
className={`flex items-center justify-between px-4 py-2 transition-colors duration-150 rounded-md
|
||||
${
|
||||
isGroupHeader && onGroupHeaderClick
|
||||
? "cursor-pointer opacity-55 hover:bg-gray-100 text-dark-800 dark:text-dark-100 dark:hover:bg-primary-900/90 font-semibold bg-gray-200 dark:bg-primary-900"
|
||||
: item.disabled
|
||||
? "text-gray-400 dark:text-dark-500 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-primary-100 text-dark-800 dark:text-dark-100 dark:hover:bg-dark-700"
|
||||
}
|
||||
${
|
||||
isSelected && !isGroupHeader
|
||||
? "bg-primary-50 dark:bg-dark-700 font-semibold"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
aria-disabled={item?.disabled && !isGroupHeader}
|
||||
>
|
||||
{checkIsMobile() ? (
|
||||
<span
|
||||
className={`truncate ${
|
||||
item?.value.length > 55 ? "text-xs" : "text-sm"
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={item.value}
|
||||
hidden={item?.value?.length < 55}
|
||||
position="right"
|
||||
>
|
||||
<span
|
||||
className={`truncate ${
|
||||
item?.value.length > 55 ? "text-xs" : "text-sm"
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isSelected && (
|
||||
<CheckIcon className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
return selectAllHeader ? [selectAllHeader, ...options] : options;
|
||||
}, [
|
||||
filteredData,
|
||||
selectedKeysSet,
|
||||
handleOptionClick,
|
||||
multiselect,
|
||||
areAllSelected,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
onGroupHeaderClick,
|
||||
]);
|
||||
|
||||
const dropdownPortalContent = useMemo(() => {
|
||||
if (!showOptions) return null;
|
||||
|
||||
return createPortal(
|
||||
<motion.ul
|
||||
ref={dropdownRef}
|
||||
data-autocomplete-portal={`${uniqueId}`}
|
||||
initial={{ opacity: 0, scaleY: 0.95, y: -5 }}
|
||||
animate={{ opacity: 1, scaleY: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: `${dropdownWidth}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
zIndex: 9999,
|
||||
transformOrigin: "top center",
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "#cbd5e1 transparent",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
className={`overflow-y-auto border border-gray-200 dark:border-dark-500 bg-white dark:bg-dark-800 divide-y divide-gray-100 dark:divide-dark-600 text-sm backdrop-blur-lg rounded-xl shadow-2xl modern-scrollbar`}
|
||||
>
|
||||
{dropdownOptions}
|
||||
</motion.ul>,
|
||||
document.body
|
||||
);
|
||||
}, [
|
||||
showOptions,
|
||||
dropdownPosition,
|
||||
dropdownWidth,
|
||||
uniqueId,
|
||||
dropdownOptions,
|
||||
maxHeight,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`select-group select-group-${uniqueId} ${
|
||||
inPage ? "w-auto" : "w-full"
|
||||
}`}
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<div className="relative">
|
||||
<Textfield
|
||||
disabled={disabled}
|
||||
readOnly={selectField}
|
||||
inputMode={selectField ? "none" : undefined}
|
||||
handleCloseInput={handleCloseInput}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
ref={inputRef}
|
||||
isAutoComplete
|
||||
inputSize={size}
|
||||
value={isTyping ? localInputValue : inputValue}
|
||||
onChange={handleInputChange}
|
||||
onClick={handleInputClick}
|
||||
className="selected-value w-full p-3 pl-10 outline-0 rounded-lg border border-black-100 transition-all duration-200 text-right"
|
||||
placeholder={title || "انتخاب کنید..."}
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className={`absolute left-3 text-dark-400 dark:text-dark-100 transition-transform duration-200 ${
|
||||
showOptions ? "transform rotate-180" : ""
|
||||
} ${getSizeStyles(size).icon}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dropdownPortalContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoComplete;
|
||||
69
src/components/BackDrop/Backdrop.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useBackdropStore } from "../../context/zustand-store/appStore";
|
||||
import Lottie from "lottie-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import waiting from "../../assets/animations/waiting.json";
|
||||
|
||||
const Backdrop: React.FC = () => {
|
||||
const { isOpen, closeBackdrop } = useBackdropStore();
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeBackdrop();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
setTimeout(() => {
|
||||
backdropRef.current?.focus();
|
||||
}, 100);
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
ref={backdropRef}
|
||||
key="backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
autoFocus
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<div className="w-32 h-32">
|
||||
<Lottie animationData={waiting} loop={true} />
|
||||
</div>
|
||||
<p className="mt-4 text-white text-lg font-medium select-none">
|
||||
لطفا صبر کنید ...
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default Backdrop;
|
||||
232
src/components/Button/Button.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { ReactNode, ReactElement, ButtonHTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ChartBarIcon,
|
||||
DocumentChartBarIcon,
|
||||
EyeIcon,
|
||||
FolderPlusIcon,
|
||||
PencilIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
ViewfinderCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
bgPrimaryColor,
|
||||
mobileBorders,
|
||||
textColorOnPrimary,
|
||||
} from "../../data/getColorBasedOnMode";
|
||||
import { checkIsMobile } from "../../utils/checkIsMobile";
|
||||
import { inputWidths } from "../../data/getItemsWidth";
|
||||
import { PlusIcon } from "@heroicons/react/24/solid";
|
||||
import { useUserProfileStore } from "../../context/zustand-store/userStore";
|
||||
import excel from "../../assets/images/svg/excel.svg?react";
|
||||
import SVGImage from "../SvgImage/SvgImage";
|
||||
import api from "../../utils/axios";
|
||||
import { useBackdropStore } from "../../context/zustand-store/appStore";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
|
||||
type ExcelProps = {
|
||||
link: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ButtonProps = {
|
||||
children?: ReactNode | string;
|
||||
icon?: ReactElement;
|
||||
direction?: "row" | "row-reverse" | "col" | "col-reverse";
|
||||
iconColor?: string;
|
||||
iconBgColor?: string;
|
||||
iconSize?: number | string;
|
||||
className?: string;
|
||||
variant?:
|
||||
| "submit"
|
||||
| "secondary-submit"
|
||||
| "edit"
|
||||
| "secondary-edit"
|
||||
| "detail"
|
||||
| "delete"
|
||||
| "view"
|
||||
| "info"
|
||||
| "chart";
|
||||
access?: string;
|
||||
height?: string | number;
|
||||
fullWidth?: boolean;
|
||||
excelInfo?: ExcelProps;
|
||||
rounded?: boolean;
|
||||
size?: "small" | "medium" | "large";
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
icon,
|
||||
direction = "row",
|
||||
iconSize,
|
||||
className = "",
|
||||
variant = "",
|
||||
access = "",
|
||||
height,
|
||||
fullWidth = false,
|
||||
excelInfo,
|
||||
rounded = false,
|
||||
size = "medium",
|
||||
...props
|
||||
}) => {
|
||||
const directionClass = {
|
||||
row: "flex-row",
|
||||
"row-reverse": "flex-row-reverse",
|
||||
col: "flex-col",
|
||||
"col-reverse": "flex-col-reverse",
|
||||
}[direction];
|
||||
|
||||
const sizeStyles = {
|
||||
small: {
|
||||
padding: "h-[32px] px-2",
|
||||
text: "text-xs",
|
||||
icon: iconSize ?? 14,
|
||||
},
|
||||
medium: {
|
||||
padding: "h-[40px] px-2",
|
||||
text: "text-sm",
|
||||
icon: iconSize ?? 18,
|
||||
},
|
||||
large: {
|
||||
padding: "h-[48px] px-2",
|
||||
text: "text-base",
|
||||
icon: iconSize ?? 20,
|
||||
},
|
||||
}[size] ?? {
|
||||
padding: "px-4 py-2",
|
||||
text: "text-sm",
|
||||
icon: iconSize ?? 18,
|
||||
};
|
||||
|
||||
const getVariantIcon = () => {
|
||||
switch (variant) {
|
||||
case "submit":
|
||||
return (
|
||||
<PlusIcon
|
||||
className={`w-5 h-5 ${
|
||||
children ? "text-white" : "text-purple-400 dark:text-white"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
case "secondary-submit":
|
||||
return (
|
||||
<FolderPlusIcon
|
||||
className={`w-5 h-5 ${
|
||||
children ? "text-white" : "text-purple-400 dark:text-white"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
case "edit":
|
||||
return (
|
||||
<PencilIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
|
||||
);
|
||||
case "secondary-edit":
|
||||
return (
|
||||
<PencilSquareIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
|
||||
);
|
||||
case "detail":
|
||||
return (
|
||||
<EyeIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
|
||||
);
|
||||
case "view":
|
||||
return (
|
||||
<ViewfinderCircleIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
|
||||
);
|
||||
case "delete":
|
||||
return <TrashIcon className="w-5 h-5 text-red-500" />;
|
||||
case "info":
|
||||
return (
|
||||
<DocumentChartBarIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
|
||||
);
|
||||
case "chart":
|
||||
return (
|
||||
<ChartBarIcon className="w-5 h-5 text-purple-400 dark:text-purple-100" />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const { profile } = useUserProfileStore();
|
||||
const { openBackdrop, closeBackdrop } = useBackdropStore();
|
||||
const showToast = useToast();
|
||||
|
||||
const ableToSeeButton = () => {
|
||||
if (!access) {
|
||||
return true;
|
||||
} else {
|
||||
const permissions = profile?.permissions || [];
|
||||
// Check if access exists in the permissions array (simple array of strings)
|
||||
return permissions.includes(access);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={clsx(
|
||||
`${
|
||||
ableToSeeButton() ? "flex" : "hidden"
|
||||
} flex items-center justify-center gap-1 backdrop-blur-md transition-all duration-200 focus:outline-none cursor-pointer`,
|
||||
fullWidth ? "w-full" : inputWidths,
|
||||
!className.includes("bg-") ? children && bgPrimaryColor : "hover-",
|
||||
directionClass,
|
||||
!className.includes("text-") && textColorOnPrimary,
|
||||
rounded ? "rounded-2xl" : "rounded-[8px]",
|
||||
sizeStyles.padding,
|
||||
sizeStyles.text,
|
||||
className,
|
||||
checkIsMobile() && !icon && !variant && children && mobileBorders
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
{variant && !icon && <>{getVariantIcon()}</>}
|
||||
<span className="whitespace-nowrap">{children}</span>
|
||||
{icon && <div>{icon}</div>}
|
||||
{excelInfo && (
|
||||
<a
|
||||
onClick={() => {
|
||||
openBackdrop();
|
||||
api
|
||||
.get(excelInfo?.link || "", {
|
||||
responseType: "blob",
|
||||
})
|
||||
.then((response) => {
|
||||
closeBackdrop();
|
||||
const url = window.URL.createObjectURL(
|
||||
new Blob([response.data])
|
||||
);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
link.setAttribute("download", `${excelInfo?.title}.xlsx`);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error downloading file:", error);
|
||||
closeBackdrop();
|
||||
showToast("خطا در دانلود فایل", "error");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SVGImage
|
||||
src={excel}
|
||||
className={` text-primary-600 dark:text-primary-100 w-5 h-5`}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
288
src/components/Captcha/Captcha.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { ArrowPathIcon, SpeakerWaveIcon } from "@heroicons/react/24/outline";
|
||||
import Textfield from "../Textfeild/Textfeild";
|
||||
|
||||
interface CaptchaProps {
|
||||
onChange: (isValid: boolean) => void;
|
||||
captchaImage?: string;
|
||||
captchaKey?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const Captcha: React.FC<CaptchaProps> = ({
|
||||
onChange,
|
||||
captchaImage,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const [input, setInput] = useState("");
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [solution, setSolution] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (solution !== null) {
|
||||
drawCaptcha();
|
||||
}
|
||||
}, [solution]);
|
||||
|
||||
useEffect(() => {
|
||||
generateNewCaptcha();
|
||||
}, []);
|
||||
|
||||
const getRandomInt = (min: number, max: number) => {
|
||||
min = Math.ceil(min);
|
||||
max = Math.floor(max);
|
||||
return Math.floor(Math.random() * (max - min)) + min;
|
||||
};
|
||||
|
||||
const getRandomFloat = (min: number, max: number) => {
|
||||
return Math.random() * (max - min) + min;
|
||||
};
|
||||
|
||||
const generateNewCaptcha = () => {
|
||||
const newSolution = getRandomInt(111111, 999999);
|
||||
setSolution(newSolution);
|
||||
};
|
||||
|
||||
const drawCaptcha = () => {
|
||||
if (!canvasRef.current || solution === null) return;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { width, height } = canvas;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, width, height);
|
||||
const colors = ["#f0f4f8", "#e8f0f5", "#f5f5f5", "#fafafa", "#f0f0f0"];
|
||||
gradient.addColorStop(0, colors[getRandomInt(0, colors.length)]);
|
||||
gradient.addColorStop(1, colors[getRandomInt(0, colors.length)]);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.1)";
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const x = getRandomInt(0, width);
|
||||
const y = getRandomInt(0, height);
|
||||
const size = getRandomInt(1, 3);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "rgba(150, 150, 150, 0.3)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
ctx.beginPath();
|
||||
const startX = getRandomInt(0, width);
|
||||
const startY = getRandomInt(0, height);
|
||||
ctx.moveTo(startX, startY);
|
||||
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const cpX = getRandomInt(0, width);
|
||||
const cpY = getRandomInt(0, height);
|
||||
const endX = getRandomInt(0, width);
|
||||
const endY = getRandomInt(0, height);
|
||||
ctx.quadraticCurveTo(cpX, cpY, endX, endY);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "rgba(200, 200, 200, 0.4)";
|
||||
ctx.lineWidth = 1.5;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.beginPath();
|
||||
const y = getRandomInt(0, height);
|
||||
ctx.moveTo(0, y);
|
||||
for (let x = 0; x < width; x += 5) {
|
||||
const waveY = y + Math.sin(x * 0.1 + i) * 6;
|
||||
ctx.lineTo(x, waveY);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const solutionStr = solution.toString();
|
||||
const charWidth = width / (solutionStr.length + 1);
|
||||
const baseY = height / 2;
|
||||
const fonts = [
|
||||
"Arial",
|
||||
"Verdana",
|
||||
"Courier New",
|
||||
"Georgia",
|
||||
"Times New Roman",
|
||||
];
|
||||
const colors_text = ["#1a1a1a", "#2d2d2d", "#1f2937", "#111827", "#0f172a"];
|
||||
|
||||
solutionStr.split("").forEach((char, index) => {
|
||||
ctx.save();
|
||||
|
||||
const rotation = getRandomFloat(-0.3, 0.3);
|
||||
const x = charWidth * (index + 1);
|
||||
const yOffset = getRandomInt(-6, 6);
|
||||
const fontSize = getRandomInt(28, 36);
|
||||
const fontFamily = fonts[getRandomInt(0, fonts.length)];
|
||||
|
||||
ctx.translate(x, baseY + yOffset);
|
||||
ctx.rotate(rotation);
|
||||
|
||||
ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
|
||||
ctx.shadowBlur = 2;
|
||||
ctx.shadowOffsetX = 1;
|
||||
ctx.shadowOffsetY = 1;
|
||||
|
||||
if (Math.random() > 0.5) {
|
||||
const charGradient = ctx.createLinearGradient(-15, -20, 15, 20);
|
||||
charGradient.addColorStop(
|
||||
0,
|
||||
colors_text[getRandomInt(0, colors_text.length)]
|
||||
);
|
||||
charGradient.addColorStop(
|
||||
1,
|
||||
colors_text[getRandomInt(0, colors_text.length)]
|
||||
);
|
||||
ctx.fillStyle = charGradient;
|
||||
} else {
|
||||
ctx.fillStyle = colors_text[getRandomInt(0, colors_text.length)];
|
||||
}
|
||||
|
||||
ctx.font = `bold ${fontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(char, 0, 0);
|
||||
|
||||
if (Math.random() > 0.6) {
|
||||
ctx.strokeStyle = "rgba(100, 100, 100, 0.3)";
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.strokeText(char, 0, 0);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
ctx.globalAlpha = 0.15;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const shapeType = getRandomInt(0, 3);
|
||||
const x = getRandomInt(0, width);
|
||||
const y = getRandomInt(0, height);
|
||||
const size = getRandomInt(8, 20);
|
||||
|
||||
ctx.fillStyle = `hsl(${getRandomInt(0, 360)}, 50%, 50%)`;
|
||||
|
||||
if (shapeType === 0) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (shapeType === 1) {
|
||||
ctx.fillRect(x - size / 2, y - size / 2, size, size);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - size / 2);
|
||||
ctx.lineTo(x - size / 2, y + size / 2);
|
||||
ctx.lineTo(x + size / 2, y + size / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1.0;
|
||||
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const x = getRandomInt(0, width);
|
||||
const y = getRandomInt(0, height);
|
||||
const size = getRandomInt(1, 2);
|
||||
ctx.fillRect(x, y, size, size);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
generateNewCaptcha();
|
||||
setInput("");
|
||||
if (onRefresh) {
|
||||
onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
const playAudio = () => {
|
||||
if (solution === null) return;
|
||||
const audio = new SpeechSynthesisUtterance(
|
||||
solution.toString().split("").join(" ")
|
||||
);
|
||||
audio.rate = 0.25;
|
||||
window.speechSynthesis.speak(audio);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setInput(value);
|
||||
if (solution !== null) {
|
||||
onChange(value === solution.toString());
|
||||
}
|
||||
};
|
||||
|
||||
if (captchaImage) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Textfield
|
||||
fullWidth
|
||||
placeholder="کد امنیتی"
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
isNumber
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-dark-600 transition"
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
<div className="h-10 w-[180px] overflow-hidden rounded-lg border border-gray-300 dark:border-dark-600">
|
||||
<img
|
||||
className="w-full h-full object-cover"
|
||||
src={captchaImage}
|
||||
alt="captcha"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={200}
|
||||
height={40}
|
||||
className="border border-gray-300 dark:border-dark-600 rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-dark-600 transition"
|
||||
aria-label="get new captcha"
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={playAudio}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-dark-600 transition"
|
||||
aria-label="play audio"
|
||||
>
|
||||
<SpeakerWaveIcon className="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
<Textfield
|
||||
fullWidth
|
||||
placeholder="کد امنیتی"
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
isNumber
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Captcha;
|
||||
66
src/components/CheckBox/CheckBox.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { textColor } from "../../data/getColorBasedOnMode";
|
||||
import { getSizeStyles } from "../../data/getInputSizes";
|
||||
|
||||
interface CheckboxProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||
label?: string;
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
const Checkbox: React.FC<CheckboxProps> = ({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
size,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<div className={"flex items-center"}>
|
||||
<label className="inline-flex items-center space-x-2 cursor-pointer select-none">
|
||||
<span className="relative inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={`${getSizeStyles(size).check} cursor-pointer
|
||||
appearance-none border-1 rounded
|
||||
checked:border-primary-600 border-dark-300 bg-gray-100 dark:border-gray-50 checked:bg-primary-600
|
||||
checked:border-none focus:outline-none
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
peer
|
||||
`}
|
||||
{...rest}
|
||||
/>
|
||||
<svg
|
||||
className={` ${getSizeStyles(size).check} ${textColor}
|
||||
absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none
|
||||
hidden peer-checked:block text-white
|
||||
`}
|
||||
viewBox="1 1 24 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</span>
|
||||
{label && (
|
||||
<span
|
||||
className={`${size === "small" ? "text-xs" : "text-sm"} ${
|
||||
disabled ? "text-dark-400 cursor-not-allowed" : textColor
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
46
src/components/Divider/Divider.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type DividerProps = {
|
||||
size?: "fullWidth" | "middle" | "inset";
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Divider({
|
||||
size = "fullWidth",
|
||||
children,
|
||||
className = "",
|
||||
}: DividerProps) {
|
||||
const getWidthClass = () => {
|
||||
switch (size) {
|
||||
case "fullWidth":
|
||||
return "w-full";
|
||||
case "middle":
|
||||
return "w-1/2 mx-auto";
|
||||
case "inset":
|
||||
return "w-1/3 ml-6";
|
||||
default:
|
||||
return "w-full";
|
||||
}
|
||||
};
|
||||
|
||||
if (children) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-4 text-md text-dark-700 dark:text-primary-100 nt-medium ${getWidthClass()} ${className}`}
|
||||
>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-primary-600 to-transparent rounded-full opacity-70" />
|
||||
<span className="whitespace-nowrap">{children}</span>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-primary-600 to-transparent rounded-full opacity-70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-px bg-gradient-to-r from-transparent via-dark-300 to-transparent rounded-full opacity-70 ${getWidthClass()} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
135
src/components/Drawer/Drawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { ArrowRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { useDrawerStore } from "../../context/zustand-store/appStore";
|
||||
import { checkIsMobile } from "../../utils/checkIsMobile";
|
||||
import { panelBgAndTextColors } from "../../data/getColorBasedOnMode";
|
||||
import Divider from "../Divider/Divider";
|
||||
|
||||
type Direction = "top" | "bottom" | "left" | "right" | null;
|
||||
|
||||
const Drawer: React.FC = () => {
|
||||
const { drawerState, closeDrawer } = useDrawerStore();
|
||||
const [shouldRender, setShouldRender] = useState<boolean>(
|
||||
!!drawerState.isOpen
|
||||
);
|
||||
const [animate, setAnimate] = useState<boolean>(false);
|
||||
const [mouseDownTarget, setMouseDownTarget] = useState<EventTarget | null>(
|
||||
null
|
||||
);
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rawDirection = drawerState.direction;
|
||||
|
||||
const direction: Direction = checkIsMobile()
|
||||
? "top"
|
||||
: rawDirection && ["top", "bottom", "left", "right"].includes(rawDirection)
|
||||
? (rawDirection as Direction)
|
||||
: "left";
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === mouseDownTarget && e.target === e.currentTarget) {
|
||||
closeDrawer();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setMouseDownTarget(e.target);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (drawerState.isOpen) {
|
||||
setShouldRender(true);
|
||||
requestAnimationFrame(() => {
|
||||
setAnimate(true);
|
||||
});
|
||||
} else {
|
||||
setAnimate(false);
|
||||
const timer = setTimeout(() => setShouldRender(false), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [drawerState.isOpen]);
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directionClasses: Record<Exclude<Direction, null>, string> = {
|
||||
top: "transform -translate-y-full",
|
||||
bottom: "transform translate-y-full",
|
||||
left: "transform -translate-x-full",
|
||||
right: "transform translate-x-full",
|
||||
};
|
||||
|
||||
const openClasses: Record<Exclude<Direction, null>, string> = {
|
||||
top: "top-0 left-0 right-0 w-full h-full sm:h-1/3 sm:w-auto translate-y-0 top-0",
|
||||
bottom:
|
||||
"bottom-0 left-0 right-0 w-full h-full sm:h-1/3 sm:w-auto translate-y-0 bottom-0",
|
||||
left: "left-0 top-0 bottom-0 h-full sm:w-1/2 md:w-2/4 lg:w-[400px] translate-x-0",
|
||||
right:
|
||||
"right-0 top-0 bottom-0 h-full sm:w-1/2 md:w-2/4 lg:w-[400px] translate-x-0",
|
||||
};
|
||||
|
||||
const isMobile = checkIsMobile();
|
||||
if ((direction === "left" || direction === "right") && isMobile) {
|
||||
openClasses.left = "left-0 top-0 bottom-0 w-full h-full translate-x-0";
|
||||
openClasses.right = "right-0 top-0 bottom-0 w-full h-full translate-x-0";
|
||||
}
|
||||
|
||||
const currentClasses =
|
||||
direction && animate && direction in openClasses
|
||||
? openClasses[direction]
|
||||
: direction
|
||||
? directionClasses[direction]
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 bg-opacity-50 transition-opacity duration-300
|
||||
${drawerState.isOpen ? "opacity-100" : "opacity-0 pointer-events-none"}
|
||||
flex z-[1000]`}
|
||||
onClick={handleBackdropClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`${panelBgAndTextColors} fixed shadow-lg p-4 transition-transform duration-300 ease-in-out
|
||||
${currentClasses} ${!animate ? "opacity-0" : "opacity-100 "}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{checkIsMobile() ? (
|
||||
<div className=" items-center justify-between pb-2">
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<button
|
||||
onClick={closeDrawer}
|
||||
className="text-primary-500 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<ArrowRightIcon className="w-6 h-6" />
|
||||
</button>
|
||||
<h2 className="text-base font-medium text-gray-900 dark:text-white">
|
||||
{drawerState?.title}
|
||||
</h2>
|
||||
<div className="w-5" />
|
||||
</div>
|
||||
<Divider />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between items-center border-b pb-2 ">
|
||||
<h2 className="text-lg ">{drawerState.title}</h2>
|
||||
<button
|
||||
onClick={closeDrawer}
|
||||
className="mr-1 text-primary-500 hover:text-red-700 dark:text-white rounded-full p-0.5 transition-colors cursor-pointer "
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 overflow-y-auto max-h-[94vh] scrollbar-hidden pb-24">
|
||||
{drawerState.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Drawer;
|
||||
54
src/components/Grid/Grid.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
|
||||
interface GridProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
container?: boolean;
|
||||
column?: boolean;
|
||||
isDashboard?: boolean;
|
||||
xs?: string;
|
||||
sm?: string;
|
||||
md?: string;
|
||||
lg?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const Grid: React.FC<GridProps> = ({
|
||||
children,
|
||||
className,
|
||||
container,
|
||||
column,
|
||||
isDashboard,
|
||||
xs,
|
||||
sm,
|
||||
md,
|
||||
lg,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const getWidthSizes = () => {
|
||||
let sizes;
|
||||
|
||||
if (xs || sm || md || lg) {
|
||||
sizes = `w-${xs} ${sm && `w-${sm}`} ${md && `md:w-${md}`} ${
|
||||
lg && `lg:w-${lg}`
|
||||
}`;
|
||||
}
|
||||
|
||||
return sizes;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
className={`${
|
||||
isDashboard && "shadow-xl rounded-2xl shadow-red-500/10"
|
||||
} ${className} ${container && "flex"} ${getWidthSizes()} ${
|
||||
column && "flex-col"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
123
src/components/Modal/Modal.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useModalStore } from "../../context/zustand-store/appStore";
|
||||
import { XMarkIcon } from "@heroicons/react/16/solid";
|
||||
import { FC, useRef, useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
const Modal: FC = () => {
|
||||
const { isOpen, title, content, closeModal, isFullSize } = useModalStore();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [mouseDownTarget, setMouseDownTarget] = useState<EventTarget | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === mouseDownTarget && e.target === e.currentTarget) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setMouseDownTarget(e.target);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = originalStyle;
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
className="hidden md:flex fixed inset-0 z-[999] backdrop-blur-[2px] justify-center items-center z-40 p-4"
|
||||
onClick={handleBackdropClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
className={`bg-white dark:bg-gray-900 rounded-xl shadow-2xl overflow-y-auto ${
|
||||
isFullSize ? "w-[80%] h-[90%] p-3" : "max-w-md w-full p-6"
|
||||
} max-h-[90vh]`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center justify-between pb-2 mb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2
|
||||
className={`${
|
||||
isFullSize ? "text-sm" : "text-md font-semibold"
|
||||
} text-gray-900 dark:text-white`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className={`rounded-full cursor-pointer ${
|
||||
isFullSize ? "border-1 " : "p-2"
|
||||
} hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors duration-200`}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden pt-1">{content}</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[999] md:hidden"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-opacity-30 backdrop-blur-[1px]"
|
||||
onClick={handleBackdropClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-x-0 bottom-0 border-t-5 border-primary-100 bg-white rounded-t-3xl shadow-2xl p-4 pt-2 max-h-[80vh] overflow-y-auto dark:bg-dark-700 dark:border-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
<div className="w-12 h-1.5 bg-gray-300 rounded-full mx-auto mb-4" />
|
||||
<div className="flex items-center justify-center pb-2 mb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-base font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="text-sm text-gray-800 dark:text-gray-200 mt-2 overflow-hidden pt-1">
|
||||
{content}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
225
src/components/PopOver/PopOver.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState, useRef, useEffect, createContext, useContext } from "react";
|
||||
import { Cog6ToothIcon } from "@heroicons/react/24/outline";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Grid } from "../Grid/Grid";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export const PopOverContext = createContext<boolean>(false);
|
||||
export const usePopOverContext = () => useContext(PopOverContext);
|
||||
|
||||
export const Popover = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode | any;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 640);
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
const buttonRect = buttonRef?.current?.getBoundingClientRect();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
!buttonRef.current?.contains(e.target as Node) &&
|
||||
!popoverRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
if (isMobile) document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
if (isMobile) document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen, isMobile]);
|
||||
|
||||
const togglePopover = () => setIsOpen(!isOpen);
|
||||
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const buttonRect = buttonRef.current?.getBoundingClientRect();
|
||||
const popoverElement = popoverRef.current;
|
||||
|
||||
if (!buttonRect) return;
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const scrollY = window.scrollY;
|
||||
const scrollX = window.scrollX;
|
||||
|
||||
const padding = 16;
|
||||
const maxPopoverHeight = viewportHeight - padding * 2;
|
||||
|
||||
let top = buttonRect.top + scrollY;
|
||||
let left = buttonRect.right + scrollX + 8;
|
||||
|
||||
if (popoverElement) {
|
||||
const popoverRect = popoverElement.getBoundingClientRect();
|
||||
|
||||
const popoverHeight = Math.min(popoverRect.height, maxPopoverHeight);
|
||||
const popoverWidth = popoverRect.width;
|
||||
|
||||
const popoverBottomInViewport = buttonRect.top + popoverHeight;
|
||||
|
||||
if (popoverBottomInViewport > viewportHeight - padding) {
|
||||
const overflow = popoverBottomInViewport - (viewportHeight - padding);
|
||||
|
||||
top = buttonRect.top + scrollY - overflow;
|
||||
|
||||
if (top < scrollY + padding) {
|
||||
top = scrollY + padding;
|
||||
}
|
||||
}
|
||||
|
||||
const popoverRightInViewport = buttonRect.right + popoverWidth + 8;
|
||||
if (popoverRightInViewport > viewportWidth - padding) {
|
||||
left = buttonRect.left + scrollX - popoverWidth - 8;
|
||||
|
||||
if (left < scrollX + padding) {
|
||||
left = scrollX + padding;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const estimatedHeight = Math.min(
|
||||
(Array.isArray(children) ? children.length : 1) * 50 + 100,
|
||||
maxPopoverHeight
|
||||
);
|
||||
|
||||
if (buttonRect.top + estimatedHeight > viewportHeight - padding) {
|
||||
const overflow =
|
||||
buttonRect.top + estimatedHeight - (viewportHeight - padding);
|
||||
|
||||
top = buttonRect.top + scrollY - overflow;
|
||||
if (top < scrollY + padding) {
|
||||
top = scrollY + padding;
|
||||
}
|
||||
}
|
||||
|
||||
const estimatedWidth = 300;
|
||||
if (buttonRect.right + estimatedWidth + 8 > viewportWidth - padding) {
|
||||
left = buttonRect.left + scrollX - estimatedWidth - 8;
|
||||
if (left < scrollX + padding) {
|
||||
left = scrollX + padding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
const timeoutId = setTimeout(updatePosition, 0);
|
||||
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
};
|
||||
}, [isOpen, isMobile, children]);
|
||||
|
||||
return (
|
||||
<PopOverContext.Provider value={true}>
|
||||
<div className={`relative ${className}`}>
|
||||
<motion.button
|
||||
ref={buttonRef}
|
||||
onClick={togglePopover}
|
||||
className={`p-2 rounded-full hover:bg-gray-100/50 dark:hover:bg-gray-800 transition-colors`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div animate={{ rotate: isOpen ? 90 : 0 }}>
|
||||
<Cog6ToothIcon
|
||||
className={`h-5 w-5 ${
|
||||
isOpen ? "text-primary-600" : "text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<AnimatePresence>
|
||||
<>
|
||||
{isMobile && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.4 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-9998 bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
ref={popoverRef}
|
||||
id="popover-content"
|
||||
initial={isMobile ? { y: "100%" } : { opacity: 0, y: -10 }}
|
||||
animate={isMobile ? { y: 0 } : { opacity: 1, y: 0 }}
|
||||
exit={isMobile ? { y: "100%" } : { opacity: 0, y: -10 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className={`z-9999 bg-white dark:bg-dark-700 rounded-lg shadow-lg ring-1 ring-black/10 dark:ring-white/10 ${
|
||||
isMobile
|
||||
? "fixed bottom-0 inset-x-0 rounded-t-3xl pb-[env(safe-area-inset-bottom)]"
|
||||
: "fixed max-h-[calc(100vh-32px)]"
|
||||
}`}
|
||||
style={
|
||||
!isMobile
|
||||
? {
|
||||
top: `${
|
||||
buttonRect?.bottom && buttonRect?.bottom > 700
|
||||
? position.top - 100
|
||||
: position.top
|
||||
}px`,
|
||||
left: `${position.left}px`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PopOverContext.Provider value={true}>
|
||||
<Grid
|
||||
container
|
||||
column
|
||||
className="gap-2 p-2 max-h-[calc(100vh-32px)] overflow-y-auto"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{children}
|
||||
</Grid>
|
||||
</PopOverContext.Provider>
|
||||
</motion.div>
|
||||
</>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</PopOverContext.Provider>
|
||||
);
|
||||
};
|
||||
114
src/components/PopOverButtons/PopOverButtons.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useModalStore } from "../../context/zustand-store/appStore";
|
||||
import { useUserProfileStore } from "../../context/zustand-store/userStore";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useApiMutation } from "../../utils/useApiRequest";
|
||||
import Button from "../Button/Button";
|
||||
import { Grid } from "../Grid/Grid";
|
||||
import { Tooltip } from "../Tooltip/Tooltip";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type Props = {
|
||||
api?: string;
|
||||
title?: string;
|
||||
getData?: () => void;
|
||||
access?: string;
|
||||
};
|
||||
|
||||
export const DeleteButtonForPopOver = ({
|
||||
api,
|
||||
title,
|
||||
getData,
|
||||
access = "",
|
||||
}: Props) => {
|
||||
const { openModal, closeModal } = useModalStore();
|
||||
const showToast = useToast();
|
||||
const { profile } = useUserProfileStore();
|
||||
|
||||
const mutation = useApiMutation({
|
||||
api: api || "",
|
||||
method: "delete",
|
||||
});
|
||||
|
||||
const ableToSeeButton = () => {
|
||||
if (!access) {
|
||||
return true;
|
||||
} else {
|
||||
const permissions = profile?.permissions || [];
|
||||
// Check if access exists in the permissions array (simple array of strings)
|
||||
return permissions.includes(access);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
await mutation.mutateAsync({});
|
||||
showToast("عملیات با موفقیت انجام شد", "success");
|
||||
|
||||
closeModal();
|
||||
if (getData) {
|
||||
getData();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.status === 400 || error?.status === 403) {
|
||||
showToast(
|
||||
error?.response?.data?.detail ||
|
||||
error?.response?.data?.message + " !",
|
||||
"error"
|
||||
);
|
||||
} else {
|
||||
showToast("مشکلی پیش آمده است!", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!ableToSeeButton()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="حذف" position="right">
|
||||
<Button
|
||||
variant="delete"
|
||||
onClick={() => {
|
||||
openModal({
|
||||
title: title || "از حذف این مورد مطمئنید؟",
|
||||
content: (
|
||||
<Grid
|
||||
container
|
||||
xs="full"
|
||||
column
|
||||
className="flex justify-center items-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full max-w-md p-4"
|
||||
>
|
||||
<Grid container className="flex-row space-y-0 space-x-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSubmit();
|
||||
}}
|
||||
fullWidth
|
||||
className="bg-[#eb5757] hover:bg-[#d44e4e] text-white py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md"
|
||||
>
|
||||
بله
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => closeModal()}
|
||||
fullWidth
|
||||
className="bg-gray-200 text-gray-700 hover:bg-gray-100 py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md"
|
||||
>
|
||||
خیر
|
||||
</Button>
|
||||
</Grid>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ReactElement } from "react";
|
||||
import { useModalStore } from "../../context/zustand-store/appStore";
|
||||
import { useUserProfileStore } from "../../context/zustand-store/userStore";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useApiMutation } from "../../utils/useApiRequest";
|
||||
import Button from "../Button/Button";
|
||||
import { Grid } from "../Grid/Grid";
|
||||
import { Tooltip } from "../Tooltip/Tooltip";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type Props = {
|
||||
api?: string;
|
||||
title?: string;
|
||||
tooltipText?: string;
|
||||
getData?: () => void;
|
||||
page?: string;
|
||||
access?: string;
|
||||
method?: "delete" | "post" | "put" | "patch";
|
||||
icon?: ReactElement;
|
||||
};
|
||||
|
||||
export const PopoverCustomModalOperation = ({
|
||||
api,
|
||||
title,
|
||||
method = "delete",
|
||||
tooltipText,
|
||||
getData,
|
||||
page = "",
|
||||
access = "",
|
||||
icon,
|
||||
}: Props) => {
|
||||
const { openModal, closeModal } = useModalStore();
|
||||
const showToast = useToast();
|
||||
const { profile } = useUserProfileStore();
|
||||
|
||||
const mutation = useApiMutation({
|
||||
api: api || "",
|
||||
method: method || "delete",
|
||||
});
|
||||
|
||||
const ableToSeeButton = () => {
|
||||
if (!access || !page) {
|
||||
return true;
|
||||
} else {
|
||||
const finded = profile?.permissions?.find(
|
||||
(item: any) => item.page_name === page
|
||||
);
|
||||
if (finded && finded.page_access.includes(access)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
await mutation.mutateAsync({});
|
||||
showToast("عملیات با موفقیت انجام شد", "success");
|
||||
|
||||
closeModal();
|
||||
if (getData) {
|
||||
getData();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.status === 400) {
|
||||
showToast(
|
||||
error?.response?.data?.detail ||
|
||||
error?.response?.data?.message + " !",
|
||||
"error"
|
||||
);
|
||||
} else {
|
||||
showToast("مشکلی پیش آمده است!", "error");
|
||||
}
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
if (!ableToSeeButton()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipText || ""} position="right">
|
||||
<Button
|
||||
icon={icon}
|
||||
onClick={() => {
|
||||
openModal({
|
||||
title: title || "آیا از انجام عملیات مطمئنید؟",
|
||||
content: (
|
||||
<Grid
|
||||
container
|
||||
xs="full"
|
||||
column
|
||||
className="flex justify-center items-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full max-w-md p-4"
|
||||
>
|
||||
<Grid container className="flex-row space-y-0 space-x-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSubmit();
|
||||
}}
|
||||
fullWidth
|
||||
className="bg-[#eb5757] hover:bg-[#d44e4e] text-white py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md"
|
||||
>
|
||||
بله
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => closeModal()}
|
||||
fullWidth
|
||||
className="bg-gray-200 text-gray-700 hover:bg-gray-100 py-3 rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-md"
|
||||
>
|
||||
خیر
|
||||
</Button>
|
||||
</Grid>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
70
src/components/RadioButton/RadioButton.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type RadioSize = "small" | "medium" | "large";
|
||||
|
||||
interface RadioButtonProps {
|
||||
name: string;
|
||||
value: string | any;
|
||||
checked?: boolean;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
isError?: boolean;
|
||||
size?: RadioSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeMap: Record<RadioSize, string> = {
|
||||
small: "w-3 h-3",
|
||||
medium: "w-4 h-4",
|
||||
large: "w-5 h-5",
|
||||
};
|
||||
|
||||
export const RadioButton: React.FC<RadioButtonProps> = ({
|
||||
name,
|
||||
value,
|
||||
checked = false,
|
||||
onChange,
|
||||
label,
|
||||
disabled = false,
|
||||
isError = false,
|
||||
size = "medium",
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={"w-full items-center flex sm:w-auto md:w-auto lg:w-auto"}>
|
||||
<label
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 cursor-pointer",
|
||||
disabled && "cursor-not-allowed opacity-60",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"appearance-none rounded-full w-5 h-5 border-2 transition-all duration-150",
|
||||
sizeMap[size],
|
||||
isError ? "border-red-500" : "border-dark-400",
|
||||
checked
|
||||
? "border-transparent bg-primary-600 dark:bg-dark-400 dark:ring-1 ring-gray-300 dark:ring-white"
|
||||
: "bg-white dark:bg-dark-600"
|
||||
)}
|
||||
/>
|
||||
{label && (
|
||||
<span
|
||||
className={`text-sm text-gray-700 dark:text-dark-100 select-none`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
src/components/RadioButton/RadioGroup.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { RadioButton, RadioSize } from "./RadioButton";
|
||||
import clsx from "clsx";
|
||||
import { inputWidths } from "../../data/getItemsWidth";
|
||||
import Typography from "../Typography/Typography";
|
||||
|
||||
interface RadioOption {
|
||||
value?: string | any;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface RadioGroupProps {
|
||||
name?: string;
|
||||
groupTitle?: string;
|
||||
options?: RadioOption[];
|
||||
value?: string | any;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
isError?: boolean;
|
||||
size?: RadioSize;
|
||||
direction?: "row" | "column";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const RadioGroup: React.FC<RadioGroupProps> = ({
|
||||
name = "",
|
||||
groupTitle = "",
|
||||
options = [],
|
||||
value = "",
|
||||
onChange,
|
||||
disabled = false,
|
||||
isError = false,
|
||||
size = "medium",
|
||||
direction = "column",
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex ",
|
||||
direction === "column"
|
||||
? "flex-col space-y-2 items-start"
|
||||
: "flex-row space-x-4 items-center",
|
||||
inputWidths,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{groupTitle && (
|
||||
<Typography
|
||||
color="text-gray-700 dark:text-primary-100"
|
||||
variant="body2"
|
||||
className="text-nowrap "
|
||||
>
|
||||
{groupTitle}
|
||||
</Typography>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<RadioButton
|
||||
key={option.value ?? ""}
|
||||
name={name}
|
||||
value={option.value ?? ""}
|
||||
label={option.label ?? ""}
|
||||
checked={value === option.value}
|
||||
onChange={onChange}
|
||||
disabled={option.disabled || disabled}
|
||||
isError={isError}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
170
src/components/ShowImage/ShowImage.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
XMarkIcon,
|
||||
ArrowPathIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import sampleImage from "../../assets/images/no-image.png";
|
||||
|
||||
const imageExtensions = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".bmp",
|
||||
".webp",
|
||||
".svg",
|
||||
];
|
||||
|
||||
interface ShowImageProps {
|
||||
src?: string;
|
||||
size?: number | string;
|
||||
className?: string;
|
||||
noOpen?: boolean;
|
||||
}
|
||||
|
||||
const ShowImage: React.FC<ShowImageProps> = ({
|
||||
src = sampleImage,
|
||||
size,
|
||||
className,
|
||||
noOpen = false,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
const handleOpen = () => {
|
||||
!noOpen && setOpen(true);
|
||||
};
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!src) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = src;
|
||||
const filename = src.split("/").pop() || "document";
|
||||
link.download = filename;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleRotate = () => {
|
||||
setRotation((prev) => prev + 90);
|
||||
};
|
||||
|
||||
const getFileExtension = () => {
|
||||
if (!src) return "";
|
||||
const filename = src.split("/").pop() || "";
|
||||
const lastDotIndex = filename.lastIndexOf(".");
|
||||
if (lastDotIndex === -1) return "";
|
||||
return filename.substring(lastDotIndex + 1).toLowerCase();
|
||||
};
|
||||
|
||||
const isImage = () => {
|
||||
if (!src) return false;
|
||||
const ext = getFileExtension();
|
||||
return imageExtensions.includes(`.${ext}`);
|
||||
};
|
||||
|
||||
if (!src) {
|
||||
return <span className="text-gray-400 italic">-</span>;
|
||||
}
|
||||
|
||||
if (!isImage()) {
|
||||
const ext = getFileExtension();
|
||||
const buttonText = ext ? `دانلود سند ${ext}` : "دانلود سند";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg shadow-lg transition-shadow duration-300 focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
type="button"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||
{buttonText}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
alt="thumbnail"
|
||||
onClick={handleOpen}
|
||||
className={`${className} cursor-pointer rounded-lg select-none transition-transform duration-300 ease-in-out hover:scale-105 ${
|
||||
size
|
||||
? typeof size === "number"
|
||||
? `w-[${size}px] h-[${size}px]`
|
||||
: `w-full h-full`
|
||||
: "w-16 h-16"
|
||||
}`}
|
||||
style={{
|
||||
width: typeof size === "number" ? size : undefined,
|
||||
height: typeof size === "number" ? size : undefined,
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-opacity-70 backdrop-blur-sm"
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.85, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="relative max-w-[90vw] max-h-[90vh] min-w-[40vw] min-h-[40vh] rounded-2xl overflow-hidden bg-white dark:bg-dark-600 shadow-2xl flex flex-col items-center justify-center"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt="full-size"
|
||||
style={{ transform: `rotate(${rotation}deg)` }}
|
||||
className="max-w-full max-h-[80vh] transition-transform duration-500 ease-in-out select-none"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
<div className="absolute top-4 right-4 flex space-x-3">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
title="دانلود تصویر"
|
||||
className="flex items-center cursor-pointer justify-center bg-white bg-opacity-90 hover:bg-opacity-100 p-3 rounded-full shadow-lg transition-shadow duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
type="button"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-6 h-6 text-primary-700" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRotate}
|
||||
title="چرخش تصویر"
|
||||
className="flex items-center cursor-pointer justify-center bg-white bg-opacity-90 hover:bg-opacity-100 p-3 rounded-full shadow-lg transition-shadow duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
type="button"
|
||||
>
|
||||
<ArrowPathIcon className="w-6 h-6 text-primary-700" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute cursor-pointer top-4 left-4 bg-white bg-opacity-90 hover:bg-opacity-100 p-3 rounded-full shadow-lg transition-shadow duration-200 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6 text-red-600" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowImage;
|
||||
226
src/components/ShowStringList/ShowStringList.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
XMarkIcon,
|
||||
HashtagIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
interface ShowStringListProps {
|
||||
strings: string[];
|
||||
title?: string;
|
||||
maxItems?: number;
|
||||
showSearch?: boolean;
|
||||
showNumbers?: boolean;
|
||||
className?: string;
|
||||
emptyMessage?: string;
|
||||
searchPlaceholder?: string;
|
||||
onItemClick?: (item: string, index: number) => void;
|
||||
}
|
||||
|
||||
const ShowStringList: React.FC<ShowStringListProps> = ({
|
||||
strings = [],
|
||||
title,
|
||||
maxItems = 50,
|
||||
showSearch = true,
|
||||
showNumbers = true,
|
||||
className = "",
|
||||
emptyMessage = "هیچ آیتمی یافت نشد",
|
||||
searchPlaceholder = "جستجو...",
|
||||
onItemClick,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
|
||||
const filteredStrings = useMemo(() => {
|
||||
if (!searchTerm.trim()) return strings.slice(0, maxItems);
|
||||
return strings
|
||||
.filter((str) => str.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.slice(0, maxItems);
|
||||
}, [strings, searchTerm, maxItems]);
|
||||
|
||||
const hasMoreItems = strings.length > maxItems;
|
||||
const hasSearchResults = searchTerm.trim() && filteredStrings.length > 0;
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setSearchTerm("");
|
||||
}, []);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: string, index: number) => {
|
||||
onItemClick?.(item, index);
|
||||
},
|
||||
[onItemClick]
|
||||
);
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 8, scale: 0.9, filter: "blur(4px)" },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
delay: i * 0.03,
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1] as const,
|
||||
filter: { duration: 0.3 },
|
||||
},
|
||||
}),
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -8,
|
||||
scale: 0.9,
|
||||
filter: "blur(4px)",
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
ease: [0.4, 0, 1, 1] as const,
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
scale: 1.02,
|
||||
y: -2,
|
||||
transition: { duration: 0.2, ease: [0.16, 1, 0.3, 1] as const },
|
||||
},
|
||||
tap: {
|
||||
scale: 0.98,
|
||||
transition: { duration: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.03,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (!strings || strings.length === 0) {
|
||||
return (
|
||||
<div className={`text-center py-8 ${className}`}>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{title && (
|
||||
<div className="flex items-center gap-2">
|
||||
<HashtagIcon className="w-4 h-4 text-primary-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{title}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({strings.length})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSearch && strings.length > 1 && (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setIsSearchFocused(false)}
|
||||
className={`w-full px-3 py-1.5 pr-10 text-sm border rounded-md transition-all duration-200 ${
|
||||
isSearchFocused
|
||||
? "border-primary-500 ring-1 ring-primary-500/30 shadow-sm"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
} bg-white dark:bg-dark-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none`}
|
||||
aria-label="جستجو در لیست"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="absolute left-2.5 top-1/2 transform -translate-y-1/2 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-full transition-colors"
|
||||
aria-label="پاک کردن جستجو"
|
||||
>
|
||||
<XMarkIcon className="w-3 h-3 text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasSearchResults && (
|
||||
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{filteredStrings.length} نتیجه از {strings.length} آیتم
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredStrings.map((str, index) => (
|
||||
<motion.button
|
||||
key={`${str}-${index}`}
|
||||
custom={index}
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onClick={() => handleItemClick(str, index)}
|
||||
className={`group inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium focus:outline-none focus:ring-2 focus:ring-primary-500/50 ${
|
||||
index % 2 === 0
|
||||
? "bg-linear-to-r from-primary-100/80 to-primary-50/80 dark:from-dark-600/80 dark:to-dark-700/80 text-primary-700 dark:text-primary-300 border border-primary-200/50 dark:border-dark-500/50"
|
||||
: "bg-linear-to-r from-secondary-100/80 to-secondary-50/80 dark:from-dark-700/80 dark:to-dark-800/80 text-secondary-700 dark:text-secondary-300 border border-secondary-200/50 dark:border-dark-600/50"
|
||||
} hover:shadow-lg hover:border-opacity-100`}
|
||||
aria-label={`آیتم ${index + 1}: ${str}`}
|
||||
>
|
||||
{showNumbers && (
|
||||
<span className="shrink-0 w-4 h-4 bg-white/60 dark:bg-black/30 rounded-full flex items-center justify-center text-[10px] font-bold">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate max-w-[180px]" title={str}>
|
||||
{str}
|
||||
</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{hasMoreItems && !searchTerm && (
|
||||
<div className="text-center">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded-md">
|
||||
و {strings.length - maxItems} آیتم دیگر...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchTerm && filteredStrings.length === 0 && (
|
||||
<div className="text-center py-6">
|
||||
<div className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
هیچ نتیجهای برای "{searchTerm}" یافت نشد
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowStringList;
|
||||
19
src/components/ShowWeight/ShowWeight.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
type Props = {
|
||||
weight: string;
|
||||
type?: string;
|
||||
};
|
||||
export const ShowWeight = ({ weight, type = "کیلوگرم" }: Props) => {
|
||||
return (
|
||||
<div className="!grid !justify-center !items-center">
|
||||
<span className="text-[14px] gap-1 justify-center items-center flex text-center mb-1 border-b-[0.5px] border-gray-400 dark:text-white">
|
||||
{weight?.toLocaleString()}{" "}
|
||||
<span className="sm:block md:hidden text-red-300 text-[9px] dark:text-white">
|
||||
{type}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-center select-none hidden md:block dark:text-white">
|
||||
{type}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
28
src/components/SvgImage copy/SvgImage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
|
||||
interface SVGImageProps extends React.SVGProps<SVGSVGElement> {
|
||||
src: React.FC<React.SVGProps<SVGSVGElement>> | any;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SVGImage: React.FC<SVGImageProps> = ({
|
||||
src: IconComponent,
|
||||
width = "30px",
|
||||
height = "30px",
|
||||
className = "",
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<IconComponent
|
||||
fill="currentColor"
|
||||
width={width}
|
||||
height={height}
|
||||
className={`inline-block ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SVGImage;
|
||||
30
src/components/SvgImage/SvgImage.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
interface SVGImageProps extends React.SVGProps<SVGSVGElement> {
|
||||
src: React.FC<React.SVGProps<SVGSVGElement>> | any;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SVGImage: React.FC<SVGImageProps> = ({
|
||||
src: IconComponent,
|
||||
width = "30px",
|
||||
height = "30px",
|
||||
className = "",
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<IconComponent
|
||||
fill="currentColor"
|
||||
width={width}
|
||||
height={height}
|
||||
className={`inline-block ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SVGImage;
|
||||
|
||||
|
||||