diff --git a/backend/app/auth.py b/backend/app/auth.py index 98b8691..9fd2719 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -145,18 +145,6 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: user.username = username await db.flush() - project_ids: list = [] - if user.role in ("supervisor", "pm"): - projects = ( - await db.execute( - select(Project) - .where(Project.pm_user_id == user.id) - .order_by(Project.created_at, Project.id) - ) - ).scalars().all() - project_ids = [p.id for p in projects] - project_id = project_ids[0] if project_ids else None - # لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش) panels: dict = {} panel_rows = ( @@ -166,6 +154,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: ).scalars().all() for row in panel_rows: panels[row.panel] = row.level + member_project_ids: list = [ row.project_id for row in ( @@ -177,6 +166,19 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: ).scalars().all() ] + pm_projects = ( + await db.execute( + select(Project) + .where(Project.pm_user_id == user.id) + .order_by(Project.created_at, Project.id) + ) + ).scalars().all() + pm_project_ids_list = [p.id for p in pm_projects] + + # ترکیب پروژه‌های تحت مدیریت مستقیم و پروژه‌های عضویت با حفظ اولویت و بدون تکرار + project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids)) + project_id = project_ids[0] if project_ids else None + display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id return UserContext( user=user, @@ -190,15 +192,22 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: async def pm_project_ids(db: AsyncSession, user: User) -> list: - """Every project id managed by the given PM/supervisor user (oldest first).""" - rows = ( + """Every project id managed or assigned to the given PM/supervisor user (oldest first).""" + pm_rows = ( await db.execute( select(Project.id) .where(Project.pm_user_id == user.id) .order_by(Project.created_at, Project.id) ) ).scalars().all() - return list(rows) + member_rows = ( + await db.execute( + select(UserProjectMembership.project_id) + .where(UserProjectMembership.user_id == user.id) + .order_by(UserProjectMembership.created_at) + ) + ).scalars().all() + return list(dict.fromkeys(list(pm_rows) + list(member_rows))) async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None: diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 560b7f1..191ef30 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -78,7 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe claims = await _verify_token(access_token) ctx = await context_from_claims(claims, db) projects = await _user_projects(db, ctx.user) - if ctx.role == "supervisor" and not projects: + has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids) + if ctx.role == "supervisor" and not has_projects: raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید") await analytics.track( "login_success", diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py index f8a03e6..66799ce 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -169,11 +169,14 @@ async def create_quote( ): # سطح limited: استعلام فقط برای پروژه‌های عضویت خودش if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin": - member = set(ctx.member_project_ids or []) - wanted = {pid for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid} + allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or []) + if ctx.project_id: + allowed.add(ctx.project_id) + allowed_str = {str(pid) for pid in allowed if pid} + wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid} if not wanted: raise ValidationError("برای افزودن استعلام، پروژه الزامی است") - if not wanted <= member: + if not wanted <= allowed_str: raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است") quote = await quote_service.create_quote(db, payload, actor) return {"data": await serialize_quote_with_children(db, quote)} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 180daf9..62ddd04 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -17,7 +17,7 @@ import uuid import httpx from fastapi import APIRouter, Depends -from sqlalchemy import delete, select, update, text +from sqlalchemy import delete, select, update, text, or_ from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_current_user, require_role @@ -100,7 +100,9 @@ async def user_to_dict(user: User, projects: list[Project] | None = None, db: As } if db is not None: out["panels"] = await _panels_of(db, user.id) - out["member_project_ids"] = await _member_project_ids_of(db, user.id) + member_pids = await _member_project_ids_of(db, user.id) + all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects])) + out["member_project_ids"] = all_pids return out @@ -112,13 +114,23 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont async def _user_projects(db: AsyncSession, user: User) -> list[Project]: - """Every project assigned to the user (supervisor or PM) (oldest first).""" + """Every project assigned to the user (supervisor or PM) (oldest first). + Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership). + """ if user.role not in ("supervisor", "pm"): return [] + member_subq = select(UserProjectMembership.project_id).where( + UserProjectMembership.user_id == user.id + ) rows = ( await db.execute( select(Project) - .where(Project.pm_user_id == user.id) + .where( + or_( + Project.pm_user_id == user.id, + Project.id.in_(member_subq), + ) + ) .order_by(Project.created_at, Project.id) ) ).scalars().all() @@ -246,12 +258,12 @@ async def create_user( async def update_user_role( user_id, payload: UserRoleUpdate, - ctx: UserContext = Depends(require_role("admin")), + ctx: UserContext = Depends(_org_manager), db: AsyncSession = Depends(get_db), ): - """Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not - assign or elevate roles. Revoking (or switching away from «pm») also - unassigns the user from their project.""" + """Set/clear a role — organization management (admin, procurement manager, PM).""" + if payload.role == "admin" and ctx.role != "admin": + raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند") user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") @@ -386,13 +398,14 @@ async def assign_user_project( @router.put("/{user_id}/panels") +@router.patch("/{user_id}/panels") async def set_user_panels( user_id, payload: UserPanelsUpdate, - _: UserContext = Depends(require_role("admin")), + _: UserContext = Depends(_org_manager), db: AsyncSession = Depends(get_db), ): - """تخصیص پنل + سطح دسترسی به کاربر — فقط ادمین.""" + """تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه).""" user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") @@ -409,10 +422,11 @@ async def set_user_panels( @router.put("/{user_id}/panel-projects") +@router.patch("/{user_id}/panel-projects") async def set_user_panel_projects( user_id, payload: UserPanelProjectsUpdate, - _: UserContext = Depends(require_role("admin")), + _: UserContext = Depends(_org_manager), db: AsyncSession = Depends(get_db), ): """عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited.""" diff --git a/frontend/index.html b/frontend/index.html index 701ce25..5d4a30a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,7 +13,7 @@ href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> - دفتر پیشنهادهای خرید — دفترچه تأمین + سامانه جامع مدیریت پروژه، تدارکات و انبار
diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest index 12b0389..69712ee 100644 --- a/frontend/public/manifest.webmanifest +++ b/frontend/public/manifest.webmanifest @@ -1,7 +1,7 @@ { - "name": "دفترچه تأمین — سامانه تدارکات و انبار", - "short_name": "دفترچه تأمین", - "description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی", + "name": "سامانه جامع مدیریت پروژه، تدارکات و انبار", + "short_name": "مدیریت پروژه و تأمین", + "description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری", "dir": "rtl", "lang": "fa", "start_url": "/", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3004ee2..b9b0b87 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -62,7 +62,10 @@ function RequireAuth({ children }: { children: React.ReactNode }) { if (!user) return ; if (user.role === "supervisor") { // اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود - if (!user.projects || user.projects.length === 0) { + const hasProjects = + (Boolean(user.projects && user.projects.length > 0)) || + (Boolean(user.member_project_ids && user.member_project_ids.length > 0)); + if (!hasProjects) { return (
@@ -85,42 +88,137 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
); } - // سرپرست پروژه فقط به انبار و PM دسترسی دارد - if (!pathname.startsWith("/inventory") && pathname !== "/panel/pm") { - return ; - } } + + // بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنل‌های تخصیصی: + const panels = user.panels ?? {}; + const isAdmin = user.role === "admin"; + const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0); + + const hasProcurement = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.procurement) + : user.role === "procurement_manager"); + + const hasInventory = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.inventory) + : user.role === "pm" || user.role === "supervisor"); + + const hasPm = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.pm) + : user.role === "pm" || user.role === "supervisor"); + + const isProcurementRoute = + pathname === "/" || + pathname.startsWith("/wizard") || + pathname.startsWith("/suppliers") || + pathname.startsWith("/projects") || + pathname.startsWith("/units") || + pathname.startsWith("/requester"); + + if (isProcurementRoute && !hasProcurement) { + if (hasInventory) return ; + if (hasPm) return ; + return ; + } + + if (pathname.startsWith("/inventory") && !hasInventory) { + if (hasProcurement) return ; + if (hasPm) return ; + return ; + } + return <>{children}; } // صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها): -// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات -// نقش‌های میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شده‌اند. function HomeRoute() { const user = useAuthStore((s) => s.user); const panels = user?.panels ?? {}; - if (panels.inventory) return ; - if (panels.procurement === "limited") return ; - // فقط پنل PM: مستقیم به پنل مدیریت پروژه - if (panels.pm && !panels.inventory && !panels.procurement) { - return ; + const isAdmin = user?.role === "admin"; + const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0); + + const hasProcurement = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.procurement) + : user?.role === "procurement_manager"); + + const hasInventory = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.inventory) + : user?.role === "pm" || user?.role === "supervisor"); + + const hasPm = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.pm) + : user?.role === "pm" || user?.role === "supervisor"); + + // ۱. اگر پنل تدارکات دارد: + if (hasProcurement) { + if (panels.procurement === "limited" && !isAdmin) { + return ; + } + return ; } - if (user?.role === "supervisor") return ; - if (user?.role === "pm") return ; - return ; + + // ۲. اگر پنل انبار دارد: + if (hasInventory) return ; + + // ۳. اگر پنل PM دارد: + if (hasPm) return ; + + // در صورتی که کاربر به هیچ پنلی دسترسی ندارد: + return ( +
+
+

عدم دسترسی به پنل‌های سامانه

+

+ حساب کاربری شما فعال است اما در حال حاضر به هیچ‌یک از پنل‌های سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید. +

+
+
+ ); } -// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های میراث، تدارکات full -// و تدارکات limited (محدود به پروژه‌های عضویت — صفحه /requester) +// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های تدارکات و پنل‌های تدارکات (full یا limited) function RequireProcurementWrite({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); const panels = user?.panels ?? {}; - if (user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm") { + const isAdmin = user?.role === "admin"; + const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0); + const hasProcurement = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.procurement) + : user?.role === "procurement_manager" || user?.role === "pm"); + + if (hasProcurement) { return <>{children}; } - if (panels.procurement === "full") return <>{children}; - if (panels.procurement === "limited") return ; - return ; + + const hasInventory = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.inventory) + : user?.role === "pm" || user?.role === "supervisor"); + if (hasInventory) return ; + + const hasPm = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.pm) + : user?.role === "pm" || user?.role === "supervisor"); + if (hasPm) return ; + + return ; } // پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود @@ -134,25 +232,52 @@ function PmPanelPage() { ); } -// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های میراث pm/supervisor — -// مدیر تدارکات (یا هر کاربر بدون پنل pm) دسترسی ندارد +// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های pm/supervisor function RequirePmPanel({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); const panels = user?.panels ?? {}; - const allowed = - user?.role === "admin" || - Boolean(panels.pm) || - user?.role === "pm" || - user?.role === "supervisor"; - if (!allowed) return ; + const isAdmin = user?.role === "admin"; + const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0); + const hasPm = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.pm) + : user?.role === "pm" || user?.role === "supervisor"); + + if (!hasPm) { + const hasProcurement = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.procurement) + : user?.role === "procurement_manager"); + if (hasProcurement) { + return ; + } + const hasInventory = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.inventory) + : user?.role === "pm" || user?.role === "supervisor"); + if (hasInventory) return ; + return ; + } return <>{children}; } // دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه function RequireOrganization({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; - if (!allowed) return ; + const isAdmin = user?.role === "admin"; + const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm"; + if (!allowed) { + const panels = user?.panels ?? {}; + const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0); + const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager"); + if (hasProcurement) return ; + const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor"); + if (hasInventory) return ; + return ; + } return <>{children}; } @@ -204,6 +329,7 @@ export default function App() { } /> + } /> s.user); - const isSupervisor = user?.role === "supervisor"; + const panels = user?.panels ?? {}; - // سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده می‌شود - if (isSupervisor) { - const supervisorItems: MenuItem[] = [ - { - label: "انبار", - to: "/inventory", - active: activeModule === "warehouse", - }, - { - label: "مدیریت پروژه", - to: "/panel/pm", - }, - { divider: true }, - { label: "خروج از حساب", onClick: logout }, - ]; - return ; - } + const isAdmin = user?.role === "admin"; + const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0); - const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; - const items: MenuItem[] = [ - { + const hasProcurement = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.procurement) + : user?.role === "procurement_manager"); + + const hasInventory = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.inventory) + : user?.role === "pm" || user?.role === "supervisor"); + + const hasPm = + isAdmin || + (hasPanelsConfig + ? Boolean(panels.pm) + : user?.role === "pm" || user?.role === "supervisor"); + + // فقط نقش‌های ادمین، مدیر پروژه و مدیر تدارکات به پنل سازمان‌دهی دسترسی دارند + const canManageOrg = + isAdmin || + user?.role === "procurement_manager" || + user?.role === "pm"; + + const items: MenuItem[] = []; + + if (hasInventory) { + items.push({ label: "انبار", to: "/inventory", active: activeModule === "warehouse", - }, - ]; + }); + } - if (canSwitchToProcurement) { + if (hasProcurement && canSwitchToProcurement) { items.push({ label: "تدارکات", - to: "/", + to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/", active: activeModule === "procurement", }); } - items.push({ - label: "مدیریت پروژه", - to: "/panel/pm", - }); + if (hasPm) { + items.push({ + label: "مدیریت پروژه", + to: "/panel/pm", + }); + } - if (canManage) { + if (canManageOrg) { items.push({ label: "سازمان‌دهی", to: "/organization", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1b45bb4..8f25a33 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -250,6 +250,10 @@ async function patchData(path: string, body: unknown): Promise { const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); return res.data; } +async function putData(path: string, body: unknown): Promise { + const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) }); + return res.data; +} async function deleteData(path: string): Promise { const res = await request<{ data: T }>(path, { method: "DELETE" }); return res.data; @@ -1663,9 +1667,9 @@ export const apiV26 = { patchData(`/users/${id}`, { role }), // لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین) setPanels: async (id: string, panels: Record): Promise => - patchData(`/users/${id}/panels`, { panels }), + putData(`/users/${id}/panels`, { panels }), setPanelProjects: async (id: string, project_ids: string[]): Promise => - patchData(`/users/${id}/panel-projects`, { project_ids }), + putData(`/users/${id}/panel-projects`, { project_ids }), assignProject: async (id: string, projectId: string | null): Promise => postData(`/users/${id}/project`, { project_id: projectId }), // همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند diff --git a/frontend/src/pages/Auth/LoginPage.tsx b/frontend/src/pages/Auth/LoginPage.tsx index 360988b..c422195 100644 --- a/frontend/src/pages/Auth/LoginPage.tsx +++ b/frontend/src/pages/Auth/LoginPage.tsx @@ -77,7 +77,10 @@ export function LoginPage() { toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید"); return; } - if (user.role === "supervisor" && (!user.projects || user.projects.length === 0)) { + const hasProjects = + (Boolean(user.projects && user.projects.length > 0)) || + (Boolean(user.member_project_ids && user.member_project_ids.length > 0)); + if (user.role === "supervisor" && !hasProjects) { toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید"); return; } @@ -86,9 +89,23 @@ export function LoginPage() { // تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*) identifyUser({ id: user.id, name: user.display_name, role: user.role }); track("login_success", { role: user.role }); - toast.success(`خوش آمدید، ${user.display_name}`); - // سرپرست پروژه → پنل مدیریت پروژه (PM)؛ بقیه نقش‌ها → دفتر پیشنهادهای خرید - navigate(user.role === "supervisor" ? "/panel/pm" : "/"); + // هدایت پس از ورود بر اساس پنل‌ها یا نقش: + const panels = user.panels ?? {}; + if (panels.procurement === "full") { + navigate("/"); + } else if (panels.procurement === "limited") { + navigate("/requester"); + } else if (panels.inventory) { + navigate("/inventory"); + } else if (panels.pm) { + navigate("/panel/pm"); + } else if (user.role === "supervisor") { + navigate("/panel/pm"); + } else if (user.role === "pm") { + navigate("/inventory"); + } else { + navigate("/"); + } } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در ورود"); } finally { @@ -277,10 +294,21 @@ export function LoginPage() {
-

