Compare commits

9 Commits
dev ... main

Author SHA1 Message Date
e51f998a91 feat: postal inquiry 2026-02-03 14:59:54 +03:30
67517e99c8 fix: ionspection add users and infractions 2026-02-03 12:31:56 +03:30
58ea683c1c feat: switch between provinces 2026-02-01 16:33:28 +03:30
438e46d9ae chore: remove comments 2026-02-01 16:33:28 +03:30
9b876c8dd7 update: combine menu and sidebar paths 2026-02-01 16:33:28 +03:30
25aefe2b49 update: combine menu and sidebar paths 2026-02-01 16:33:28 +03:30
8b5ec3c6f9 feat: edit user and add admin permission 2026-02-01 16:33:28 +03:30
62dad94d5d fix: tokn timeout redirect 2026-02-01 16:33:28 +03:30
1fab9dc2d0 Update dockerfile 2026-01-26 07:27:08 +00:00
19 changed files with 713 additions and 279 deletions

View File

@@ -1,5 +1,6 @@
FROM registry.hamdocker.ir/seniorkian/node:18-alpine FROM registry.hamdocker.ir/seniorkian/node:18-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./

View File

@@ -11,7 +11,7 @@ import { makeRouter } from "./routes/routes";
import { useDarkMode } from "./hooks/useDarkMode"; import { useDarkMode } from "./hooks/useDarkMode";
import { ItemWithSubItems } from "./types/userPermissions"; import { ItemWithSubItems } from "./types/userPermissions";
import { useFetchProfile } from "./hooks/useFetchProfile"; import { useFetchProfile } from "./hooks/useFetchProfile";
import { getInspectionMenuItems } from "./screen/SideBar"; import { getInspectionMenuItems } from "./config/menuItems";
const versionNumber = "/src/version.txt"; const versionNumber = "/src/version.txt";
import "./index.css"; import "./index.css";
@@ -127,7 +127,7 @@ export default function App() {
window.history.replaceState( window.history.replaceState(
{}, {},
document.title, document.title,
url.pathname + url.search url.pathname + url.search,
); );
} }
}, []); }, []);

View File

