first commit

This commit is contained in:
2026-01-26 10:14:10 +03:30
commit 9a995d5109
160 changed files with 34879 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

182
src/Pages/Menu.tsx Normal file
View 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
View 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
View 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
View 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;

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

127
src/assets/fonts/fonts.css Normal file
View 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;
} */

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 199 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 221 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 201 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 198 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 233 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 222 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 219 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
src/assets/images/dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

BIN
src/assets/images/fav.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 B

BIN
src/assets/images/hen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
src/assets/images/light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

BIN
src/assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

BIN
src/assets/images/sheep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
src/assets/images/store.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

View 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">
هیچ نتیجهای برای &quot;{searchTerm}&quot; یافت نشد
</div>
</div>
)}
</div>
);
};
export default ShowStringList;

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

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

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

Some files were not shown because too many files have changed in this diff Show More