سامانه تدارکات و انبار

-

- برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید +

سامانه جامع مدیریت پروژه، تدارکات و انبار

+

+ درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری

+
+ + مدیریت پروژه + + + تدارکات + + + انبارداری + +
diff --git a/frontend/src/pages/Dashboard/RequesterHome.tsx b/frontend/src/pages/Dashboard/RequesterHome.tsx index 64032b6..b0617e5 100644 --- a/frontend/src/pages/Dashboard/RequesterHome.tsx +++ b/frontend/src/pages/Dashboard/RequesterHome.tsx @@ -1,28 +1,68 @@ +import { useMemo } from "react"; import { Link } from "react-router-dom"; import { FilePlus2, Info } from "lucide-react"; import { Header, PageLayout } from "../../components/layout/Header"; +import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { useAuthStore } from "../../store/auth"; import { useProjects } from "../../hooks"; import { faNum } from "../../lib/formatters"; -// خانه حداقلی کاربرِ «تدارکات — سطح limited»: فقط افزودن استعلام برای -// پروژه‌های عضویت خودش. هیچ سربرگ دیگری از پنل تدارکات دیده نمی‌شود. +// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژه‌های تخصیص‌یافته export function RequesterHome() { const user = useAuthStore((s) => s.user); const projects = useProjects(); - const memberIds = new Set(user?.member_project_ids ?? []); - const myProjects = memberIds.size - ? projects.filter((p) => memberIds.has(p.id)) - : []; + + // پروژه‌های کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژه‌های عمومی) + const myProjects = useMemo(() => { + const list: { id: string; name: string }[] = []; + const seen = new Set(); + + // ۱. پروژه‌های موجود در آرایه user.projects + for (const p of user?.projects ?? []) { + if (p.id && !seen.has(p.id)) { + seen.add(p.id); + list.push({ id: p.id, name: p.name }); + } + } + + // ۲. پروژه‌ی اختصاصی فعال (project_id) + if (user?.project_id && !seen.has(user.project_id)) { + seen.add(user.project_id); + list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیص‌یافته" }); + } + + // ۳. پروژه‌های عضویت member_project_ids تطبیق‌یافته با لیست پروژه‌ها + const memberIdSet = new Set(user?.member_project_ids ?? []); + for (const p of projects) { + if (memberIdSet.has(p.id) && !seen.has(p.id)) { + seen.add(p.id); + list.push({ id: p.id, name: p.name }); + } + } + + // ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد + if (list.length === 0 && user?.project_name) { + const match = projects.find((p) => p.name === user.project_name); + if (match && !seen.has(match.id)) { + list.push({ id: match.id, name: match.name }); + } + } + + return list; + }, [user, projects]); return (
-
+
} + />
- شما فقط مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید. + شما مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
@@ -36,8 +76,8 @@ export function RequesterHome() { {myProjects.map((p) => (
🏢 {p.name} @@ -50,7 +90,10 @@ export function RequesterHome() { ))}
- + افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال) @@ -60,3 +103,4 @@ export function RequesterHome() {
); } + diff --git a/frontend/src/pages/Organization/OrganizationPage.tsx b/frontend/src/pages/Organization/OrganizationPage.tsx index 6fbff6e..81db82e 100644 --- a/frontend/src/pages/Organization/OrganizationPage.tsx +++ b/frontend/src/pages/Organization/OrganizationPage.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; import { useAuthStore } from "../../store/auth"; -import { apiV26, type UserRow } from "../../lib/api"; +import { api, apiV26, type UserRow } from "../../lib/api"; +import type { Project } from "../../lib/types"; import { refreshProcurementStore, useProjects } from "../../hooks"; import { faNum } from "../../lib/formatters"; import { ProjectMultiSelect } from "../../components/ProjectMultiSelect"; @@ -17,10 +18,12 @@ const ROLE_LABELS: Record = { supervisor: "سرپرست پروژه", }; -// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها به سرپرستان +// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها و پنل‌ها export function OrganizationPage() { const user = useAuthStore((s) => s.user); - const projects = useProjects(); + const storeProjects = useProjects(); + const [directProjects, setDirectProjects] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(false); const [users, setUsers] = useState([]); const [loadingUsers, setLoadingUsers] = useState(true); @@ -28,11 +31,25 @@ export function OrganizationPage() { const [busyId, setBusyId] = useState(null); const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; - // فقط ادمین می‌تواند نقش‌ها را تغییر دهد؛ مدیران می‌توانند پروژه‌ها را به سرپرستان تخصیص دهند - const canChangeRoles = user?.role === "admin"; + const canChangeRoles = canManage; + + const fetchProjects = useCallback(async () => { + try { + setLoadingProjects(true); + const list = await api.projects.list(); + if (list && Array.isArray(list)) { + setDirectProjects(list); + } + } catch { + // fallback silently + } finally { + setLoadingProjects(false); + } + }, []); const loadUsers = useCallback(async () => { try { + setLoadingUsers(true); const list = await apiV26.users.list(); setUsers(list); } catch (err) { @@ -43,8 +60,13 @@ export function OrganizationPage() { }, []); useEffect(() => { - if (canManage) void loadUsers(); - }, [canManage, loadUsers]); + if (canManage) { + void loadUsers(); + void fetchProjects(); + } + }, [canManage, loadUsers, fetchProjects]); + + const projects = storeProjects.length > 0 ? storeProjects : directProjects; const syncUsers = async () => { setSyncing(true); @@ -57,6 +79,7 @@ export function OrganizationPage() { : "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد", ); refreshProcurementStore(); + void fetchProjects(); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor"); } finally { @@ -91,39 +114,8 @@ export function OrganizationPage() { } }; - const assignedProjectsOf = (u: UserRow) => - u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []); - - const syncUserProjects = async (u: UserRow, nextIds: string[]) => { - const current = assignedProjectsOf(u).map((p) => p.id); - const added = nextIds.filter((id) => !current.includes(id)); - const removed = current.filter((id) => !nextIds.includes(id)); - if (!added.length && !removed.length) return; - setBusyId(u.id); - try { - for (const pid of added) await apiV26.projects.setPm(pid, u.id); - for (const pid of removed) await apiV26.projects.setPm(pid, null); - const names = nextIds - .map((id) => projects.find((p) => p.id === id)?.name) - .filter(Boolean) - .join("، "); - toast.success( - nextIds.length - ? `پروژه‌های «${u.first_name} ${u.last_name}» به‌روزرسانی شد: ${names} ✓` - : `«${u.first_name} ${u.last_name}» از همه‌ی پروژه‌ها جدا شد`, - ); - await loadUsers(); - refreshProcurementStore(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها"); - await loadUsers(); - } finally { - setBusyId(null); - } - }; - // ---------- لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای ---------- - const setPanel = async (u: UserRow, panel: "procurement" | "inventory", level: "" | "full" | "limited") => { + const setPanel = async (u: UserRow, panel: "procurement" | "inventory" | "pm", level: "" | "full" | "limited") => { setBusyId(u.id); const next = { ...(u.panels ?? {}) }; if (level) next[panel] = level; @@ -157,12 +149,14 @@ export function OrganizationPage() { const panelLabel = (level: string | undefined) => level === "full" ? "کامل" : level === "limited" ? "محدود" : "—"; - // سرپرستان تخصیص‌یافته به یک پروژه + // سرپرستان و مسئولان تخصیص‌یافته به یک پروژه const supervisorsOf = (projectId: string) => users.filter( (u) => - (u.role === "supervisor" || u.role === "pm") && - (u.projects?.some((p) => p.id === projectId) || u.project_id === projectId), + (u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) && + (u.projects?.some((p) => p.id === projectId) || + u.project_id === projectId || + u.member_project_ids?.includes(projectId)), ); return ( @@ -172,34 +166,33 @@ export function OrganizationPage() { brand menu={} /> - -
- ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌ها در این پنل توسط ادمین تعیین می‌گردند. - مدیران پروژه و تدارکات می‌توانند پروژه‌ها را به سرپرستان پروژه تخصیص دهند. + +
+ ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌های سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین می‌گردد.
- {/* ---------- بخش ۱: جدول کاربران و نقش‌ها ---------- */} + {/* ---------- بخش ۱: جدول یکپارچه کاربران، نقش‌ها و سطوح دسترسی پنل‌ها ---------- */}
-