@@ -4,99 +4,26 @@ import { useUserProfileStore } from "../context/zustand-store/userStore";
import { getFaPermissions } from "../utils/getFaPermissions"; import { getFaPermissions } from "../utils/getFaPermissions";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { import {
UsersIcon,
DocumentTextIcon,
ChartBarIcon,
ChevronDownIcon, ChevronDownIcon,
UserIcon,
GlobeAsiaAustraliaIcon, GlobeAsiaAustraliaIcon,
MapPinIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { ItemWithSubItems } from "../types/userPermissions"; import { getInspectionMenuItems } from "../config/menuItems";
import { checkMenuPermission } from "../utils/checkMenuPermission";
const getInspectionMenuItems = ( const ADMIN_PROVINCES = [
permissions: string[] = [] { value: "hamedan", label: "همدان" },
): ItemWithSubItems[] => { { value: "markazi", label: "مرکزی" },
const items: ItemWithSubItems[] = []; ] as const;
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 = () => { export const Menu = () => {
const { profile } = useUserProfileStore(); const { profile, updateProfile } = useUserProfileStore();
const navigate = useNavigate(); const navigate = useNavigate();
const menuItems = getInspectionMenuItems(profile?.permissions || []); const menuItems = getInspectionMenuItems(profile?.permissions || []);
const [openIndex, setOpenIndex] = useState<number | null>(null); const [openIndex, setOpenIndex] = useState<number | null>(null);
const hasAdmin =
Array.isArray(profile?.permissions) &&
profile.permissions.includes("admin");
const currentProvince = profile?.province || "hamedan";
const toggleSubmenu = (index: number) => { const toggleSubmenu = (index: number) => {
setOpenIndex((prev) => (prev === index ? null : index)); setOpenIndex((prev) => (prev === index ? null : index));
@@ -110,7 +37,7 @@ export const Menu = () => {
منو منو
</h1> </h1>
<div className="mb-4"> <div className="mb-4 space-y-3">
<button <button
onClick={() => navigate({ to: "/" })} 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" 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"
@@ -118,6 +45,30 @@ export const Menu = () => {
<GlobeAsiaAustraliaIcon className="w-5 h-5" /> <GlobeAsiaAustraliaIcon className="w-5 h-5" />
<span>مشاهده نقشه</span> <span>مشاهده نقشه</span>
</button> </button>
{hasAdmin && (
<div className="flex flex-col gap-2 p-3 rounded-xl bg-white dark:bg-dark-700 border border-gray-200 dark:border-dark-600">
<div className="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-400">
<MapPinIcon className="w-4 h-4 shrink-0" />
<span>استان</span>
</div>
<div className="grid grid-cols-2 gap-2">
{ADMIN_PROVINCES.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => updateProfile({ province: value })}
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-all ${
currentProvince === value
? "bg-primary-500 text-white dark:bg-primary-600 shadow-md"
: "bg-gray-100 dark:bg-dark-600 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-dark-500"
}`}
>
{label}
</button>
))}
</div>
</div>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -0,0 +1,290 @@
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 } from "framer-motion";
import Typography from "../components/Typography/Typography";
import { useToast } from "../hooks/useToast";
import {
MapPinIcon,
BuildingOfficeIcon,
IdentificationIcon,
} from "@heroicons/react/24/outline";
import Button from "../components/Button/Button";
interface PostcodeInquiryData {
avenue: string;
buildingName: string;
description: string;
floorNo: number | null;
houseNo: string;
houseNoSpecified: boolean;
location: string;
locationCode: string;
locationType: string;
parish: string | null;
postCode: string;
preAvenue: string;
sideFloor: number | null;
state: string;
townShip: string;
village: string;
zone: string;
address: string;
hasError: boolean;
errorMessage: string | null;
errorCode: number;
}
interface PostcodeInquiryResponse {
status: boolean;
statusCode: number;
data: PostcodeInquiryData;
apiLogId: string;
}
const PostcodeInquiry: React.FC = () => {
const [postcode, setPostcode] = useState("");
const [inquiryData, setInquiryData] = useState<PostcodeInquiryData | null>(
null,
);
const showToast = useToast();
const inquiryMutation = useApiMutation<PostcodeInquiryResponse>({
api: "postcode-inquiry",
method: "get",
});
const handleSearch = async () => {
const trimmed = postcode.trim();
if (!trimmed) {
showToast("لطفا کد پستی را وارد کنید", "error");
return;
}
if (trimmed.length !== 10) {
showToast("کد پستی باید 10 رقم باشد", "error");
return;
}
try {
const result = await inquiryMutation.mutateAsync({ postcode: trimmed });
if (result?.status && result?.data) {
if (result.data.hasError) {
setInquiryData(null);
showToast(result.data.errorMessage || "نتیجه‌ای یافت نشد", "info");
} else {
setInquiryData(result.data);
}
} else {
setInquiryData(null);
showToast("نتیجه‌ای یافت نشد", "info");
}
} catch (error: any) {
console.error("Postcode inquiry error:", error);
showToast(
error?.response?.data?.message || "خطا در استعلام کد پستی",
"error",
);
setInquiryData(null);
}
};
const handlePostcodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const numericValue = value.replace(/\D/g, "").slice(0, 10);
setPostcode(numericValue);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleSearch();
}
};
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="کد پستی (10 رقم)"
value={postcode}
onChange={handlePostcodeChange}
onKeyPress={handleKeyPress}
fullWidth
/>
</Grid>
<Button
onClick={handleSearch}
disabled={inquiryMutation.isPending}
className="px-6 py-2.5"
>
{inquiryMutation.isPending ? "در حال جستجو..." : "جستجو"}
</Button>
</Grid>
</Grid>
</motion.div>
<AnimatePresence mode="popLayout">
{inquiryData && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
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">
<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">
<MapPinIcon 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"
>
نتیجه استعلام کد پستی
</Typography>
<Typography
variant="body2"
className="text-gray-600 dark:text-gray-400 mt-1"
>
کد پستی: {inquiryData.postCode}
</Typography>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{inquiryData.address && (
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg md:col-span-2">
<MapPinIcon 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>
<Typography
variant="body1"
className="text-gray-900 dark:text-gray-100 font-medium"
>
{inquiryData.address}
</Typography>
</div>
</div>
)}
{inquiryData.buildingName && (
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-dark-700 rounded-lg">
<BuildingOfficeIcon 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"
>
{inquiryData.buildingName}
</Typography>
</div>
</div>
)}
{inquiryData.state && (
<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"
>
{inquiryData.state}
</Typography>
</div>
</div>
)}
{inquiryData.townShip && (
<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"
>
{inquiryData.townShip}
</Typography>
</div>
</div>
)}
{inquiryData.locationCode && (
<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"
>
{inquiryData.locationCode}
</Typography>
</div>
</div>
)}
</div>
</Grid>
</div>
</motion.div>
)}
</AnimatePresence>
</Grid>
);
};
export default PostcodeInquiry;

View File

@@ -12,6 +12,7 @@ import { DeleteButtonForPopOver } from "../components/PopOverButtons/PopOverButt
import { getFaPermissions } from "../utils/getFaPermissions"; import { getFaPermissions } from "../utils/getFaPermissions";
import { getFaProvince } from "../utils/getFaProvince"; import { getFaProvince } from "../utils/getFaProvince";
import { getFaCityName } from "../utils/getFaCityName"; import { getFaCityName } from "../utils/getFaCityName";
import { Tooltip } from "../components/Tooltip/Tooltip";
const Users: React.FC = () => { const Users: React.FC = () => {
const { profile } = useUserProfileStore(); const { profile } = useUserProfileStore();
@@ -38,19 +39,32 @@ const Users: React.FC = () => {
)) || "-", )) || "-",
getFaProvince(item?.province || ""), getFaProvince(item?.province || ""),
getFaCityName(item?.city || ""), getFaCityName(item?.city || ""),
item?.mobile === profile?.mobile ? (
<Typography variant="body2" className="text-gray-400">
-
</Typography>
) : (
<Popover key={i}> <Popover key={i}>
<Tooltip title="ویرایش" position="right">
<Button
variant="edit"
access="add"
onClick={() => {
openDrawer({
title: "ویرایش کاربر",
content: (
<SubmitNewUser
province={profile?.province || ""}
onSuccess={refetch}
item={item}
/>
),
});
}}
/>
</Tooltip>
<DeleteButtonForPopOver <DeleteButtonForPopOver
access="add" access="add"
api={`users/${item?._id || item?.Id}`} api={`users/${item?._id || item?.Id}`}
getData={refetch} getData={refetch}
/> />
</Popover> </Popover>,
),
]; ];
}); });
setTableData(d); setTableData(d);

91
src/config/menuItems.tsx Normal file
View File

@@ -0,0 +1,91 @@
import {
UsersIcon,
DocumentTextIcon,
ChartBarIcon,
UserIcon,
} from "@heroicons/react/24/outline";
import { ItemWithSubItems } from "../types/userPermissions";
import { checkMenuPermission } from "../utils/checkMenuPermission";
export 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("../Pages/NationalInfo"),
},
{
name: "ladinginfo",
path: "/ladinginfo",
component: () => import("../Pages/LadingInfo"),
},
{
name: "veterinarytransfer",
path: "/veterinarytransfer",
component: () => import("../Pages/VeterinaryTransfer"),
},
{
name: "postcodeinquiry",
path: "/postcode-inquiry",
component: () => import("../Pages/PostcodeInquiry"),
},
],
});
}
if (checkMenuPermission("users", permissions)) {
items.push({
en: "users",
fa: "کاربران",
icon: () => <UsersIcon className="w-6 h-6" />,
subItems: [
{
name: "users",
path: "/users",
component: () => import("../Pages/Users"),
},
],
});
}
if (checkMenuPermission("inspections", permissions)) {
items.push({
en: "inspections",
fa: "سوابق بازرسی",
icon: () => <DocumentTextIcon className="w-6 h-6" />,
subItems: [
{
name: "inspections",
path: "/inspections",
component: () => import("../Pages/UserInspections"),
},
],
});
}
if (checkMenuPermission("statics", permissions)) {
items.push({
en: "statics",
fa: "آمار",
icon: () => <ChartBarIcon className="w-6 h-6" />,
subItems: [
{
name: "statics",
path: "/statics",
component: () => import("../Pages/Statics"),
},
],
});
}
return items;
};

View File

@@ -5,12 +5,13 @@ import { useDashboardTabStore } from "./dashboardTabStore";
interface UseUserProfileStore { interface UseUserProfileStore {
profile?: Record<string, any> | null; profile?: Record<string, any> | null;
setUserProfile: (profile: Record<string, any>) => void; setUserProfile: (profile: Record<string, any>) => void;
updateProfile: (updates: Partial<Record<string, any>>) => void;
clearProfile: () => void; clearProfile: () => void;
} }
const arePermissionsEqual = ( const arePermissionsEqual = (
permissions1?: string[], permissions1?: string[],
permissions2?: string[] permissions2?: string[],
): boolean => { ): boolean => {
if (!permissions1 && !permissions2) return true; if (!permissions1 && !permissions2) return true;
if (!permissions1 || !permissions2) return false; if (!permissions1 || !permissions2) return false;
@@ -24,7 +25,7 @@ const arePermissionsEqual = (
const areProfilesEqual = ( const areProfilesEqual = (
currentProfile: Record<string, any> | null | undefined, currentProfile: Record<string, any> | null | undefined,
newProfile: Record<string, any> newProfile: Record<string, any>,
): boolean => { ): boolean => {
if (!currentProfile) return false; if (!currentProfile) return false;
@@ -35,10 +36,10 @@ const areProfilesEqual = (
} }
const currentKeys = Object.keys(currentProfile).filter( const currentKeys = Object.keys(currentProfile).filter(
(key) => key !== "permissions" (key) => key !== "permissions",
); );
const newKeys = Object.keys(newProfile).filter( const newKeys = Object.keys(newProfile).filter(
(key) => key !== "permissions" (key) => key !== "permissions",
); );
if (currentKeys.length !== newKeys.length) return false; if (currentKeys.length !== newKeys.length) return false;
@@ -72,10 +73,15 @@ export const useUserProfileStore = create<UseUserProfileStore>()(
console.log("profile", profile); console.log("profile", profile);
} }
}, },
updateProfile: (updates) => {
const currentProfile = get().profile;
if (!currentProfile) return;
set({ profile: { ...currentProfile, ...updates } });
},
clearProfile: () => set({ profile: null }), clearProfile: () => set({ profile: null }),
}), }),
{ name: "userprofile" } { name: "userprofile" },
) ),
); );
interface UseUserStore { interface UseUserStore {
@@ -97,6 +103,6 @@ export const useUserStore = create<UseUserStore>()(
}), }),
{ {
name: "user", name: "user",
} },
) ),
); );

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useState } from "react";
import { motion, Variants } from "framer-motion"; import { motion, Variants } from "framer-motion";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useApiMutation } from "../../utils/useApiRequest"; import { useApiMutation } from "../../utils/useApiRequest";
@@ -52,12 +52,6 @@ const Login: React.FC = () => {
disableBackdrop: false, disableBackdrop: false,
}); });
useEffect(() => {
if (phoneNumber && phoneNumber.length >= 10) {
handleGetCaptcha();
}
}, [phoneNumber]);
const handleGetCaptcha = async () => { const handleGetCaptcha = async () => {
try { try {
const data = await mutationCaptcha.mutateAsync({ const data = await mutationCaptcha.mutateAsync({

View File

@@ -1,17 +1,103 @@
import Typography from "../../components/Typography/Typography"; import Typography from "../../components/Typography/Typography";
import { Grid } from "../../components/Grid/Grid"; import { motion, AnimatePresence } from "framer-motion";
import {
ExclamationTriangleIcon,
DocumentTextIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.05 },
},
exit: {
opacity: 0,
transition: { staggerChildren: 0.05, staggerDirection: -1 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
};
export const MainInfractions = ({ export const MainInfractions = ({
data, data,
handleUpdate, handleUpdate: _handleUpdate,
}: { }: {
data: any; data: any;
handleUpdate: () => void; handleUpdate: () => void;
}) => { }) => {
console.log(data, handleUpdate); const infractions = data?.infractions || [];
if (infractions.length === 0) {
return ( return (
<Grid container column> <motion.div
<Typography variant="body1">این بخش در دست توسعه است</Typography> initial={{ opacity: 0, scale: 0.96 }}
</Grid> animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="flex flex-col items-center justify-center py-12 px-6 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700"
>
<CheckCircleIcon className="w-14 h-14 text-emerald-500 dark:text-emerald-400 mb-4" />
<Typography
variant="body1"
className="text-gray-500 dark:text-gray-400 text-center"
>
تخلفی ثبت نشده است
</Typography>
</motion.div>
);
}
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
exit="exit"
className="flex flex-col gap-3"
>
<AnimatePresence mode="popLayout">
{infractions.map(
(
infraction: { _id: string; title: string; description?: string },
index: number,
) => (
<motion.div
key={infraction._id || index}
variants={itemVariants}
layout
className="group flex gap-4 p-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/80 hover:bg-gray-50 dark:hover:bg-gray-800 hover:border-red-200 dark:hover:border-red-900/50 shadow-sm hover:shadow-md transition-all duration-200"
>
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
</div>
<div className="flex-1 min-w-0">
<Typography
variant="subtitle2"
className="font-semibold mb-1 text-gray-900 dark:text-gray-100"
>
{index + 1}. {infraction.title}
</Typography>
{infraction.description && (
<div className="flex items-start gap-2 mt-2">
<DocumentTextIcon className="w-4 h-4 text-gray-400 dark:text-gray-500 flex-shrink-0 mt-0.5" />
<Typography
variant="body2"
className="text-gray-600 dark:text-gray-400 leading-relaxed"
>
{infraction.description}
</Typography>
</div>
)}
</div>
</motion.div>
),
)}
</AnimatePresence>
</motion.div>
); );
}; };

View File

@@ -119,7 +119,7 @@ const MainPlaceInfo: React.FC<MainPlaceInfoProps> = ({
const [personRegistryData, setPersonRegistryData] = const [personRegistryData, setPersonRegistryData] =
useState<PersonRegistryData | null>(null); useState<PersonRegistryData | null>(null);
const [unitRegistryData, setUnitRegistryData] = useState<UnitRegistryData[]>( const [unitRegistryData, setUnitRegistryData] = useState<UnitRegistryData[]>(
[] [],
); );
const [isLoadingRegistry, setIsLoadingRegistry] = useState(false); const [isLoadingRegistry, setIsLoadingRegistry] = useState(false);
const { openDrawer } = useDrawerStore(); const { openDrawer } = useDrawerStore();
@@ -201,7 +201,7 @@ const MainPlaceInfo: React.FC<MainPlaceInfoProps> = ({
const unitsCandidate = const unitsCandidate =
unitResult?.data ?? (Array.isArray(unitResult) ? unitResult : []); unitResult?.data ?? (Array.isArray(unitResult) ? unitResult : []);
setUnitRegistryData( setUnitRegistryData(
Array.isArray(unitsCandidate) ? unitsCandidate : [] Array.isArray(unitsCandidate) ? unitsCandidate : [],
); );
} catch (error: any) { } catch (error: any) {
console.error("Unit registry error:", error); console.error("Unit registry error:", error);
@@ -320,7 +320,6 @@ const MainPlaceInfo: React.FC<MainPlaceInfoProps> = ({
)} )}
</div> </div>
{/* Submit Inspection Button */}
<div className="pt-2"> <div className="pt-2">
<Button <Button
onClick={handleOpenInspection} onClick={handleOpenInspection}

View File

@@ -93,30 +93,28 @@ const MainSubmitInspection: React.FC<MainSubmitInspectionProps> = ({
{ key: "عمده فروش", value: "عمده فروش" }, { key: "عمده فروش", value: "عمده فروش" },
]; ];
const usersList = Array.isArray(usersData)
? usersData
: ((usersData as any)?.data ?? []);
const inspectorOptions = useMemo(() => { const inspectorOptions = useMemo(() => {
if (!usersData?.data) return []; if (!usersList?.length) return [];
return usersData.data return usersList
.filter( .filter((user: any) => user.province === profile?.province)
(user: any) =>
user.province === profile?.province &&
!user?.permissions?.includes("admin")
)
.map((user: any) => ({ .map((user: any) => ({
key: `${user.fullname} / ${user.mobile}`, key: `${user.fullname} / ${user.mobile}`,
value: `${user.fullname} / ${user.mobile}`, value: `${user.fullname} / ${user.mobile}`,
})); }));
}, [usersData, profile]); }, [usersList, profile]);
const defaultInspectorKeys = useMemo(() => { const defaultInspectorKeys = useMemo(() => {
if (!isEdit || !item?.inspectors) return []; if (!isEdit || !item?.inspectors) return [];
return item.inspectors.map((insp: any) => { return item.inspectors.map((insp: any) => {
if (typeof insp === "string") return insp; if (typeof insp === "string") return insp;
const user = usersData?.data?.find( const user = usersList?.find((u: any) => u.fullname === insp.fullname);
(u: any) => u.fullname === insp.fullname
);
return user ? `${insp.fullname} / ${user.mobile}` : insp.fullname; return user ? `${insp.fullname} / ${user.mobile}` : insp.fullname;
}); });
}, [item, isEdit, usersData]); }, [item, isEdit, usersList]);
const { const {
control, control,
@@ -150,17 +148,15 @@ const MainSubmitInspection: React.FC<MainSubmitInspectionProps> = ({
const watchedInfractions = watch("infractions"); const watchedInfractions = watch("infractions");
useEffect(() => { useEffect(() => {
if (isEdit && item?.inspectors && usersData?.data) { if (isEdit && item?.inspectors && usersList?.length) {
const inspectorStrings = item.inspectors.map((insp: any) => { const inspectorStrings = item.inspectors.map((insp: any) => {
if (typeof insp === "string") return insp; if (typeof insp === "string") return insp;
const user = usersData.data.find( const user = usersList.find((u: any) => u.fullname === insp.fullname);
(u: any) => u.fullname === insp.fullname
);
return user ? `${insp.fullname} / ${user.mobile}` : insp.fullname; return user ? `${insp.fullname} / ${user.mobile}` : insp.fullname;
}); });
setValue("inspectors", inspectorStrings); setValue("inspectors", inspectorStrings);
} }
}, [item, isEdit, usersData, setValue]); }, [item, isEdit, usersList, setValue]);
const submitInspectionMutation = useApiMutation({ const submitInspectionMutation = useApiMutation({
api: "inspections", api: "inspections",

View File

@@ -17,25 +17,42 @@ import {
zValidateAutoComplete, zValidateAutoComplete,
} from "../../data/getFormTypeErrors"; } from "../../data/getFormTypeErrors";
interface UserItem {
_id?: string;
Id?: string;
mobile?: string;
fullname?: string;
permissions?: string[];
province?: string;
city?: string;
}
interface SubmitNewUserProps { interface SubmitNewUserProps {
province: string; province: string;
onSuccess?: () => void; onSuccess?: () => void;
item?: UserItem | null;
} }
const schema = z.object({ const getSchema = (isEdit: boolean) =>
z.object({
mobile: zValidateMobile("موبایل"), mobile: zValidateMobile("موبایل"),
password: zValidateString("کلمه عبور"), password: isEdit ? z.string().optional() : zValidateString("کلمه عبور"),
fullname: zValidateString("نام کامل"), fullname: zValidateString("نام کامل"),
permissions: zValidateAutoComplete("دسترسی‌ها"), permissions: zValidateAutoComplete("دسترسی‌ها"),
city: zValidateAutoComplete("شهر"), city: zValidateAutoComplete("شهر"),
}); });
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<ReturnType<typeof getSchema>>;
export const SubmitNewUser: React.FC<SubmitNewUserProps> = ({ onSuccess }) => { export const SubmitNewUser: React.FC<SubmitNewUserProps> = ({
onSuccess,
item,
}) => {
const { profile } = useUserProfileStore(); const { profile } = useUserProfileStore();
const showToast = useToast(); const showToast = useToast();
const { closeDrawer } = useDrawerStore(); const { closeDrawer } = useDrawerStore();
const isEdit = !!item;
const schema = useMemo(() => getSchema(isEdit), [isEdit]);
const { const {
control, control,
@@ -44,7 +61,15 @@ export const SubmitNewUser: React.FC<SubmitNewUserProps> = ({ onSuccess }) => {
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<FormValues>({ } = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: item
? {
mobile: item.mobile ?? "",
password: "",
fullname: item.fullname ?? "",
permissions: Array.isArray(item.permissions) ? item.permissions : [],
city: item.city ? [item.city] : [],
}
: {
mobile: "", mobile: "",
password: "", password: "",
fullname: "", fullname: "",
@@ -53,11 +78,16 @@ export const SubmitNewUser: React.FC<SubmitNewUserProps> = ({ onSuccess }) => {
}, },
}); });
const submitUserMutation = useApiMutation({ const createUserMutation = useApiMutation({
api: "user", api: "user",
method: "post", method: "post",
}); });
const updateUserMutation = useApiMutation({
api: `user/${item?._id ?? item?.Id ?? ""}`,
method: "put",
});
const cityOptions = useMemo(() => { const cityOptions = useMemo(() => {
return getCitiesOfProvinceInfo(profile?.province || "").map((item) => ({ return getCitiesOfProvinceInfo(profile?.province || "").map((item) => ({
key: item.en, key: item.en,
@@ -65,18 +95,23 @@ export const SubmitNewUser: React.FC<SubmitNewUserProps> = ({ onSuccess }) => {
})); }));
}, [profile?.province]); }, [profile?.province]);
const hasAdminPermission = profile?.permissions?.includes("admin");
const permissionOptions = useMemo(() => { const permissionOptions = useMemo(() => {
return [ const options = [
{ key: "add", value: "ثبت کاربر" }, { key: "add", value: "ثبت کاربر" },
{ key: "submit", value: "ثبت بازرسی" }, { key: "submit", value: "ثبت بازرسی" },
]; ];
}, []); if (hasAdminPermission) {
options.push({ key: "admin", value: "ادمین" });
}
return options;
}, [hasAdminPermission]);
const onSubmit = async (data: FormValues) => { const onSubmit = async (data: FormValues) => {
try { try {
const payload = { const basePayload = {
mobile: data.mobile, mobile: data.mobile,
password: data.password,
fullname: data.fullname, fullname: data.fullname,
pic: "", pic: "",
permissions: data.permissions as string[], permissions: data.permissions as string[],
@@ -87,15 +122,34 @@ export const SubmitNewUser: React.FC<SubmitNewUserProps> = ({ onSuccess }) => {
: "", : "",
}; };
await submitUserMutation.mutateAsync(payload); if (isEdit) {
const payload =
data.password && String(data.password).trim() !== ""
? { ...basePayload, password: data.password }
: basePayload;
await updateUserMutation.mutateAsync(payload);
showToast("کاربر با موفقیت ویرایش شد", "success");
} else {
if (!data.password || String(data.password).trim() === "") {
showToast("کلمه عبور را وارد کنید", "error");
return;
}
const payload = { ...basePayload, password: data.password };
await createUserMutation.mutateAsync(payload);
showToast("کاربر با موفقیت ثبت شد", "success"); showToast("کاربر با موفقیت ثبت شد", "success");
}
closeDrawer(); closeDrawer();
if (onSuccess) { if (onSuccess) {
onSuccess(); onSuccess();
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
showToast("مشکلی پیش آمده است، ممکن است کاربر تکراری باشد!", "error"); showToast(
isEdit
? "مشکلی پیش آمده است!"
: "مشکلی پیش آمده است، ممکن است کاربر تکراری باشد!",
"error",
);
} }
}; };
@@ -198,9 +252,13 @@ export const SubmitNewUser: React.FC<SubmitNewUserProps> = ({ onSuccess }) => {
type="submit" type="submit"
variant="submit" variant="submit"
fullWidth fullWidth
disabled={isSubmitting || submitUserMutation.isPending} disabled={
isSubmitting ||
createUserMutation.isPending ||
updateUserMutation.isPending
}
> >
ثبت کاربر {isEdit ? "ذخیره تغییرات" : "ثبت کاربر"}
</Button> </Button>
</Grid> </Grid>
</form> </form>

View File

@@ -14,3 +14,4 @@ export const AUTO_LOGIN_WITH_PARAMS = "/autologin/:key/:province";
export const NATIONAL_INFO = "/nationalinfo"; export const NATIONAL_INFO = "/nationalinfo";
export const LADING_INFO = "/ladinginfo"; export const LADING_INFO = "/ladinginfo";
export const VETERINARY_TRANSFER = "/veterinarytransfer"; export const VETERINARY_TRANSFER = "/veterinarytransfer";
export const POSTCODE_INQUIRY = "/postcode-inquiry";

View File

@@ -11,6 +11,7 @@ import AutoLogin from "../Pages/AutoLogin";
import NationalInfo from "../Pages/NationalInfo"; import NationalInfo from "../Pages/NationalInfo";
import LadingInfo from "../Pages/LadingInfo"; import LadingInfo from "../Pages/LadingInfo";
import VeterinaryTransfer from "../Pages/VeterinaryTransfer"; import VeterinaryTransfer from "../Pages/VeterinaryTransfer";
import PostcodeInquiry from "../Pages/PostcodeInquiry";
interface Route { interface Route {
path: string; path: string;
@@ -30,6 +31,7 @@ export const getRoutes = (auth: string | null): Route[] => {
{ path: R.NATIONAL_INFO, component: NationalInfo }, { path: R.NATIONAL_INFO, component: NationalInfo },
{ path: R.LADING_INFO, component: LadingInfo }, { path: R.LADING_INFO, component: LadingInfo },
{ path: R.VETERINARY_TRANSFER, component: VeterinaryTransfer }, { path: R.VETERINARY_TRANSFER, component: VeterinaryTransfer },
{ path: R.POSTCODE_INQUIRY, component: PostcodeInquiry },
]; ];
if (checkIsMobile()) { if (checkIsMobile()) {

View File

@@ -17,15 +17,7 @@ export function makeRouter(auth: string | null) {
try { try {
const routeConfigs = getRoutes(auth); const routeConfigs = getRoutes(auth);
console.log(
"Creating router with routes:",
routeConfigs.length,
"auth:",
!!auth
);
if (routeConfigs.length === 0) { if (routeConfigs.length === 0) {
console.log("No routes found, adding default Auth route");
routeConfigs.push({ path: "/", component: Auth }); routeConfigs.push({ path: "/", component: Auth });
} }
@@ -57,7 +49,6 @@ export function makeRouter(auth: string | null) {
defaultPreload: "intent", defaultPreload: "intent",
}); });
console.log("Router created successfully");
return router; return router;
} catch (error) { } catch (error) {
console.error("Error creating router:", error); console.error("Error creating router:", error);

View File

@@ -4,24 +4,25 @@ import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { ChevronDownIcon } from "@heroicons/react/24/solid"; import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { useUserProfileStore } from "../context/zustand-store/userStore"; import { useUserProfileStore } from "../context/zustand-store/userStore";
import { ItemWithSubItems } from "../types/userPermissions";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { getFaPermissions } from "../utils/getFaPermissions"; import { getFaPermissions } from "../utils/getFaPermissions";
import { useSideBarStore } from "../context/zustand-store/appStore"; import { useSideBarStore } from "../context/zustand-store/appStore";
import SVGImage from "../components/SvgImage/SvgImage"; import SVGImage from "../components/SvgImage/SvgImage";
import { checkMenuPermission } from "../utils/checkMenuPermission"; import { getInspectionMenuItems } from "../config/menuItems";
import { import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
BuildingOfficeIcon, BuildingOfficeIcon,
UsersIcon,
DocumentTextIcon,
ChartBarIcon,
UserIcon,
GlobeAsiaAustraliaIcon, GlobeAsiaAustraliaIcon,
MapPinIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const ADMIN_PROVINCES = [
{ value: "hamedan", label: "همدان" },
{ value: "markazi", label: "مرکزی" },
] as const;
const containerVariants = { const containerVariants = {
hidden: {}, hidden: {},
visible: { transition: { staggerChildren: 0.03 } }, visible: { transition: { staggerChildren: 0.03 } },
@@ -69,91 +70,14 @@ const sidebarVariants = {
}, },
}; };
export 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("../Pages/NationalInfo"),
},
{
name: "ladinginfo",
path: "/ladinginfo",
component: () => import("../Pages/LadingInfo"),
},
{
name: "veterinarytransfer",
path: "/veterinarytransfer",
component: () => import("../Pages/VeterinaryTransfer"),
},
],
});
}
if (checkMenuPermission("users", permissions)) {
items.push({
en: "users",
fa: "کاربران",
icon: () => <UsersIcon className="w-6 h-6" />,
subItems: [
{
name: "users",
path: "/users",
component: () => import("../Pages/Users"),
},
],
});
}
if (checkMenuPermission("inspections", permissions)) {
items.push({
en: "inspections",
fa: "سوابق بازرسی",
icon: () => <DocumentTextIcon className="w-6 h-6" />,
subItems: [
{
name: "inspections",
path: "/inspections",
component: () => import("../Pages/UserInspections"),
},
],
});
}
if (checkMenuPermission("statics", permissions)) {
items.push({
en: "statics",
fa: "آمار",
icon: () => <ChartBarIcon className="w-6 h-6" />,
subItems: [
{
name: "statics",
path: "/statics",
component: () => import("../Pages/Statics"),
},
],
});
}
return items;
};
export const SideBar = () => { export const SideBar = () => {
const isMobile = checkIsMobile(); const isMobile = checkIsMobile();
const { profile } = useUserProfileStore(); const { profile, updateProfile } = useUserProfileStore();
const menuItems: ItemWithSubItems[] = getInspectionMenuItems( const menuItems = getInspectionMenuItems(profile?.permissions || []);
profile?.permissions || [] const hasAdmin =
); Array.isArray(profile?.permissions) &&
profile.permissions.includes("admin");
const currentProvince = profile?.province || "hamedan";
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { isSideBarOpen, toggleSideBar } = useSideBarStore(); const { isSideBarOpen, toggleSideBar } = useSideBarStore();
@@ -161,7 +85,7 @@ export const SideBar = () => {
const getOpenedItem = () => { const getOpenedItem = () => {
if (window.location.pathname !== "/") { if (window.location.pathname !== "/") {
const matchedIndex = menuItems.findIndex((item) => const matchedIndex = menuItems.findIndex((item) =>
item.subItems.some((sub) => sub.path === window.location.pathname) item.subItems.some((sub) => sub.path === window.location.pathname),
); );
return matchedIndex; return matchedIndex;
} else { } else {
@@ -180,7 +104,7 @@ export const SideBar = () => {
getFaPermissions(subItem.name) getFaPermissions(subItem.name)
.toLowerCase() .toLowerCase()
.includes(search.toLowerCase()) || .includes(search.toLowerCase()) ||
subItem.path.toLowerCase().includes(search.toLowerCase()) subItem.path.toLowerCase().includes(search.toLowerCase()),
); );
return { return {
@@ -191,7 +115,7 @@ export const SideBar = () => {
.filter( .filter(
(item) => (item) =>
item.subItems.length > 0 || item.subItems.length > 0 ||
item.fa.toLowerCase().includes(search.toLowerCase()) item.fa.toLowerCase().includes(search.toLowerCase()),
); );
if (isMobile) return null; if (isMobile) return null;
@@ -287,6 +211,30 @@ export const SideBar = () => {
<span>مشاهده نقشه</span> <span>مشاهده نقشه</span>
</motion.button> </motion.button>
)} )}
{isSideBarOpen && hasAdmin && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400">
<MapPinIcon className="w-4 h-4 shrink-0" />
<span>استان</span>
</div>
<div className="grid grid-cols-2 gap-1.5">
{ADMIN_PROVINCES.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => updateProfile({ province: value })}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
currentProvince === value
? "bg-primary-500 text-white dark:bg-primary-600 shadow-md"
: "bg-gray-100 dark:bg-dark-600 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-dark-500"
}`}
>
{label}
</button>
))}
</div>
</div>
)}
</div> </div>
</motion.div> </motion.div>
)} )}

View File

@@ -33,7 +33,7 @@ api.interceptors.request.use(
return config; return config;
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error),
); );
api.interceptors.response.use( api.interceptors.response.use(
@@ -50,12 +50,13 @@ api.interceptors.response.use(
rtl: true, rtl: true,
}); });
if (typeof logOut === "function") { if (typeof logOut === "function") {
window.location.href = "/";
logOut(); logOut();
} }
} }
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );
export default api; export default api;