👥 کاربران و نقش‌ها

+

👥 کاربران، نقش‌ها و سطوح دسترسی پنل‌ها

- تعیین نقش کاربران و تخصیص پروژه‌ها به سرپرستان پروژه + تعیین نقش سازمانی، تخصیص پنل‌ها (تدارکات، انبار، PM) و مشخص نمودن پروژه‌های عضویت کاربر

{loadingUsers ? ( -
در حال بارگذاری کاربران…
+
در حال بارگذاری کاربران…
) : !users.length ? ( -
+
کاربری ثبت نشده است — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
) : ( @@ -207,210 +200,394 @@ export function OrganizationPage() { - - - - - + + + + + + - {users.map((u) => ( - - - - - + {/* ۱. نام و نام کاربری */} + - - - ))} + + + {/* ۲. نقش سازمانی */} + + + {/* ۳. پنل تدارکات */} + + + {/* ۴. پنل انبار */} + + + {/* ۵. پنل مدیریت پروژه (PM) */} + + + {/* ۶. پروژه‌های عضویت */} + + + ); + })}
نامنام کاربرینقشپروژه‌های تخصیص‌یافته (سرپرست پروژه)وضعیتنام و مشخصاتنقش سازمانیپنل تدارکاتپنل انبارپنل مدیریت پروژه (PM)پروژه‌های عضویت (محدوده سطح محدود)
- {u.first_name} {u.last_name} - - {u.username || "—"} - - {canChangeRoles ? ( - - ) : ( - - {ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"} - - )} - - {u.role === "supervisor" || u.role === "pm" ? ( -
- ({ id: p.id, name: p.name }))} - values={assignedProjectsOf(u).map((p) => p.id)} - onChange={(nextIds) => void syncUserProjects(u, nextIds)} - disabled={busyId === u.id} - /> + {users.map((u) => { + const isAdmin = u.role === "admin"; + const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm); + + return ( +
+
+
+ + {u.first_name} {u.last_name} + + + ({u.username || "—"}) + +
+ {busyId === u.id && ( + + در حال ذخیره تغییرات… + + )}
- ) : ( - - {u.role === "admin" - ? "دسترسی کامل به همه پروژه‌ها" - : u.role === "procurement_manager" - ? "دسترسی کامل مدیریتی" - : "—"} - - )} -
- {busyId === u.id ? ( - در حال ذخیره… - ) : ( - ✓ ثبت‌شده - )} -
+ {canChangeRoles ? ( + + ) : ( + + {ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"} + + )} + + {isAdmin ? ( + کامل (ادمین) + ) : canChangeRoles ? ( + + ) : ( + {panelLabel(u.panels?.procurement)} + )} + + {isAdmin ? ( + کامل (ادمین) + ) : canChangeRoles ? ( + + ) : ( + {panelLabel(u.panels?.inventory)} + )} + + {isAdmin ? ( + کامل (ادمین) + ) : canChangeRoles ? ( + + ) : ( + {panelLabel(u.panels?.pm)} + )} + + {isAdmin ? ( + دسترسی خودکار به تمام پروژه‌ها + ) : hasPanel ? ( +
+ ({ id: p.id, name: p.name }))} + values={u.member_project_ids ?? []} + onChange={(nextIds) => void setMemberProjects(u, nextIds)} + disabled={busyId === u.id} + /> +
+ ) : ( + — ابتدا پنل تخصیص دهید — + )} +
-
- سرپرستان پروژه‌ای که پروژه‌ای به آن‌ها تخصیص داده نشده باشد، تا زمان تخصیص پروژه اجازه ورود به سامانه را نخواهند داشت. -
)}
{/* ---------- بخش ۲: کارت‌های پروژه‌ها و سرپرستان مسئول ---------- */}
-

🏢 پروژه‌ها و سرپرستان مسئول

-
- {projects.map((p) => { - const sups = supervisorsOf(p.id); - return ( -
-
-

🏢 {p.name}

- - {p.status || "پروژه فعال"} - -
- {p.description && ( -

{p.description}

- )} -
-
👤 سرپرست(های) پروژه:
- {!sups.length ? ( -
- ⚠️ سرپرستی تعیین نشده است — از جدول بالا پروژه را به سرپرست تخصیص دهید. -
- ) : ( -
- {sups.map((s) => ( -
- {s.first_name} {s.last_name} - ({s.username}) -
- ))} -
- )} -
-
- ); - })} -
-
- {/* ---------- بخش ۳: پنل‌ها و سطوح دسترسی (جایگزین نقش‌ها) ---------- */} -
-
-

🔑 پنل‌ها و سطوح دسترسی

-

- به هر شخص پنل «تدارکات» یا «انبار» را تخصیص دهید. تدارکات — کامل: تغییر در کل پنل؛ محدود: فقط افزودن - استعلام برای پروژه‌های عضویت. انبار — کامل: همه پروژه‌ها بدون تغییر موجودی؛ محدود: فقط پروژه‌های عضویت با - امکان تغییر موجودی. ادمین دست‌نخورده است. -