View File

@@ -1,6 +1,6 @@
export const checkMenuPermission = ( export const checkMenuPermission = (
menuItemName: string, menuItemName: string,
userPermissions: string[] = [] userPermissions: string[] = [],
): boolean => { ): boolean => {
if (userPermissions.includes("admin")) { if (userPermissions.includes("admin")) {
return true; return true;
@@ -24,7 +24,7 @@ export const checkMenuPermission = (
export const checkRoutePermission = ( export const checkRoutePermission = (
routePath: string, routePath: string,
userPermissions: string[] = [] userPermissions: string[] = [],
): boolean => { ): boolean => {
if (userPermissions.includes("admin")) { if (userPermissions.includes("admin")) {
return true; return true;
@@ -35,6 +35,7 @@ export const checkRoutePermission = (
"/inspections": ["admin", "submit"], "/inspections": ["admin", "submit"],
"/statics": [], "/statics": [],
"/nationalinfo": ["admin"], "/nationalinfo": ["admin"],
"/postcode-inquiry": ["admin"],
}; };
const requiredPermissions = routePermissionMap[routePath] || []; const requiredPermissions = routePermissionMap[routePath] || [];

View File

@@ -1,6 +1,9 @@
export function getFaPermissions(permission: string) { export function getFaPermissions(permission: string) {
let faPermission = ""; let faPermission = "";
switch (permission) { switch (permission) {
case "admin":
faPermission = "ادمین";
break;
case "users": case "users":
faPermission = "کاربران"; faPermission = "کاربران";
break; break;
@@ -19,6 +22,9 @@ export function getFaPermissions(permission: string) {
case "veterinarytransfer": case "veterinarytransfer":
faPermission = "اطلاعات گواهی حمل"; faPermission = "اطلاعات گواهی حمل";
break; break;
case "postcodeinquiry":
faPermission = "استعلام کد پستی";
break;
case "main": case "main":
faPermission = "صفحه اصلی"; faPermission = "صفحه اصلی";
break; break;
@@ -28,9 +34,7 @@ export function getFaPermissions(permission: string) {
case "submit": case "submit":
faPermission = "ثبت بازرسی"; faPermission = "ثبت بازرسی";
break; break;
case "admin":
faPermission = "مدیر";
break;
default: default:
faPermission = permission; faPermission = permission;
break; break;