+
+
+

🏢 پروژه‌ها و سرپرستان مسئول

+

+ مشاهده وضعیت پروژه‌ها و سرپرستان یا اعضای تخصیص‌یافته به هر پروژه +

+
+ {loadingProjects && ( + در حال به‌روزرسانی پروژه‌ها… + )}
-
- - - - - - - - - - - {users.map((u) => { - if (u.role === "admin") { - return ( - - - - - ); - } - const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory); - return ( - - - - - - - ); - })} - -
نامپنل تدارکاتپنل انبارپروژه‌های عضویت (محدوده سطح محدود)
{u.first_name} {u.last_name} - ادمین — دسترسی کامل به همه پنل‌ها (دست‌نخورده) -
- {u.first_name} {u.last_name} - ({u.username || "—"}) - - {canChangeRoles ? ( - - ) : ( - {panelLabel(u.panels?.procurement)} - )} - - {canChangeRoles ? ( - - ) : ( - {panelLabel(u.panels?.inventory)} - )} - - {hasPanel ? ( -
- ({ id: p.id, name: p.name }))} - values={u.member_project_ids ?? []} - onChange={(nextIds) => void setMemberProjects(u, nextIds)} - disabled={busyId === u.id} - /> -
- ) : ( - — ابتدا پنل تخصیص دهید — - )} -
+ {loadingProjects && !projects.length ? ( +
+ در حال دریافت اطلاعات پروژه‌ها… +
+ ) : !projects.length ? ( +
+ پروژه‌ای برای نمایش یافت نشد یا در دسترس نیست. + +
+ ) : ( +
+ {projects.map((p) => { + const sups = supervisorsOf(p.id); + return ( +
+
+
+

🏢 {p.name}

+ + {p.status || "پروژه فعال"} + +
+ {p.description && ( +

{p.description}

+ )} +
+ +
+
👤 سرپرست(های) مسئول:
+ {!sups.length ? ( +
+ ⚠️ سرپرستی تعیین نشده است — از جدول بالا، این پروژه را به عنوان عضویت تخصیص دهید. +
+ ) : ( +
+ {sups.map((s) => ( +
+ {s.first_name} {s.last_name} + ({s.username || ROLE_LABELS[s.role ?? ""] || "عضو"}) +
+ ))} +
+ )} +
+
+ ); + })} +
+ )} +
+ + {/* ---------- بخش ۴: راهنما و توضیحات سطوح دسترسی پنل‌ها ---------- */} +
+
+
+

+ + 📖 + + توضیحات پنل‌ها و راهنمای سطوح دسترسی +

+

+ مفهوم، اختیارات و محدوده کاری هر سطح دسترسی (کامل / محدود) در هر یک از پنل‌های سامانه +

+
+
+ + مدل دسترسی ماژولار مبتنی بر پنل +
+
+ +
+ {/* کارت پنل تدارکات */} +
+
+
+ + 🛒 + +
+

پنل تدارکات

+ Procurement +
+
+ + خرید و بازرگانی + +
+ +
+
+
+ + سطح کامل (Full) +
+
    +
  • دسترسی کامل مدیریتی به تمامی بخش‌های تدارکات
  • +
  • ثبت و ویرایش پیش‌فاکتورها، شرایط پرداخت و فاکتورها
  • +
  • مدیریت تأمین‌کنندگان، اقلام و تصمیم‌گیری خرید در همه پروژه‌ها
  • +
+
+ +
+
+ + سطح محدود (Limited - متقاضی) +
+
    +
  • فقط امکان ثبت درخواست استعلام قیمت و پیش‌فاکتور اولیه
  • +
  • محدود منحصراً به پروژه‌هایی که کاربر در آن‌ها عضویت دارد
  • +
  • عدم دسترسی به تأییدات نهایی، تغییر قیمت‌ها یا پروژه‌های غیرعضو
  • +
+
+
+
+ + {/* کارت پنل انبار */} +
+
+
+ + 📦 + +
+

پنل انبار

+ Inventory +
+
+ + انبارداری و موجودی + +
+ +
+
+
+ + سطح کامل (Full - مدیر/ناظر) +
+
    +
  • مشاهده موجودی و کاردکس کالا در تمامی انبارها و پروژه‌ها
  • +
  • پایش و نظارت بر گردش کالا در کل سازمان و گزارش‌گیری کلان
  • +
  • بدون امکان تغییر مستقیم موجودی (جهت حفظ سلامت داده و کنترل نظارتی)
  • +
+
+ +
+
+ + سطح محدود (Limited - انباردار کارگاه) +
+
    +
  • دسترسی فقط به انبارهای پروژه‌های عضویت کاربر
  • +
  • امکان ثبت و تغییر موجودی، رسید کالا، حواله مصرف و تطبیق فیزیکی
  • +
  • تفکیک دقیق موجودی و تراکنش‌های انبار بر اساس پروژه
  • +
+
+
+
+ + {/* کارت پنل مدیریت پروژه (PM) */} +
+
+
+ + 📊 + +
+

پنل مدیریت پروژه (PM)

+ Project Management +
+
+ + کنترل پروژه و اجرا + +
+ +
+
+
+ + سطح کامل (Full - مدیر پروژه) +
+
    +
  • مدیریت جامع ساختار شکست کار (WBS)، زمان‌بندی و گانت چارت تعاملی
  • +
  • تعریف و ویرایش فعالیت‌ها، ثبت خط مبنا (Baseline)، کنترل هزینه و منابع
  • +
  • پایش شاخص‌های پیشرفت زمانی و مالی (EVM) و گزارش‌های تحلیلی
  • +
+
+ +
+
+ + سطح محدود (Limited - سرپرست/کارشناس) +
+
    +
  • مشاهده فعالیت‌ها و تسک‌های مربوط به پروژه‌های عضویت کاربر
  • +
  • به‌روزرسانی درصد پیشرفت فیزیکی و وضعیت انجام تسک‌ها
  • +
  • تکمیل چک‌لیست‌های کنترل کیفیت (QC) و ثبت گزارش‌های اجرایی روزانه
  • +
+
+
+
+
+ + {/* نوار توضیحات تکمیلی و راهنمای عمومی */} +
+
+ 👑 +
+ دسترسی ادمین (Admin): +

+ کاربر با نقش ادمین بالاترین سطح دسترسی سیستمی را داشته و به تمامی پنل‌ها و پروژه‌ها دسترسی کامل دارد و نیازی به تخصیص جداگانه پنل‌ها ندارد. +

+
+
+ +
+ 🎯 +
+ کارکرد «پروژه‌های عضویت»: +

+ هرگاه برای کاربری در هر پنلی سطح «محدود» انتخاب شود، دامنه داده‌ها و اختیارات وی دقیقاً به پروژه‌های انتخابی در ستون «پروژه‌های عضویت» محدود خواهد شد. +

+
+
diff --git a/frontend/src/pages/Wizard/Step4Projects.tsx b/frontend/src/pages/Wizard/Step4Projects.tsx index 5e6690a..8842c78 100644 --- a/frontend/src/pages/Wizard/Step4Projects.tsx +++ b/frontend/src/pages/Wizard/Step4Projects.tsx @@ -213,7 +213,11 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig /> ))} {!projects.length && ( -
هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.
+
+ {canAddProject + ? "هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید." + : "هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید."} +
)} {/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */}